diff --git a/.github/workflows/publication-security.yml b/.github/workflows/publication-security.yml index 0040ad5..130f510 100644 --- a/.github/workflows/publication-security.yml +++ b/.github/workflows/publication-security.yml @@ -19,11 +19,6 @@ jobs: with: go-version-file: go.mod cache: true - - name: Verify publication guard regression tests - run: | - python3 deploy/test_publication_security.py - python3 deploy/test_publish_public_repo.py - python3 deploy/test_sync_public_repo.py - name: Reject runtime artifacts and key material across the entire tree run: | python3 deploy/publication_security.py --tree HEAD diff --git a/README.md b/README.md index 4e82827..696159f 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,12 @@ wallets, exchange records, or other network/application-layer information. ## ⬇ Install a Full Node -### One-line install (Ubuntu / Debian) +### Installer security notice -```bash -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-node.sh | sudo bash -``` +Do not pipe an installer fetched from the mutable `main` branch into `sudo bash`. +No signed, immutable installer release is currently published. Install only from +a reviewed source commit whose identity and deployment-tools checksum have been +verified independently; otherwise do not run the installer as root. Supported platforms: **Ubuntu 22.04 · Ubuntu 24.04 LTS · Debian 12 · x86\_64 · ARM64** @@ -185,21 +186,17 @@ Block rewards go directly to your **Telegram wallet**. You need an APRO address ### Step 2 — Install the validator node -```bash -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash -``` +Do not run `install-validator.sh` from mutable `main` as root. A signed, +immutable installer release is not currently published. The installer will: - Prompt for your APRO reward address - Generate a **consensus key** (signs blocks only — cannot move funds) - Configure the node as a `systemd` service and start it -**Non-interactive install (CI / cloud-init):** - -```bash -APEROD_REWARD_ADDRESS= \ - curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash -``` +**CI / cloud-init installation is unavailable** until a signed, immutable +installer release is published. Do not execute a downloaded script with elevated +privileges. ### Step 3 — Register your node 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_audit_daily.go b/api/lpod_audit_daily.go index bf4dd5f..b1acaed 100644 --- a/api/lpod_audit_daily.go +++ b/api/lpod_audit_daily.go @@ -84,6 +84,19 @@ func dailyPositionOutput(p *lpodDailyPositionSum) lpodDailyPosition { Accrued: strconv.FormatUint(p.accrued, 10), Paid: strconv.FormatUint(p.paid, 10), PrincipalReturned: strconv.FormatUint(p.principal, 10)} } +func dailyOpeningBoundaryFailure(start time.Time, anchorTimestamp int64, certifiedWindowLimited, candidateAtSearchFloor, parentHashMatches bool) string { + if !time.Unix(0, anchorTimestamp).Before(start) { + if certifiedWindowLimited && candidateAtSearchFloor { + return "certified search window starts after interval opening" + } + return "opening anchor timestamp is not before interval start" + } + if !parentHashMatches { + return "opening boundary is discontinuous" + } + return "" +} + func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -123,9 +136,6 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { "opening_anchor": nil, "closing_witness": nil, "certificate": nil, "funding": nil, "coverage": map[string]interface{}{"blocks": 0, "vaults": 0, "positions": 0}, "vaults": []lpodDailyVault{}, "positions": []lpodDailyPosition{}, - "burns_napro": map[string]string{ - "protocol_base_fee": "0", "signed_intentional": "0", "avm_gas": "0", "total": "0", - }, } fail := func(reason string) { out["status"] = "incomplete" @@ -298,10 +308,12 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { } } searchFloor := pruneCursor + 1 + certifiedWindowLimited := false if cert.Height >= lpodDailyMaxBlocks { certifiedFloor := cert.Height - lpodDailyMaxBlocks + 2 if certifiedFloor > searchFloor { searchFloor = certifiedFloor + certifiedWindowLimited = true } } if searchFloor > cert.Height { @@ -349,6 +361,10 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { return } if !openingFound || openingHeight == 0 { + if background { + fail("opening boundary not yet certified") + return + } fail("opening anchor is unavailable") return } @@ -362,9 +378,14 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { } return } - if !time.Unix(0, anchorBlock.Header.Timestamp).Before(start) || - openingBlock.Header.PrevHash != anchorHash { - fail("opening boundary is discontinuous") + if reason := dailyOpeningBoundaryFailure( + start, + anchorBlock.Header.Timestamp, + certifiedWindowLimited, + openingHeight == searchFloor, + openingBlock.Header.PrevHash == anchorHash, + ); reason != "" { + fail(reason) return } witnessHeight, witnessBlock, witnessFound, boundaryErr := firstAtOrAfter(end) @@ -376,7 +397,9 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { } return } - if !witnessFound { + preclosing := !witnessFound + preclosingReason := "closing witness not yet present" + if preclosing && !background { tipBlock, _, tipReadErr := readBlock(tipHeight) if tipReadErr == nil && !time.Unix(0, tipBlock.Header.Timestamp).Before(end) { fail("verified certificate predates the closing witness") @@ -385,6 +408,30 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { } return } + if preclosing { + tipBlock, _, tipReadErr := readBlock(tipHeight) + if tipReadErr == nil && !time.Unix(0, tipBlock.Header.Timestamp).Before(end) { + preclosingReason = "verified certificate predates the closing witness" + } + // Before the closing witness is final, use the certificate's last + // authenticated block as a provisional boundary. The worker audits only + // through the preceding block and advances this boundary with each new + // certificate; no daily result is complete until the real witness exists. + witnessHeight = cert.Height + witnessBlock, _, readErr = readBlock(witnessHeight) + if readErr != nil { + if ctx.Err() != nil { + fail(dailyAuditContextReason(ctx.Err())) + } else { + fail(readErr.Error()) + } + return + } + if !time.Unix(0, witnessBlock.Header.Timestamp).Before(end) { + fail("closing witness boundary could not be located") + return + } + } witnessHash := witnessBlock.Hash() witnessTimestamp := witnessBlock.Header.Timestamp anchorTimestamp := anchorBlock.Header.Timestamp @@ -446,7 +493,7 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { var expectedNextRegistry *core.RegistrySnapshot nextHeight := anchorHeight if background && progress.Next != 0 { - if progress.Anchor != anchorHash || progress.Witness != witnessHash || progress.Marker != markerDigest || + if progress.Anchor != anchorHash || (!progress.Preclosing && progress.Witness != witnessHash) || progress.Marker != markerDigest || !bytes.Equal(progress.Prune, pruneCursorRaw) || progress.Next <= anchorHeight || cert.Height == ^uint64(0) || progress.Next > cert.Height+1 { fail("saved boundary or prune binding changed") return @@ -477,6 +524,7 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { checkpointProgress := func(next uint64) { progress.Certificate, progress.Anchor, progress.Witness = cert, anchorHash, witnessHash progress.AnchorHeight, progress.WitnessHeight, progress.Marker = anchorHeight, witnessHeight, markerDigest + progress.Preclosing = preclosing progress.Prune = append([]byte(nil), pruneCursorRaw...) progress.Next, progress.Previous, progress.PriorTS = next, prevHash, priorTS progress.Rewards = rewards.clone() @@ -494,7 +542,11 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { progress.Digest, progress.HaveDigest, progress.Registry = lastAuditDigest, haveLastAuditDigest, expectedNextRegistry } batchStarted := time.Now() - for h := nextHeight; h <= cert.Height; h++ { + processThrough := cert.Height + if preclosing { + processThrough = witnessHeight - 1 + } + for h := nextHeight; h <= processThrough; h++ { // Cooperative budget at verified block boundaries. A single block is not // preemptible; the outer deadline cancels between verifier operations. if background && h > nextHeight && (h-nextHeight >= dailyBatchBlocks || time.Since(batchStarted) >= dailyBatchTime) { @@ -638,6 +690,20 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { } previousBlock = block } + if preclosing { + if err := ctx.Err(); err != nil { + fail(dailyAuditContextReason(err)) + return + } + if !s.dailyBindingValid(batchBinding) { + fail("canonical certificate or finality changed during batch") + return + } + checkpointProgress(witnessHeight) + out["coverage"] = map[string]interface{}{"blocks": included, "vaults": len(vaults), "positions": len(positions)} + fail(preclosingReason) + return + } if included == 0 { fail("interval has no included canonical blocks") return diff --git a/api/lpod_audit_daily_limits_test.go b/api/lpod_audit_daily_limits_test.go new file mode 100644 index 0000000..8a1018c --- /dev/null +++ b/api/lpod_audit_daily_limits_test.go @@ -0,0 +1,9 @@ +package api + +import "testing" + +func TestDailyAuditBlockLimitRemainsFortyThousand(t *testing.T) { + if lpodDailyMaxBlocks != 40_000 { + t.Fatalf("daily audit block limit = %d, want 40000", lpodDailyMaxBlocks) + } +} diff --git a/api/lpod_audit_daily_test.go b/api/lpod_audit_daily_test.go index 538b8ce..f8d53e4 100644 --- a/api/lpod_audit_daily_test.go +++ b/api/lpod_audit_daily_test.go @@ -33,7 +33,7 @@ func TestLPoDAuditDailyFailsClosed(t *testing.T) { wantReason string }{ {name: "missing exact row", certificateHeight: 4, wantReason: "exact block audit record missing"}, - {name: "certificate before closing witness", certificateHeight: 2, wantReason: "predates the closing witness"}, + {name: "certificate before closing witness with missing audit row", certificateHeight: 2, wantReason: "exact block audit record missing"}, {name: "same-height certificate reorg", certificateHeight: 4, reorg: true, wantReason: "not canonical"}, {name: "pruned body", certificateHeight: 4, prune: true, wantReason: "metadata is unavailable"}, {name: "partial first day", certificateHeight: 4, markerHeight: 2, wantReason: "starts after the interval opening"}, @@ -745,7 +745,11 @@ func dailyAuditGet(t *testing.T, srv *api.Server, path string) (int, map[string] t.Fatalf("decode response: %v (%s)", err, recorder.Body.String()) } reason, _ := body["reason"].(string) - if reason == "queued for bounded background verification" || reason == "bounded background verification in progress" { + if reason == "queued for bounded background verification" || + reason == "bounded background verification in progress" || + reason == "opening boundary not yet certified" || + reason == "closing witness not yet present" || + reason == "verified certificate predates the closing witness" { if time.Now().After(deadline) { t.Fatal("background audit did not finish") } diff --git a/api/lpod_daily_job_integrity.go b/api/lpod_daily_job_integrity.go index 19bced2..4b48e3a 100644 --- a/api/lpod_daily_job_integrity.go +++ b/api/lpod_daily_job_integrity.go @@ -94,6 +94,9 @@ func validateDailyJob(j *dailyJob) error { len(p.Vaults)+len(p.Positions) > lpodDailyMaxEntries { return fail() } + if p.Preclosing && j.State == "complete" { + return fail() + } lastExclusive := p.Next if lastExclusive > p.WitnessHeight { lastExclusive = p.WitnessHeight diff --git a/api/lpod_daily_jobs.go b/api/lpod_daily_jobs.go index ceffd53..6ce4b10 100644 --- a/api/lpod_daily_jobs.go +++ b/api/lpod_daily_jobs.go @@ -35,6 +35,7 @@ type dailyProgress struct { Certificate *store.FinalityCertificate Anchor, Witness crypto.Hash32 AnchorHeight, WitnessHeight uint64 + Preclosing bool Marker crypto.Hash32 Prune []byte Next uint64 @@ -163,7 +164,6 @@ func dailyPending(date, reason string) map[string]interface{} { "vaults": []lpodDailyVault{}, "positions": []lpodDailyPosition{}, "coverage": map[string]int{"blocks": 0, "vaults": 0, "positions": 0}, "opening_anchor": nil, "closing_witness": nil, "certificate": nil, "funding": nil, - "burns_napro": map[string]string{"protocol_base_fee": "0", "signed_intentional": "0", "avm_gas": "0", "total": "0"}, } loc, err := time.LoadLocation("Europe/Moscow") if err != nil { @@ -228,13 +228,15 @@ func (s *Server) restLPoDDailyJob(w http.ResponseWriter, r *http.Request) { return } if j != nil && j.Date == date { - missingRewards := dailyRewardsNeedReplay(j.Progress) - if missingRewards || (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) || - (j.State == "complete" && (!s.dailyBindingValid(j.Progress) || - !s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash))) { +missingRewards := j.State != "failed" && dailyRewardsNeedReplay(j.Progress) + invalidComplete := j.State == "complete" && + (!s.dailyBindingValid(j.Progress) || + !s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash)) + if missingRewards || invalidComplete { // Re-attest an older complete report by verifying only its unprocessed - // descendant tail. Invalidated/failed progress is never reused. - if missingRewards || j.State == "failed" || !s.dailyBindingValid(j.Progress) { + // descendant tail. Invalidated progress is never reused. Failed results + // remain visible until an authenticated DELETE explicitly resets them. + if missingRewards || !s.dailyBindingValid(j.Progress) { j.Progress = &dailyProgress{} } j.ID, j.State, j.Result = time.Now().UnixNano(), "queued", nil @@ -345,7 +347,7 @@ func (s *Server) runDailyJobBatch(ctx context.Context) { } if result.Status == "complete" { j.State = "complete" - } else if j.Progress.Next == before { + } else if j.Progress.Next == before && !dailyJobResultIsWaiting(j.Result) { j.State = "failed" } d.mu.Lock() @@ -363,3 +365,22 @@ func (s *Server) runDailyJobBatch(ctx context.Context) { } } } + +func dailyJobResultIsWaiting(raw []byte) bool { + var result struct { + Status string `json:"status"` + Reason string `json:"reason"` + } + if json.Unmarshal(raw, &result) != nil || result.Status != "incomplete" { + return false + } + switch result.Reason { + case "opening boundary not yet certified", + "closing witness not yet present", + "verified certificate predates the closing witness", + "bounded background verification in progress": + return true + default: + return false + } +} diff --git a/api/lpod_daily_jobs_test.go b/api/lpod_daily_jobs_test.go index 72c93cb..b21e27b 100644 --- a/api/lpod_daily_jobs_test.go +++ b/api/lpod_daily_jobs_test.go @@ -104,6 +104,115 @@ func TestDailyJobRestartBudgetAndQueue(t *testing.T) { } } +func TestDailyJobPrecomputesBeforeClosingWitnessAndFinalizesAfterIt(t *testing.T) { + db, s, path, _ := makeDailyAuditBenchmarkFixtureWithTail(t, 2, 0, 0, 1, 0, 0, 1) + defer func() { _ = db.Close() }() + + earlyHash, found, err := db.GetCanonicalHash(2) + if err != nil || !found { + t.Fatalf("load pre-close certificate hash: found=%v err=%v", found, err) + } + closingHash, found, err := db.GetCanonicalHash(3) + if err != nil || !found { + t.Fatalf("load closing witness hash: found=%v err=%v", found, err) + } + earlyCertificate, err := json.Marshal(store.FinalityCertificate{ + Version: 1, Height: 2, BlockHash: earlyHash, + Votes: []store.FinalityVote{{Signature: []byte{1}}}, + }) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + rawDB, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + if err := rawDB.Put([]byte("consensus/finality_certificate/v1"), earlyCertificate, nil); err != nil { + _ = rawDB.Close() + t.Fatal(err) + } + if err := rawDB.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + s.SetStore(db) + persistedCertificate, err := db.LoadFinalityCertificate() + if err != nil || persistedCertificate == nil || persistedCertificate.Height != 2 || + persistedCertificate.BlockHash != earlyHash { + t.Fatalf("pre-close certificate fixture was not persisted: cert=%+v err=%v", persistedCertificate, err) + } + oracleHeight, oracleHash := uint64(2), earlyHash + s.SetLPoDConfig(nil, func(height uint64, hash crypto.Hash32) bool { + ok := height == oracleHeight && hash == oracleHash + if !ok { + t.Logf("finality oracle mismatch: got height=%d hash=%x want height=%d hash=%x", height, hash, oracleHeight, oracleHash) + } + return ok + }) + enqueueDailyTest(t, s) + + s.runDailyJobBatch(context.Background()) + j, err := s.loadDailyJob() + if err != nil || j == nil || j.State != "queued" || j.Progress == nil || + !j.Progress.Preclosing || j.Progress.Included != 1 || + !dailyJobResultIsWaiting(j.Result) { + t.Fatalf("pre-close work did not remain safely queued: job=%+v err=%v", j, err) + } + + if err := db.SaveFinalityCertificate(store.FinalityCertificate{ + Version: 1, Height: 3, BlockHash: closingHash, + Votes: []store.FinalityVote{{Signature: []byte{1}}}, + }); err != nil { + t.Fatal(err) + } + oracleHeight, oracleHash = 3, closingHash + for attempt := 0; attempt < 4; attempt++ { + s.runDailyJobBatch(context.Background()) + j, err = s.loadDailyJob() + if err != nil { + t.Fatal(err) + } + if j.State == "complete" { + break + } + } + var result struct { + Status string `json:"status"` + Coverage struct { + Blocks int `json:"blocks"` + } `json:"coverage"` + } + if err := json.Unmarshal(j.Result, &result); err != nil || + j.State != "complete" || j.Progress.Preclosing || + result.Status != "complete" || result.Coverage.Blocks != 2 { + t.Fatalf("closing certificate did not finalize the complete daily audit: job=%+v result=%+v err=%v", j, result, err) + } +} + +func TestDailyJobResultWaitingVocabulary(t *testing.T) { + for _, reason := range []string{ + "opening boundary not yet certified", + "closing witness not yet present", + "verified certificate predates the closing witness", + "bounded background verification in progress", + } { + raw, _ := json.Marshal(map[string]string{"status": "incomplete", "reason": reason}) + if !dailyJobResultIsWaiting(raw) { + t.Errorf("expected %q to remain queued", reason) + } + } + if dailyJobResultIsWaiting([]byte(`{"status":"incomplete","reason":"block audit verification failed"}`)) || + dailyJobResultIsWaiting([]byte(`{"status":"complete","reason":""}`)) { + t.Fatal("non-waiting result was treated as retryable") + } +} + func TestDailyJobReorgAndFinalityFailClosed(t *testing.T) { for _, change := range []string{"predecessor", "certificate", "finality", "prune"} { t.Run(change, func(t *testing.T) { @@ -120,7 +229,9 @@ func TestDailyJobReorgAndFinalityFailClosed(t *testing.T) { if change == "certificate" { height = j.Progress.Certificate.Height } - db.Close() + if err := db.Close(); err != nil { + t.Fatal(err) + } raw, err := leveldb.OpenFile(path, nil) if err != nil { t.Fatal(err) @@ -196,7 +307,7 @@ func TestDailyJobCancellationAndShutdown(t *testing.T) { } } -func TestDailyJobStorageBudgetAndRetry(t *testing.T) { +func TestDailyJobStorageBudgetAndFailedResultRequiresExplicitRetry(t *testing.T) { db, s, _, _ := makeDailyAuditBenchmarkFixture(t, 2, 0, 0, 1, 0, 0) defer db.Close() j := enqueueDailyTest(t, s) @@ -205,8 +316,17 @@ func TestDailyJobStorageBudgetAndRetry(t *testing.T) { if err := s.saveDailyJob(&oversize); err == nil { t.Fatal("accepted oversized job") } - j.State, j.Progress = "failed", nil - j.Result, _ = json.Marshal(dailyPending(j.Date, "closing witness not yet present")) + failedAnchor := crypto.HashBytes([]byte("failed daily audit anchor")) + failedProgress := &dailyProgress{ + Certificate: &store.FinalityCertificate{ + Version: 1, Height: 2, BlockHash: crypto.HashBytes([]byte("failed daily certificate")), + }, + Anchor: failedAnchor, Witness: crypto.HashBytes([]byte("failed daily witness")), + AnchorHeight: 0, WitnessHeight: 1, Marker: crypto.HashBytes([]byte("failed daily marker")), + Next: 1, Previous: crypto.HashBytes([]byte("failed daily previous")), + } + j.State, j.Progress = "failed", failedProgress + j.Result, _ = json.Marshal(dailyPending(j.Date, "exact block audit record missing at height 20")) j.Updated = time.Now().Add(-time.Minute).Unix() raw, err := s.encodeDailyJob(j) if err != nil { @@ -215,7 +335,30 @@ func TestDailyJobStorageBudgetAndRetry(t *testing.T) { if err := db.PutMetaSync(dailyJobKey, raw); err != nil { t.Fatal(err) } - enqueueDailyTest(t, s) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, authenticatedDailyAuditRequest()) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "exact block audit record missing at height 20") { + t.Fatalf("failed result was hidden by automatic retry: %d %s", rec.Code, rec.Body.String()) + } + current, err := s.loadDailyJob() + if err != nil || current == nil || current.State != "failed" || current.ID != j.ID { + t.Fatalf("GET mutated the failed job: %+v %v", current, err) + } + + reset := authenticatedDailyAuditRequest() + reset.Method = http.MethodDelete + rec = httptest.NewRecorder() + s.ServeHTTP(rec, reset) + if rec.Code != http.StatusOK { + t.Fatalf("explicit retry reset failed: %d %s", rec.Code, rec.Body.String()) + } + if cleared, err := s.loadDailyJob(); err != nil || cleared != nil { + t.Fatalf("explicit reset retained failed job: %+v %v", cleared, err) + } + retry := enqueueDailyTest(t, s) + if retry.ID == j.ID { + t.Fatal("explicit retry reused the failed job identity") + } } // Production retains only 256 finalized heights in memory and restart may @@ -298,3 +441,70 @@ func TestDailyJobAdvancingCertificateOracleDoesNotResetCursor(t *testing.T) { } t.Fatal("advancing oracle prevented completion") } + +func TestDailyOpeningBoundaryFailureDistinguishesBoundedWindow(t *testing.T) { + start := time.Unix(1_800_000_000, 0) + before := start.Add(-time.Second).UnixNano() + after := start.Add(time.Second).UnixNano() + tests := []struct { + name string + anchorTimestamp int64 + certifiedWindowLimited bool + candidateAtSearchFloor bool + parentHashMatches bool + want string + }{ + { + name: "bounded certificate window starts after opening", + anchorTimestamp: after, + certifiedWindowLimited: true, + candidateAtSearchFloor: true, + parentHashMatches: true, + want: "certified search window starts after interval opening", + }, + { + name: "actual parent mismatch", + anchorTimestamp: before, + parentHashMatches: false, + want: "opening boundary is discontinuous", + }, + { + name: "anchor timestamp is not before interval", + anchorTimestamp: after, + want: "opening anchor timestamp is not before interval start", + }, + { + name: "valid opening link", + anchorTimestamp: before, + parentHashMatches: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dailyOpeningBoundaryFailure(start, tt.anchorTimestamp, tt.certifiedWindowLimited, tt.candidateAtSearchFloor, tt.parentHashMatches) + if got != tt.want { + t.Fatalf("dailyOpeningBoundaryFailure() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDailyAuditIncompleteOmitsUnverifiedBurnTotals(t *testing.T) { + s := &Server{} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/lpod/audit/daily?date=2026-11-05", nil) + s.restLPoDAuditDaily(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("incomplete audit status = %d, want 200: %s", recorder.Code, recorder.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode incomplete audit: %v", err) + } + if _, ok := body["burns_napro"]; ok { + t.Fatal("incomplete audit exposed unverified burn totals") + } + if _, ok := dailyPending("2026-11-05", "queued")["burns_napro"]; ok { + t.Fatal("pending response exposed unverified burn totals") + } +} diff --git a/api/lpod_earnings_outputs_test.go b/api/lpod_earnings_outputs_test.go index c99ff66..8a13b74 100644 --- a/api/lpod_earnings_outputs_test.go +++ b/api/lpod_earnings_outputs_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -284,7 +285,9 @@ func seedSyntheticEarningsRows( } if err := iter.Error(); err != nil { iter.Release() - raw.Close() + if closeErr := raw.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } t.Fatalf("scan synthetic earnings index: %v", err) } iter.Release() @@ -304,14 +307,18 @@ func seedSyntheticEarningsRows( key = append(key, row.TxHash[:]...) value, err := json.Marshal(row) if err != nil { - raw.Close() + if closeErr := raw.Close(); closeErr != nil { + err = errors.Join(err, 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 { + err = errors.Join(err, closeErr) + } t.Fatalf("write synthetic earnings rows: %v", err) } if err := raw.Close(); err != nil { @@ -322,7 +329,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.go b/api/rest.go index faee11a..fa59bfd 100644 --- a/api/rest.go +++ b/api/rest.go @@ -730,9 +730,8 @@ type AddressUTXO struct { AmountCommitHex string `json:"amount_commit_hex"` EncAmountHex string `json:"enc_amount_hex"` BlockHeight uint64 `json:"block_height"` - // AmountNapr is the decrypted output amount in nAPRO. Populated when a - // view key is available (via X-View-Key header or node.yaml view_key). - // Null when no view key is configured. + // AmountNapr is the decrypted output amount in nAPRO. Populated only when + // the request supplies a view key in the X-View-Key header. AmountNapr *uint64 `json:"amount_napr,string"` // IsCoinbase is true when this UTXO belongs to a per-block validator // reward (coinbase) transaction. Wallet history displays must filter @@ -745,8 +744,8 @@ type AddressUTXO struct { // Without a view key: returns transparent / mint outputs (OneTimePub matches // spend pub directly) with amount_napr=null. // -// With a view key (X-View-Key header or view_key in node.yaml): also -// discovers stealth outputs via ECDH scan and sets amount_napr for them. +// With an explicit X-View-Key header: also discovers stealth outputs via +// ECDH scan and sets amount_napr for them. // // Note on transparent/mint outputs: BuildMintTx leaves TxPubKey and EncAmount // as zero — there is no ECDH shared secret to derive, so amount_napr remains @@ -765,15 +764,14 @@ func (s *Server) restAddressUTXOs(w http.ResponseWriter, r *http.Request, addrSt return } - // Resolve view key: X-View-Key header is the only accepted source. + // Resolve view key: X-View-Key header is the only accepted source. Never + // use a node-configured key here: this endpoint is unauthenticated, so a + // configured key would disclose wallet ownership and amounts to any caller. // The view_key_hex query parameter was removed (F5 security fix) because // query parameters are logged by reverse proxies, access logs, and browser // history, leaking the private view scalar to anyone with log access. viewKeyHex := r.Header.Get("X-View-Key") requestViewKey := viewKeyHex != "" - if viewKeyHex == "" { - viewKeyHex = s.nodeViewKeyHex - } var viewPriv *crypto.Scalar32 var viewKey []byte @@ -1021,13 +1019,15 @@ func (s *Server) restAddressScan(w http.ResponseWriter, r *http.Request, addrStr // { "outputs": [...], "from_height": N, "next_height": M, "tip_height": T } type ScanOutput struct { - TxHash string `json:"tx_hash"` - OutIdx int `json:"out_idx"` - BlockHeight uint64 `json:"block_height"` - OneTimePubHex string `json:"one_time_pub_hex"` - TxPubKeyHex string `json:"tx_pub_key_hex"` - AmountCommitHex string `json:"amount_commit_hex"` - EncAmountHex string `json:"enc_amount_hex"` + TxHash string `json:"tx_hash"` + OutIdx int `json:"out_idx"` + TxVersion uint8 `json:"tx_version"` + BlockHeight uint64 `json:"block_height"` + OneTimePubHex string `json:"one_time_pub_hex"` + TxPubKeyHex string `json:"tx_pub_key_hex"` + AmountCommitHex string `json:"amount_commit_hex"` + EncAmountHex string `json:"enc_amount_hex"` + AmountBinding *crypto.AmountBinding `json:"amount_binding,omitempty"` // IsCoinbase is true when this output belongs to the first (coinbase) // transaction in its block. Wallet clients must filter these out so // per-block validator rewards do not appear in user history. @@ -1093,11 +1093,13 @@ func (s *Server) restScanOutputs(w http.ResponseWriter, r *http.Request) { outputs = append(outputs, ScanOutput{ TxHash: hashHex, OutIdx: j, + TxVersion: uint8(tx.Version), BlockHeight: h, OneTimePubHex: fmt.Sprintf("%x", out.OneTimePub[:]), TxPubKeyHex: fmt.Sprintf("%x", out.TxPubKey[:]), AmountCommitHex: fmt.Sprintf("%x", out.AmountCommit[:]), EncAmountHex: fmt.Sprintf("%x", out.EncAmount[:]), + AmountBinding: out.AmountBinding, IsCoinbase: isCoinbase, }) } @@ -1532,10 +1534,15 @@ func (s *Server) restFeeEstimate(w http.ResponseWriter, r *http.Request) { baseFeePerByte = core.MinBaseFeePerByte } - // Typical transfer: 1 input, 2 outputs (payment + change). + // Typical transfer: 1 input, 2 outputs (payment + change), using the version + // required by the next block's active consensus rules. const typicalInputs = 1 const typicalOutputs = 2 - estimatedFeeNapro := core.ExportedEstimateFee(typicalInputs, typicalOutputs, baseFeePerByte) + txVersion := core.TxVersionCommitmentBinding + if s.mempool != nil { + txVersion = s.mempool.NextSpendVersion() + } + estimatedFeeNapro := core.ExportedEstimateFeeForVersion(txVersion, typicalInputs, typicalOutputs, baseFeePerByte) txSizeBytes := int(estimatedFeeNapro / baseFeePerByte) writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -1543,6 +1550,7 @@ func (s *Server) restFeeEstimate(w http.ResponseWriter, r *http.Request) { "estimated_fee_napro": estimatedFeeNapro, "estimated_fee_apro": float64(estimatedFeeNapro) / 1e8, "tx_size_bytes": txSizeBytes, + "transaction_version": uint8(txVersion), }) } @@ -2390,6 +2398,18 @@ func (s *Server) restUTXO(w http.ResponseWriter, r *http.Request) { "exists": true, "block_height": blockHeight, } + binding, version, found := s.amountBindingForOutput(txHash, outIdx, blockHeight) + if found { + resp["tx_version"] = uint8(version) + required := version == core.TxVersionAmountBinding || version == core.TxVersionAVMAmountBinding + if required && binding == nil { + writeJSONError(w, http.StatusServiceUnavailable, "amount-binding proof unavailable for active output") + return + } + if binding != nil { + resp["amount_binding"] = binding + } + } // Once CLSAG is active, absence from su/ cannot prove that a particular // output is unspent: v5 intentionally hides the real ring member. Owners // must derive the output key image and query /api/v1/keyimage/{ki}/is-spent. @@ -2454,6 +2474,28 @@ func (s *Server) restUTXO(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func outputIndexInRange(outIndex uint32, outputCount int) bool { + return outputCount >= 0 && uint64(outIndex) < uint64(outputCount) +} + +func (s *Server) amountBindingForOutput(txHash crypto.Hash32, outIndex uint32, height uint64) (*crypto.AmountBinding, core.TxVersion, bool) { + if s.chain == nil { + return nil, 0, false + } + block := s.chain.GetByHeight(height) + if block == nil { + return nil, 0, false + } + for i := range block.Txs { + tx := &block.Txs[i] + if tx.Hash() != txHash || !outputIndexInRange(outIndex, len(tx.Outputs)) { + continue + } + return tx.Outputs[outIndex].AmountBinding, tx.Version, true + } + return nil, 0, false +} + func (s *Server) restUTXOSnapshot(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { @@ -2513,7 +2555,7 @@ func (s *Server) restUTXOSnapshot(w http.ResponseWriter, r *http.Request) { spentStatus = "unknown_key_image_required" } } - writeJSON(w, http.StatusOK, map[string]interface{}{ + resp := map[string]interface{}{ "tx_hash": fmt.Sprintf("%x", txHash[:]), "out_idx": outIndex, "amount_commit_hex": fmt.Sprintf("%x", utxo.AmountCommit[:]), "one_time_pub_hex": fmt.Sprintf("%x", utxo.OneTimePub[:]), @@ -2522,7 +2564,20 @@ func (s *Server) restUTXOSnapshot(w http.ResponseWriter, r *http.Request) { "exists": true, "block_height": utxo.BlockHeight, "spent_status": spentStatus, "snapshot_id": lease.entry.id, "checkpoint_height": lease.entry.height, "checkpoint_hash": fmt.Sprintf("%x", lease.entry.hash[:]), - }) + } + binding, version, found := s.amountBindingForOutput(txHash, outIndex, utxo.BlockHeight) + if found { + resp["tx_version"] = uint8(version) + required := version == core.TxVersionAmountBinding || version == core.TxVersionAVMAmountBinding + if required && binding == nil { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_QUERY_FAILED", "amount-binding proof unavailable") + return + } + if binding != nil { + resp["amount_binding"] = binding + } + } + writeJSON(w, http.StatusOK, resp) } func (s *Server) restAdminStakeDeposit(w http.ResponseWriter, r *http.Request) { @@ -2904,6 +2959,11 @@ func (s *Server) restAdminMint(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "invalid address: "+err.Error()) return } + if s.rewardAddress != "" && req.Address == s.rewardAddress { + writeJSONError(w, http.StatusBadRequest, + "cannot admin-mint to the validator reward address: the per-block coinbase reward would collide with the same one-time pub") + return + } if math.IsNaN(req.AmountAPR) || math.IsInf(req.AmountAPR, 0) || req.AmountAPR <= 0 || req.AmountAPR > 10_000_000_000 { writeJSONError(w, http.StatusBadRequest, "amount_apr must be > 0 and <= 10000000000") return @@ -3185,6 +3245,7 @@ func (s *Server) restStatus(w http.ResponseWriter, r *http.Request) { "ok": true, "height": height, "syncing": syncing, + "next_restart_at": nil, "syncing_height": syncingHeight, "tip_height": tipHeight, "utxo_rebuilding": atomic.LoadInt32(&s.utxoRebuilding) == 1, @@ -3196,6 +3257,9 @@ func (s *Server) restStatus(w http.ResponseWriter, r *http.Request) { "utxo_store_missing": atomic.LoadInt64(&s.utxoStoreMissing), "burn_address": crypto.MainnetBurnAddress().String(), } + if nextRestartAt := s.nextNightlyRestartAt(time.Now()); nextRestartAt != nil { + resp["next_restart_at"] = nextRestartAt.UTC().Format(time.RFC3339) + } // Use the live whitelist from the P2P layer when wired; fall back to the // startup snapshot so /api/v1/status is never stale after live edits. var wl []string 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/rest_avm.go b/api/rest_avm.go index 95cd021..f73f2af 100644 --- a/api/rest_avm.go +++ b/api/rest_avm.go @@ -52,6 +52,10 @@ func (s *Server) avmStatus() map[string]interface{} { } ready := atomic.LoadInt32(&s.syncing) == 0 active := ready && s.avmActivationHeight > 0 && next >= s.avmActivationHeight + transactionVersion := core.TxVersionAVM + if s.mempool != nil { + transactionVersion = s.mempool.NextAVMVersion() + } return map[string]interface{}{ "available": s.avmStore != nil, "ready": ready, @@ -59,7 +63,7 @@ func (s *Server) avmStatus() map[string]interface{} { "activation_height": s.avmActivationHeight, "chain_height": height, "next_block_height": next, - "transaction_version": uint8(core.TxVersionAVM), + "transaction_version": uint8(transactionVersion), "gas_price_napr": core.AVMGasPriceNAPR, "min_gas_limit": core.AVMMinGasLimit, "max_gas_limit": core.AVMMaxGasLimit, @@ -405,8 +409,8 @@ func (s *Server) restAVMTransaction(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "invalid signed transaction JSON: "+err.Error()) return } - if req.Tx.Version != core.TxVersionAVM || req.Tx.AVM == nil { - writeJSONError(w, http.StatusBadRequest, "only an already locally signed v6 AVM transaction is accepted") + if !req.Tx.IsAVM() || req.Tx.AVM == nil { + writeJSONError(w, http.StatusBadRequest, "only an already locally signed AVM transaction is accepted") return } s.broadcastTxMu.RLock() diff --git a/api/rest_avm_test.go b/api/rest_avm_test.go index b0db0e5..89d959e 100644 --- a/api/rest_avm_test.go +++ b/api/rest_avm_test.go @@ -83,7 +83,7 @@ func TestAVMNonceAndReceiptLookup(t *testing.T) { } } -func TestAVMSubmitRejectsUnsignedOrNonV6Transactions(t *testing.T) { +func TestAVMSubmitRejectsNonAVMTransactions(t *testing.T) { srv, _ := buildChainServer(t, 0) srv.SetReady() raw, _ := json.Marshal(map[string]interface{}{"tx": map[string]interface{}{"Version": 5}}) @@ -93,7 +93,7 @@ func TestAVMSubmitRejectsUnsignedOrNonV6Transactions(t *testing.T) { if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } - if !bytes.Contains(rec.Body.Bytes(), []byte("locally signed v6")) { +if !bytes.Contains(rec.Body.Bytes(), []byte("locally signed AVM")) { t.Fatalf("unexpected body: %s", rec.Body.String()) } } diff --git a/api/rest_output_index_test.go b/api/rest_output_index_test.go new file mode 100644 index 0000000..f545f1f --- /dev/null +++ b/api/rest_output_index_test.go @@ -0,0 +1,27 @@ +package api + +import "testing" + +func TestOutputIndexInRange(t *testing.T) { + tests := []struct { + name string + outIndex uint32 + outputSize int + want bool + }{ + {name: "first output", outIndex: 0, outputSize: 1, want: true}, + {name: "index at end", outIndex: 1, outputSize: 1, want: false}, + {name: "empty outputs", outIndex: 0, outputSize: 0, want: false}, + {name: "negative output count", outIndex: 0, outputSize: -1, want: false}, + {name: "maximum uint32", outIndex: ^uint32(0), outputSize: 1, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := outputIndexInRange(tt.outIndex, tt.outputSize); got != tt.want { + t.Fatalf("outputIndexInRange(%d, %d) = %t, want %t", + tt.outIndex, tt.outputSize, got, tt.want) + } + }) + } +} diff --git a/api/rest_status_nightly_restart_test.go b/api/rest_status_nightly_restart_test.go new file mode 100644 index 0000000..01b1a73 --- /dev/null +++ b/api/rest_status_nightly_restart_test.go @@ -0,0 +1,68 @@ +package api_test + +import ( + "net/http" + "testing" + "time" +) + +func TestStatus_NextRestartAtIsNullWhenSchedulerIsInactive(t *testing.T) { + srv, _ := newTestServer(t) + if err := srv.SetNightlyRestartSchedule(""); err != nil { + t.Fatalf("SetNightlyRestartSchedule: %v", err) + } + + code, resp := getStatus(t, srv) + if code != http.StatusOK { + t.Fatalf("status = %d, want 200", code) + } + value, present := resp["next_restart_at"] + if !present { + t.Fatal("next_restart_at field is missing") + } + if value != nil { + t.Fatalf("next_restart_at = %#v, want null while scheduler is inactive", value) + } +} + +func TestStatus_NextRestartAtIsRFC3339UTC(t *testing.T) { + srv, _ := newTestServer(t) + if err := srv.SetNightlyRestartSchedule("04:00"); err != nil { + t.Fatalf("SetNightlyRestartSchedule: %v", err) + } + + before := time.Now().UTC() + code, resp := getStatus(t, srv) + if code != http.StatusOK { + t.Fatalf("status = %d, want 200", code) + } + raw, ok := resp["next_restart_at"].(string) + if !ok { + t.Fatalf("next_restart_at = %#v, want RFC3339 string", resp["next_restart_at"]) + } + next, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("next_restart_at %q is not RFC3339: %v", raw, err) + } + if next.Location() != time.UTC || raw != next.UTC().Format(time.RFC3339) { + t.Fatalf("next_restart_at = %q, want canonical RFC3339 UTC ending in Z", raw) + } + if next.Hour() != 4 || next.Minute() != 0 || next.Second() != 0 { + t.Fatalf("next_restart_at = %s, want the configured 04:00 UTC", next) + } + if !next.After(before) || next.After(before.Add(24*time.Hour)) { + t.Fatalf("next_restart_at = %s, want the next occurrence within 24 hours of %s", next, before) + } +} + +func TestStatus_NextRestartAtRejectsInvalidSchedule(t *testing.T) { + srv, _ := newTestServer(t) + if err := srv.SetNightlyRestartSchedule("25:00"); err == nil { + t.Fatal("SetNightlyRestartSchedule accepted an invalid time") + } + + _, resp := getStatus(t, srv) + if value, present := resp["next_restart_at"]; !present || value != nil { + t.Fatalf("next_restart_at = %#v (present=%t), want null after rejected configuration", value, present) + } +} diff --git a/api/rest_test.go b/api/rest_test.go index 9ba2d93..25deb58 100644 --- a/api/rest_test.go +++ b/api/rest_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -799,6 +800,35 @@ func TestREST_AdminMint_ZeroAmount(t *testing.T) { } } +func TestREST_AdminMint_RejectsValidatorRewardAddressBeforeScheduling(t *testing.T) { + srv, _ := newTestServer(t) + wallet, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + rewardAddress := crypto.EncodeAddress(crypto.MainnetByte, wallet.Spend.Public, wallet.View.Public) + srv.SetRewardAddress(string(rewardAddress)) + scheduled := false + srv.SetMintScheduler(func(idempotencyKey, addr string, amountNAPR uint64, timeout time.Duration) (string, uint64, error) { + scheduled = true + return "", 0, nil + }) + + body := []byte(`{"idempotency_key":"reward-collision","address":"` + string(rewardAddress) + `","amount_apr":1}`) + code, response := restLocalPostJSON(t, srv, "/api/v1/admin/mint", body) + if code != http.StatusBadRequest { + t.Fatalf("expected 400 for validator reward address, got %d: %v", code, response) + } + message, _ := response["error"].(string) + if !strings.Contains(message, "validator reward address") || + !strings.Contains(message, "one-time pub") { + t.Fatalf("expected readable reward-address collision, got %q", message) + } + if scheduled { + t.Fatal("reward-address mint was passed to the scheduler") + } +} + func TestREST_NetworkBanByAddr_LocalOnly(t *testing.T) { srv, _ := newTestServer(t) srv.SetBanLiftFunc(func(string) bool { return true }) // always succeeds diff --git a/api/rest_utxo_index_conflict_test.go b/api/rest_utxo_index_conflict_test.go new file mode 100644 index 0000000..0bbdfb6 --- /dev/null +++ b/api/rest_utxo_index_conflict_test.go @@ -0,0 +1,73 @@ +package api_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// Exercise the real outpoint endpoint with cryptographically owned outputs, +// not duplicate database descriptions or a mocked successful wallet RPC. +func TestREST_UTXOIndexConflictDistinctOwnedOutputs(t *testing.T) { + for _, onlyBlindIndex := range []bool{false, true} { + t.Run(fmt.Sprintf("only_blind_index=%t", onlyBlindIndex), func(t *testing.T) { + srv, utxos := buildUTXOServer(t) + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + hash := crypto.Hash32{0x71} + for index, amount := range []uint64{5_000_000_000, 200_000_000} { + path := fmt.Sprintf("/api/v1/utxo/%x/%d", hash, index) + if onlyBlindIndex && index == 0 { + code, _ := restGet(t, srv, path) + if code != http.StatusNotFound { + t.Fatalf("mis-recorded mint index: got %d, want 404", code) + } + continue + } + output, err := crypto.CreateStealthOutput(keys.Spend.Public, keys.View.Public) + if err != nil { + t.Fatal(err) + } + var blind crypto.BlindFactor + blind[0] = byte(index + 11) + commitment, err := crypto.Commit(amount, blind) + if err != nil { + t.Fatal(err) + } + owned, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, output.TxPubKey, output.OneTimePub) + if err != nil || owned == nil || *owned != output.HsScalar { + t.Fatalf("output %d is not owned by the funded wallet: %v", index, err) + } + utxos.Add(&core.UTXO{ + TxHash: hash, OutputIndex: uint32(index), BlockHeight: 1, + AmountCommit: commitment, OneTimePub: output.OneTimePub, + TxPubKey: output.TxPubKey, EncAmount: core.EncryptAmount(amount, &output.HsScalar), + }) + code, body := restGet(t, srv, path) + if code != http.StatusOK || body["exists"] != true || + body["tx_hash"] != fmt.Sprintf("%x", hash) || body["out_idx"] != float64(index) || + body["amount_commit_hex"] != fmt.Sprintf("%x", commitment) || + body["one_time_pub_hex"] != fmt.Sprintf("%x", output.OneTimePub) { + t.Fatalf("owned output %d was not independently returned: HTTP %d, %v", index, code, body) + } + } + // Check again after all outputs were added: sharing a transaction + // hash must not let the second index overwrite the first. + for index := 0; index < 2; index++ { + code, _ := restGet(t, srv, fmt.Sprintf("/api/v1/utxo/%x/%d", hash, index)) + want := http.StatusOK + if onlyBlindIndex && index == 0 { + want = http.StatusNotFound + } + if code != want { + t.Fatalf("final output %d: HTTP %d, want %d", index, code, want) + } + } + }) + } +} diff --git a/api/rest_utxo_viewkey_test.go b/api/rest_utxo_viewkey_test.go index e346ac9..f9e7612 100644 --- a/api/rest_utxo_viewkey_test.go +++ b/api/rest_utxo_viewkey_test.go @@ -2,7 +2,7 @@ package api_test // Tests for the view-key extension to GET /api/v1/address/{addr}/utxos: // - Stealth UTXO discovered + amount_napr populated via view_key_hex query param -// - Stealth UTXO discovered via node-configured view key (SetNodeViewKey) +// - Stealth UTXO is not exposed without an explicit request view key // - Transparent/mint UTXO: matched without view key, amount_napr null // - Wrong view key: stealth UTXO not discovered @@ -105,10 +105,10 @@ func TestREST_AddressUTXOs_RejectsMalformedViewKeyHeader(t *testing.T) { } } -// TestREST_AddressUTXOs_StealthDiscoveredByNodeViewKey verifies that a stealth -// output is discovered and decoded when the view key is configured on the node -// via SetNodeViewKey (as it would be from node.yaml view_key), with no query param. -func TestREST_AddressUTXOs_StealthDiscoveredByNodeViewKey(t *testing.T) { +// TestREST_AddressUTXOs_DoesNotUseNodeConfiguredViewKey verifies that the +// unauthenticated endpoint does not expose stealth outputs unless the caller +// explicitly supplies its own X-View-Key header. +func TestREST_AddressUTXOs_DoesNotUseNodeConfiguredViewKey(t *testing.T) { srv, utxos := buildUTXOServer(t) wk, err := crypto.GenerateWalletKeys() @@ -117,30 +117,17 @@ func TestREST_AddressUTXOs_StealthDiscoveredByNodeViewKey(t *testing.T) { } addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - // Configure view key on the server (simulates node.yaml view_key entry). - srv.SetNodeViewKey(hex.EncodeToString(wk.View.Private[:])) - const wantAmount = uint64(1_000_000_000) // 10 APRO makeStealthUTXO(t, utxos, wk.Spend.Public, wk.View.Public, wantAmount, 0x02) - // No view_key_hex query param — node config is used automatically. code, resp := restGet(t, srv, "/api/v1/address/"+string(addr)+"/utxos") if code != http.StatusOK { t.Fatalf("status = %d, want 200", code) } list, _ := resp["utxos"].([]interface{}) - if len(list) != 1 { - t.Fatalf("utxos count = %d, want 1 (node-configured view key must discover stealth UTXO)", len(list)) - } - entry := list[0].(map[string]interface{}) - - gotAmt, ok := entry["amount_napr"].(string) - if !ok || entry["amount_napr"] == nil { - t.Fatalf("amount_napr = %v, want %d (node-configured view key must decode amount)", entry["amount_napr"], wantAmount) - } - if gotAmt != fmt.Sprint(wantAmount) { - t.Errorf("amount_napr = %s, want %d", gotAmt, wantAmount) + if len(list) != 0 { + t.Fatalf("unauthenticated request exposed %d stealth UTXOs; expected none", len(list)) } } diff --git a/api/rpc_scan_utxos.go b/api/rpc_scan_utxos.go index e505bc3..7214f17 100644 --- a/api/rpc_scan_utxos.go +++ b/api/rpc_scan_utxos.go @@ -11,11 +11,10 @@ import ( // ─── apr_scanUTXOs ─────────────────────────────────────────────────────────── // -// Scans the entire chain for stealth outputs belonging to a wallet identified -// by (spendPub, viewPriv). Used by the Node.js layer as a fallback when -// utxo_blinds DB records are missing — the returned UTXOs can be spent with -// blind_hex="" because the Go spending path derives the blind deterministically -// from the ECDH shared secret (HsScalar + amount). +// Scans the chain for stealth outputs belonging to a wallet identified by +// (spendPub, viewPriv). Version-13 bound amounts require the wallet's spend key, +// which this RPC deliberately does not accept; clients must scan those outputs +// locally rather than sending a spend key to the node. type scanUTXOsParams struct { SpendPubHex string `json:"spend_pub_hex"` // 32-byte spend public key hex @@ -81,7 +80,10 @@ func (s *Server) aprScanUTXOs(params json.RawMessage) (interface{}, error) { "tip_height": tip, }, nil } - owned := scanner.ScanChain(s.chain, start, end) + owned, err := scanner.ScanChainVerified(s.chain, start, end) + if err != nil { + return nil, fmt.Errorf("wallet scan: %w; amount-bound outputs require local scanning with the spend key", err) + } // Check which UTXOs are still unspent (UTXO set membership) result := make([]scannedUTXO, 0, len(owned)) diff --git a/api/rpc_wallet.go b/api/rpc_wallet.go index ff82a14..310b6e8 100644 --- a/api/rpc_wallet.go +++ b/api/rpc_wallet.go @@ -117,7 +117,7 @@ func (s *Server) aprWalletSend(ctx context.Context, rawParams json.RawMessage) ( // ── 3. Build OwnedUTXO slice from caller-provided list ──────────────────── ownedUTXOs := make([]core.OwnedUTXO, 0, len(p.UTXOs)) for _, u := range p.UTXOs { - owned, resErr := s.resolveOwnedUTXO(u, viewPriv, spendPub) + owned, resErr := s.resolveOwnedUTXO(u, spendPriv, viewPriv, spendPub) if resErr != nil { return nil, resErr } @@ -263,7 +263,7 @@ func (s *Server) aprWalletSend(ctx context.Context, rawParams json.RawMessage) ( // derives or validates the Pedersen blind, and runs the pre-flight commitment // check. Shared by apr_walletSend (fail on error) and apr_walletMaxSpendable // (skip on error). -func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, spendPub crypto.Point32) (core.OwnedUTXO, error) { +func (s *Server) resolveOwnedUTXO(u walletUTXOInput, spendPriv, viewPriv crypto.Scalar32, spendPub crypto.Point32) (core.OwnedUTXO, error) { txHash, err := hash32FromHex(u.TxHash) if err != nil { return core.OwnedUTXO{}, fmt.Errorf("utxo tx_hash %q: %w", u.TxHash, err) @@ -358,6 +358,8 @@ func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, s if bTx.Hash() == txHash { if int(u.OutIdx) < len(bTx.Outputs) { out = bTx.Outputs[u.OutIdx] + tx = bTx + txOk = true loc = core.TxLocation{ Block: &blk, TxIndex: ti, @@ -618,6 +620,49 @@ func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, s } } } + txVersion := tx.Version + if !txOk && loc.Block != nil { + if binding, sourceVersion, found := s.amountBindingForOutput(txHash, u.OutIdx, loc.Block.Header.Height); found { + txVersion = sourceVersion + if out.AmountBinding == nil { + out.AmountBinding = binding + } + } else if out.TxPubKey != (crypto.Point32{}) && out.EncAmount == ([8]byte{}) { + return core.OwnedUTXO{}, fmt.Errorf("source transaction is unavailable to validate amount encoding for %s[%d]", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx) + } + } + requiresAmountBinding := txVersion == core.TxVersionAmountBinding || txVersion == core.TxVersionAVMAmountBinding + if requiresAmountBinding { + if out.AmountBinding == nil { + return core.OwnedUTXO{}, fmt.Errorf("amount binding missing for %s[%d]", u.TxHash[:min(16, len(u.TxHash))], u.OutIdx) + } + if out.EncAmount != ([8]byte{}) { + return core.OwnedUTXO{}, fmt.Errorf("legacy encrypted amount present in bound output %s[%d]", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx) + } + valid, verifyErr := crypto.VerifyAmountBinding(out.AmountCommit, out.OneTimePub, out.AmountBinding) + if verifyErr != nil || !valid { + return core.OwnedUTXO{}, fmt.Errorf("invalid amount binding for %s[%d]: %v", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx, verifyErr) + } + oneTimePriv, keyErr := crypto.AddScalars(hsScalar, spendPriv) + if keyErr != nil { + return core.OwnedUTXO{}, fmt.Errorf("derive bound spend key for %s[%d]: %w", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx, keyErr) + } + boundAmount, decryptErr := crypto.DecryptAmountBinding(out.AmountBinding, oneTimePriv) + crypto.ZeroBytes(oneTimePriv[:]) + if decryptErr != nil { + return core.OwnedUTXO{}, fmt.Errorf("decrypt bound amount for %s[%d]: %w", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx, decryptErr) + } + if boundAmount != u.AmountNAPR { + return core.OwnedUTXO{}, fmt.Errorf("amount binding mismatch for %s[%d]: supplied %d, authenticated %d", + u.TxHash[:min(16, len(u.TxHash))], u.OutIdx, u.AmountNAPR, boundAmount) + } + } + // ── Heal stale byPubKey entry ───────────────────────────────────────── // byPubKey is loaded from the snapshot at startup. After an OOM kill // the snapshot may hold a corrupt AmountCommit for this UTXO while the diff --git a/api/rpc_wallet_fee_estimate.go b/api/rpc_wallet_fee_estimate.go index b7f27f4..b73747f 100644 --- a/api/rpc_wallet_fee_estimate.go +++ b/api/rpc_wallet_fee_estimate.go @@ -41,6 +41,8 @@ type walletEstimateFeeResult struct { SpendableUTXOCount int `json:"spendable_utxo_count"` // BaseFeePerByte is the fee rate used for the estimate. BaseFeePerByte uint64 `json:"base_fee_per_byte"` + // TransactionVersion is the protocol format required for the next block. + TransactionVersion uint8 `json:"transaction_version"` // SkippedUTXOs lists candidates excluded from the calculation // (unresolvable / failed the pre-flight commitment check). SkippedUTXOs []skippedUTXOInfo `json:"skipped_utxos,omitempty"` @@ -74,7 +76,7 @@ func (s *Server) aprWalletEstimateFee(rawParams json.RawMessage) (interface{}, e owned := make([]core.OwnedUTXO, 0, len(p.UTXOs)) var skipped []skippedUTXOInfo for _, u := range p.UTXOs { - o, resErr := s.resolveOwnedUTXO(u, viewPriv, spendPub) + o, resErr := s.resolveOwnedUTXO(u, spendPriv, viewPriv, spendPub) if resErr != nil { skipped = append(skipped, skippedUTXOInfo{ TxHash: u.TxHash, @@ -88,8 +90,9 @@ func (s *Server) aprWalletEstimateFee(rawParams json.RawMessage) (interface{}, e // Rate 0 → NewTxBuilder falls back to core.InitialBaseFeePerByte — // exactly what apr_walletSend does, so the quote uses the same rate. + txVersion := s.mempool.NextSpendVersion() builder := core.NewTxBuilder(spendPriv, viewPriv, spendPub, owned, 0). - WithVersion(s.mempool.NextSpendVersion()) + WithVersion(txVersion) est := builder.EstimateFeeForAmount(p.AmountNAPR) return walletEstimateFeeResult{ @@ -100,6 +103,7 @@ func (s *Server) aprWalletEstimateFee(rawParams json.RawMessage) (interface{}, e SpendableTotalNAPR: est.SpendableTotal, SpendableUTXOCount: est.UTXOCount, BaseFeePerByte: core.InitialBaseFeePerByte, + TransactionVersion: uint8(txVersion), SkippedUTXOs: skipped, }, nil } diff --git a/api/rpc_wallet_max.go b/api/rpc_wallet_max.go index 6523d4d..57c8b63 100644 --- a/api/rpc_wallet_max.go +++ b/api/rpc_wallet_max.go @@ -80,7 +80,7 @@ func (s *Server) aprWalletMaxSpendable(rawParams json.RawMessage) (interface{}, owned := make([]core.OwnedUTXO, 0, len(p.UTXOs)) var skipped []skippedUTXOInfo for _, u := range p.UTXOs { - o, resErr := s.resolveOwnedUTXO(u, viewPriv, spendPub) + o, resErr := s.resolveOwnedUTXO(u, spendPriv, viewPriv, spendPub) if resErr != nil { // A bad DB record (wrong amount / stale blind / deleted UTXO) // must not block the quote — the send path skips such UTXOs 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/server.go b/api/server.go index d5023ff..efac112 100644 --- a/api/server.go +++ b/api/server.go @@ -23,6 +23,7 @@ import ( "time" "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/config" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" @@ -180,6 +181,7 @@ type Server struct { // consensus.Engine.ScheduleAdminMint in cmd/node after engine start. // nil = this node is not an active validator; admin mints are refused. mintScheduler func(idempotencyKey, addr string, amountNAPR uint64, timeout time.Duration) (string, uint64, error) + rewardAddress string banListFn func() []BanEntry // optional; wired to p2p.Host.ListBans by cmd/node banLiftFn func(string) bool // optional; wired to p2p.Host.LiftBan by cmd/node @@ -267,11 +269,6 @@ type Server struct { p2pListenAddr string // TCP listen address for P2P (e.g. "0.0.0.0:30303") nodeID string // hex node ID derived from the validator public key - // nodeViewKeyHex is the hex-encoded Ed25519 view private scalar from node.yaml (optional). - // When non-empty, restAddressUTXOs uses it to decrypt enc_amount for owned stealth outputs - // inline, without requiring the caller to supply a view_key_hex query parameter. - nodeViewKeyHex string - // pruningMode is "light" or "archive" (default "archive"). // Set via SetPruningMode so the API can hint about pruned UTXOs. pruningMode string @@ -373,6 +370,11 @@ type Server struct { startupSnapshotErrStr string startupID string + // nightlyRestartSchedule is set only when the in-process restart scheduler + // is active. The status endpoint uses it to expose the next UTC occurrence. + nightlyRestartScheduleMu sync.RWMutex + nightlyRestartSchedule *nightlyRestartSchedule + // utxoAddrCache is a short-TTL in-memory cache for /address/{addr}/utxos // responses. The mint-UTXO monitor calls this endpoint every 5 minutes // for each admin-mint address; without caching each call does an O(n) @@ -434,6 +436,41 @@ func NewServer(addr string, chain *core.Chain, mempool *core.Mempool, utxos *cor return s } +type nightlyRestartSchedule struct { + hour int + minute int +} + +// SetNightlyRestartSchedule records the active in-process scheduler's UTC +// schedule for GET /api/v1/status. An empty value clears the schedule so the +// endpoint reports next_restart_at: null. +func (s *Server) SetNightlyRestartSchedule(restartAt string) error { + var schedule *nightlyRestartSchedule + if restartAt != "" { + hour, minute, err := config.ParseRestartAt(restartAt) + if err != nil { + return err + } + schedule = &nightlyRestartSchedule{hour: hour, minute: minute} + } + + s.nightlyRestartScheduleMu.Lock() + s.nightlyRestartSchedule = schedule + s.nightlyRestartScheduleMu.Unlock() + return nil +} + +func (s *Server) nextNightlyRestartAt(now time.Time) *time.Time { + s.nightlyRestartScheduleMu.RLock() + schedule := s.nightlyRestartSchedule + s.nightlyRestartScheduleMu.RUnlock() + if schedule == nil { + return nil + } + next := config.NextRestartAt(now, schedule.hour, schedule.minute) + return &next +} + // SetReady marks the node as fully loaded and no longer syncing. // Call this after all blocks have been replayed from disk so that // /api/v1/status reports syncing=false and UTXO supply queries proceed normally. @@ -672,12 +709,6 @@ func (s *Server) SetPruningMode(mode string) { s.pruningMode = mode } // blocks_until_pruned warning field for UTXOs approaching the prune window. func (s *Server) SetKeepBlocks(n uint64) { s.keepBlocks = n } -// SetNodeViewKey stores the node's configured view private key (hex-encoded). -// When set, restAddressUTXOs automatically decrypts enc_amount for all owned -// UTXOs — both transparent outputs and stealth outputs — without requiring the -// caller to supply a view_key_hex query parameter. -func (s *Server) SetNodeViewKey(hexKey string) { s.nodeViewKeyHex = hexKey } - // FlushUTXOCache removes all cached /address/{addr}/utxos entries immediately. // Intended for use in tests that mutate the UTXO set and then require a fresh // response from the REST handler on the very next call, bypassing the normal TTL. @@ -798,6 +829,12 @@ func (s *Server) SetMintScheduler(f func(idempotencyKey, addr string, amountNAPR s.mintScheduler = f } +// SetRewardAddress wires the node's configured per-block coinbase destination +// so the REST handler can reject a colliding admin mint before scheduling it. +func (s *Server) SetRewardAddress(address string) { + s.rewardAddress = address +} + // SetWhitelistGetFunc wires a function returning the current peer whitelist entries. // Optional — GET /api/v1/network/whitelist returns 503 when not wired. func (s *Server) SetWhitelistGetFunc(f func() []string) { s.whitelistGetFn = f } 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..9e08f13 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 reopened 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..49411bf 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 source 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 verification 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/cli/main.go b/cmd/cli/main.go index ef96f6f..d0858ad 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -261,9 +261,11 @@ var walletRestoreCmd = &cobra.Command{ if phrase == "" { fmt.Print(" Enter mnemonic phrase: ") - var line string - fmt.Scanln(&line) - phrase = strings.TrimSpace(line) + var err error + phrase, err = readMnemonicLine(os.Stdin) + if err != nil { + return fmt.Errorf("read mnemonic: %w", err) + } } legacyMnemonic, err := wallet.ValidateMnemonicForRecovery(phrase) @@ -325,6 +327,14 @@ var walletRestoreCmd = &cobra.Command{ }, } +func readMnemonicLine(reader io.Reader) (string, error) { + line, err := bufio.NewReader(reader).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimSpace(line), nil +} + // wallet unlock — decrypt keystore and show keys. var walletUnlockCmd = &cobra.Command{ Use: "unlock [keystore-file]", diff --git a/cmd/closed-staging-tip/b2_restore_integration_test.go b/cmd/closed-staging-tip/b2_restore_integration_test.go new file mode 100644 index 0000000..cc83ed7 --- /dev/null +++ b/cmd/closed-staging-tip/b2_restore_integration_test.go @@ -0,0 +1,636 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + "time" +) + +const ( + b2TestAccessKeyEnv = "BACKBLAZE_TEST_ACCESS_KEY_ID" + b2TestSecretKeyEnv = "BACKBLAZE_TEST_SECRET_ACCESS_KEY" + b2TestBucketIDEnv = "BACKBLAZE_TEST_BUCKET_ID" + b2TestBucketNameEnv = "BACKBLAZE_TEST_BUCKET_NAME" + // The operator explicitly authorized this configured, empty backup bucket + // for the rehearsal while scheduled backups are disabled. Keep the exception + // exact; do not allow arbitrary names. + b2AuthorizedTestBucketName = "aperod-vault" +) + +var b2UploadHostPattern = regexp.MustCompile( + `^pod-[0-9]{3}-[0-9]{4}-[0-9]{2}\.backblaze\.com$`, +) + +func TestB2TestBucketPinnedRestore(t *testing.T) { + accessKey := os.Getenv(b2TestAccessKeyEnv) + secretKey := os.Getenv(b2TestSecretKeyEnv) + bucketID := os.Getenv(b2TestBucketIDEnv) + bucketName := os.Getenv(b2TestBucketNameEnv) + configured := []bool{ + accessKey != "", secretKey != "", bucketID != "", bucketName != "", + } + configuredCount := 0 + for _, present := range configured { + if present { + configuredCount++ + } + } + if configuredCount == 0 { + t.Skip("dedicated B2 test-bucket configuration is not set") + } + if configuredCount != len(configured) { + t.Fatal("incomplete dedicated B2 test-bucket configuration") + } + if err := validateB2TestBucketName(bucketName); err != nil { + t.Fatal(err) + } + + b2, err := authorizeB2TestBucket(accessKey, secretKey, bucketID, bucketName) + if err != nil { + t.Fatalf("authorize dedicated B2 test bucket: %v", err) + } + + sandbox := t.TempDir() + if sandbox == stagingRoot || strings.HasPrefix(sandbox, stagingRoot+string(os.PathSeparator)) { + t.Fatalf("integration fixture unexpectedly uses production staging path: %s", sandbox) + } + const checkpointID = "checkpoint-1189583511" + const fixtureIdentity = "Aperod isolated B2 restore rehearsal" + height := uint64(42) + hash := [32]byte{} + for i := range hash { + hash[i] = byte(i + 1) + } + hashHex := fmt.Sprintf("%x", hash[:]) + + sourceRoot := filepath.Join(sandbox, "source") + sourceCheckpoint := filepath.Join(sourceRoot, checkpointID) + sourceDB := filepath.Join(sourceCheckpoint, "chain.db") + if err := os.MkdirAll(sourceCheckpoint, 0700); err != nil { + t.Fatal(err) + } + createSyntheticCheckpointDB(t, sourceDB, height, hash) + manifest, err := json.Marshal(map[string]any{ + "success": true, "tip_height": height, "tip_hash": hashHex, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceCheckpoint, "manifest.json"), manifest, 0600); err != nil { + t.Fatal(err) + } + sourceFiles := syntheticFileInventory(t, sourceCheckpoint) + + archiveBytes := tarSyntheticCheckpoint(t, sourceCheckpoint) + archiveKeyHome := filepath.Join(sandbox, "archive-key-home") + if err := os.Mkdir(archiveKeyHome, 0700); err != nil { + t.Fatal(err) + } + runFixtureGPG(t, archiveKeyHome, nil, + "--pinentry-mode", "loopback", "--passphrase", "", + "--quick-generate-key", fixtureIdentity, "rsa2048", "encr", "0") + fingerprint := fixtureGPGFingerprint(t, archiveKeyHome) + ciphertextPath := filepath.Join(sandbox, "synthetic-checkpoint.tar.gpg") + runFixtureGPG(t, archiveKeyHome, bytes.NewReader(archiveBytes), + "--trust-model", "always", "--compress-algo", "none", + "--output", ciphertextPath, "--recipient", fingerprint, "--encrypt") + ciphertext, err := os.ReadFile(ciphertextPath) + if err != nil { + t.Fatal(err) + } + secretKeyCopy := runFixtureGPG(t, archiveKeyHome, nil, "--export-secret-keys", fingerprint) + restoreKeyHome := filepath.Join(sandbox, "offhost-restore-key-home") + if err := os.Mkdir(restoreKeyHome, 0700); err != nil { + t.Fatal(err) + } + runFixtureGPG(t, restoreKeyHome, bytes.NewReader(secretKeyCopy), "--import") + if err := os.RemoveAll(archiveKeyHome); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(archiveKeyHome); !os.IsNotExist(err) { + t.Fatalf("archive-side encryption key home remains available: %v", err) + } + + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + t.Fatalf("create unique test object name: %v", err) + } + objectName := "aperod-test-rehearsal/" + hex.EncodeToString(nonce) + ".tar.gpg" + if b2.namePrefix != "" && !strings.HasPrefix(objectName, b2.namePrefix) { + t.Fatalf("test application key is restricted to a different object prefix") + } + if err := b2.requireNoLifecycleRule(objectName); err != nil { + t.Fatalf("refuse test object covered by an automatic lifecycle rule: %v", err) + } + existingVersions, err := b2.listFileVersions(objectName) + if err != nil { + t.Fatalf("check unique B2 test object name: %v", err) + } + if len(existingVersions) != 0 { + t.Fatal("unique B2 test object name unexpectedly already exists") + } + + uploadAttempted := false + uploadedID := "" + t.Cleanup(func() { + if !uploadAttempted { + return + } + if err := b2.cleanupUniqueTestObject(objectName, uploadedID); err != nil { + t.Errorf("delete disposable B2 test object %q: %v", objectName, err) + } + }) + uploadAttempted = true + uploaded, err := b2.uploadFile(objectName, ciphertext) + if err != nil { + t.Fatalf("upload disposable synthetic checkpoint to test bucket: %v", err) + } + uploadedID = uploaded.FileID + if uploaded.FileID == "" || uploaded.FileName != objectName { + t.Fatalf("B2 returned an unexpected upload receipt") + } + + info, err := b2.getFileInfo(uploaded.FileID) + if err != nil { + t.Fatalf("inspect uploaded B2 test version: %v", err) + } + if info.FileID != uploaded.FileID || info.FileName != objectName || + info.ContentLength != int64(len(ciphertext)) { + t.Fatal("B2 file info does not match the exact uploaded test version") + } + versions, err := b2.listFileVersions(objectName) + if err != nil { + t.Fatalf("list exact B2 test object versions: %v", err) + } + if len(versions) != 1 || versions[0].FileID != uploaded.FileID || + versions[0].FileName != objectName || versions[0].Action != "upload" { + t.Fatalf("B2 version list does not contain exactly the recorded upload ID") + } + + readback, err := b2.downloadFileByID(uploaded.FileID) + if err != nil { + t.Fatalf("download exact B2 file ID %q: %v", uploaded.FileID, err) + } + expectedDigest := sha256.Sum256(ciphertext) + actualDigest := sha256.Sum256(readback) + if !bytes.Equal(actualDigest[:], expectedDigest[:]) || len(readback) != len(ciphertext) { + t.Fatal("downloaded ciphertext digest or size differs from the uploaded test object") + } + + restoredTar := runFixtureGPG(t, restoreKeyHome, bytes.NewReader(readback), "--decrypt") + restoreParent := filepath.Join(sandbox, "fresh-restore") + if err := os.Mkdir(restoreParent, 0700); err != nil { + t.Fatal(err) + } + restoreRoot := filepath.Join(restoreParent, checkpointID) + if err := extractSyntheticTar(restoredTar, restoreRoot); err != nil { + t.Fatalf("extract downloaded B2 archive: %v", err) + } + + restoredManifest, err := os.ReadFile(filepath.Join(restoreRoot, "manifest.json")) + if err != nil { + t.Fatal(err) + } + var restoredMetadata struct { + Success bool `json:"success"` + TipHeight uint64 `json:"tip_height"` + TipHash string `json:"tip_hash"` + } + if err := json.Unmarshal(restoredManifest, &restoredMetadata); err != nil { + t.Fatal(err) + } + if !restoredMetadata.Success || restoredMetadata.TipHeight != height || + restoredMetadata.TipHash != hashHex { + t.Fatalf("restored B2 manifest does not match the synthetic tip: %+v", restoredMetadata) + } + restoredFiles := syntheticFileInventory(t, restoreRoot) + if !reflect.DeepEqual(restoredFiles, sourceFiles) { + t.Fatalf("restored B2 member inventory differs from source:\nsource: %v\nrestored: %v", + sourceFiles, restoredFiles) + } + report, err := inspectCheckpoint( + filepath.Join(restoreRoot, "chain.db"), restoreParent, uint32(os.Getuid()), + ) + if err != nil { + t.Fatalf("closed-staging tip verification after B2 restore: %v", err) + } + if report.Height != height || report.Hash != hashHex || !report.HeightIndexMatches { + t.Fatalf("restored B2 database tip differs from source: %+v", report) + } +} + +type b2TestClient struct { + httpClient *http.Client + accountID string + authorization string + apiURL string + downloadURL string + bucketID string + bucketName string + namePrefix string +} + +type b2TestFileVersion struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + Action string `json:"action"` +} + +type b2TestFileInfo struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + ContentLength int64 `json:"contentLength"` +} + +func authorizeB2TestBucket(accessKey, secretKey, bucketID, bucketName string) (*b2TestClient, error) { + client := &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + credentials := base64.StdEncoding.EncodeToString([]byte(accessKey + ":" + secretKey)) + request, err := http.NewRequest(http.MethodGet, + "https://api.backblazeb2.com/b2api/v3/b2_authorize_account", nil) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Basic "+credentials) + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("B2 authorization request failed: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("B2 authorization returned HTTP %d", response.StatusCode) + } + var auth struct { + AccountID string `json:"accountId"` + AuthorizationToken string `json:"authorizationToken"` + APIInfo struct { + StorageAPI struct { + APIURL string `json:"apiUrl"` + DownloadURL string `json:"downloadUrl"` + BucketID string `json:"bucketId"` + NamePrefix string `json:"namePrefix"` + Capabilities []string `json:"capabilities"` + } `json:"storageApi"` + } `json:"apiInfo"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&auth); err != nil { + return nil, fmt.Errorf("decode B2 authorization response: %w", err) + } + storage := auth.APIInfo.StorageAPI + if auth.AccountID == "" || auth.AuthorizationToken == "" || + storage.APIURL == "" || storage.DownloadURL == "" { + return nil, fmt.Errorf("B2 authorization response is incomplete") + } + if storage.BucketID == "" { + return nil, fmt.Errorf("B2 test key is not restricted to a single bucket") + } + for _, capability := range []string{"listBuckets", "listFiles", "readFiles", "writeFiles", "deleteFiles"} { + if !containsB2Capability(storage.Capabilities, capability) { + return nil, fmt.Errorf("B2 test key lacks required %s capability", capability) + } + } + if err := validateB2TestURL(storage.APIURL, false); err != nil { + return nil, fmt.Errorf("unsafe B2 API URL: %w", err) + } + if err := validateB2TestURL(storage.DownloadURL, false); err != nil { + return nil, fmt.Errorf("unsafe B2 download URL: %w", err) + } + b2 := &b2TestClient{ + httpClient: client, accountID: auth.AccountID, + authorization: auth.AuthorizationToken, apiURL: storage.APIURL, + downloadURL: storage.DownloadURL, bucketID: storage.BucketID, + bucketName: bucketName, namePrefix: storage.NamePrefix, + } + if err := b2.requirePrivateTestBucket(); err != nil { + return nil, fmt.Errorf("B2 test key does not select the expected private bucket: %w", err) + } + if storage.BucketID != bucketID { + return nil, fmt.Errorf("configured B2 test bucket ID does not match the restricted key's bucket; update the test bucket ID") + } + return b2, nil +} + +func containsB2Capability(capabilities []string, wanted string) bool { + for _, capability := range capabilities { + if capability == wanted { + return true + } + } + return false +} + +func validateB2TestBucketName(bucketName string) error { + if strings.HasPrefix(bucketName, "aperod-test-") || bucketName == b2AuthorizedTestBucketName { + return nil + } + return fmt.Errorf("refusing B2 integration test unless bucket name starts with aperod-test- or is the explicitly authorized bucket") +} + +func validateB2TestURL(raw string, uploadURL bool) error { + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse URL: %w", err) + } + host := strings.ToLower(parsed.Hostname()) + if parsed.Scheme != "https" || parsed.User != nil || + (parsed.Port() != "" && parsed.Port() != "443") || parsed.Fragment != "" { + return fmt.Errorf("URL must use HTTPS without credentials, a custom port, or a fragment") + } + if uploadURL { + if !b2UploadHostPattern.MatchString(host) { + return fmt.Errorf("unexpected B2 upload host") + } + return nil + } + if host != "api.backblazeb2.com" && !strings.HasSuffix(host, ".backblazeb2.com") { + return fmt.Errorf("unexpected B2 API/download host") + } + return nil +} + +func TestB2TestConfigurationRejectsProductionTargets(t *testing.T) { + if err := validateB2TestBucketName("aperod-test-checkpoint-restore"); err != nil { + t.Fatalf("dedicated test bucket name rejected: %v", err) + } + if err := validateB2TestBucketName(b2AuthorizedTestBucketName); err != nil { + t.Fatalf("explicitly authorized test bucket name rejected: %v", err) + } + for _, name := range []string{"", "aperod-production", "aperod-backup-prod", "aperodapro", "aperod-vault-prod"} { + if err := validateB2TestBucketName(name); err == nil { + t.Errorf("non-test bucket name accepted: %q", name) + } + } + + validURLs := []struct { + raw string + upload bool + }{ + {"https://api.backblazeb2.com", false}, + {"https://f000.backblazeb2.com", false}, + {"https://pod-050-1050-19.backblaze.com/upload", true}, + } + for _, test := range validURLs { + if err := validateB2TestURL(test.raw, test.upload); err != nil { + t.Errorf("valid provider URL rejected %q: %v", test.raw, err) + } + } + invalidURLs := []struct { + raw string + upload bool + }{ + {"http://api.backblazeb2.com", false}, + {"https://api.backblazeb2.com.attacker.invalid", false}, + {"https://user@api.backblazeb2.com", false}, + {"https://api.backblazeb2.com:444", false}, + {"https://api.backblazeb2.com/#fragment", false}, + {"https://pod-050-1050-19.backblaze.com.attacker.invalid/upload", true}, + } + for _, test := range invalidURLs { + if err := validateB2TestURL(test.raw, test.upload); err == nil { + t.Errorf("unsafe provider URL accepted: %q", test.raw) + } + } +} + +func (b2 *b2TestClient) request(method, rawURL string, body io.Reader, headers http.Header, + uploadURL bool) (*http.Response, error) { + if err := validateB2TestURL(rawURL, uploadURL); err != nil { + return nil, err + } + request, err := http.NewRequest(method, rawURL, body) + if err != nil { + return nil, err + } + for key, values := range headers { + for _, value := range values { + request.Header.Add(key, value) + } + } + response, err := b2.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("B2 request failed: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + response.Body.Close() + return nil, fmt.Errorf("B2 request returned HTTP %d", response.StatusCode) + } + return response, nil +} + +func (b2 *b2TestClient) call(method string, payload any, output any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + requestURL := strings.TrimRight(b2.apiURL, "/") + "/b2api/v3/" + method + headers := make(http.Header) + headers.Set("Authorization", b2.authorization) + headers.Set("Content-Type", "application/json") + response, err := b2.request(http.MethodPost, requestURL, bytes.NewReader(body), headers, false) + if err != nil { + return err + } + defer response.Body.Close() + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output); err != nil { + return fmt.Errorf("decode B2 %s response: %w", method, err) + } + return nil +} + +func (b2 *b2TestClient) requirePrivateTestBucket() error { + var result struct { + Buckets []struct { + BucketID string `json:"bucketId"` + BucketName string `json:"bucketName"` + BucketType string `json:"bucketType"` + LifecycleRules []struct { + FileNamePrefix string `json:"fileNamePrefix"` + } `json:"lifecycleRules"` + } `json:"buckets"` + } + if err := b2.call("b2_list_buckets", map[string]string{ + "accountId": b2.accountID, "bucketId": b2.bucketID, + }, &result); err != nil { + return fmt.Errorf("list restricted B2 test bucket: %w", err) + } + for _, bucket := range result.Buckets { + if bucket.BucketID != b2.bucketID { + continue + } + if bucket.BucketName != b2.bucketName || bucket.BucketType != "allPrivate" { + return fmt.Errorf("configured B2 bucket is not the expected private test bucket") + } + return nil + } + return fmt.Errorf("configured B2 test bucket ID was not returned") +} + +func (b2 *b2TestClient) requireNoLifecycleRule(objectName string) error { + var result struct { + Buckets []struct { + BucketID string `json:"bucketId"` + LifecycleRules []struct { + FileNamePrefix string `json:"fileNamePrefix"` + } `json:"lifecycleRules"` + } `json:"buckets"` + } + if err := b2.call("b2_list_buckets", map[string]string{ + "accountId": b2.accountID, "bucketId": b2.bucketID, + }, &result); err != nil { + return err + } + for _, bucket := range result.Buckets { + if bucket.BucketID != b2.bucketID { + continue + } + for _, rule := range bucket.LifecycleRules { + if strings.HasPrefix(objectName, rule.FileNamePrefix) { + return fmt.Errorf("lifecycle rule for prefix %q applies", rule.FileNamePrefix) + } + } + return nil + } + return fmt.Errorf("configured B2 test bucket ID was not returned") +} + +func (b2 *b2TestClient) listFileVersions(objectName string) ([]b2TestFileVersion, error) { + var result struct { + Files []b2TestFileVersion `json:"files"` + NextFileName string `json:"nextFileName"` + NextFileID string `json:"nextFileId"` + } + if err := b2.call("b2_list_file_versions", map[string]any{ + "bucketId": b2.bucketID, "prefix": objectName, "maxFileCount": 100, + }, &result); err != nil { + return nil, err + } + if result.NextFileName != "" || result.NextFileID != "" { + return nil, fmt.Errorf("B2 file-version listing was incomplete") + } + exact := make([]b2TestFileVersion, 0, len(result.Files)) + for _, version := range result.Files { + if version.FileName == objectName { + exact = append(exact, version) + } + } + return exact, nil +} + +func (b2 *b2TestClient) getFileInfo(fileID string) (b2TestFileInfo, error) { + var result b2TestFileInfo + err := b2.call("b2_get_file_info", map[string]string{"fileId": fileID}, &result) + return result, err +} + +func (b2 *b2TestClient) uploadFile(objectName string, data []byte) (b2TestFileVersion, error) { + var upload struct { + UploadURL string `json:"uploadUrl"` + AuthorizationToken string `json:"authorizationToken"` + } + if err := b2.call("b2_get_upload_url", map[string]string{"bucketId": b2.bucketID}, &upload); err != nil { + return b2TestFileVersion{}, fmt.Errorf("get B2 upload URL: %w", err) + } + if upload.UploadURL == "" || upload.AuthorizationToken == "" { + return b2TestFileVersion{}, fmt.Errorf("B2 returned an incomplete upload URL") + } + sha := sha1.Sum(data) + headers := make(http.Header) + headers.Set("Authorization", upload.AuthorizationToken) + headers.Set("X-Bz-File-Name", url.QueryEscape(objectName)) + headers.Set("X-Bz-Content-Sha1", hex.EncodeToString(sha[:])) + headers.Set("Content-Type", "application/octet-stream") + response, err := b2.request( + http.MethodPost, upload.UploadURL, bytes.NewReader(data), headers, true, + ) + if err != nil { + return b2TestFileVersion{}, err + } + defer response.Body.Close() + var uploaded b2TestFileVersion + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&uploaded); err != nil { + return b2TestFileVersion{}, fmt.Errorf("decode B2 upload receipt: %w", err) + } + return uploaded, nil +} + +func (b2 *b2TestClient) downloadFileByID(fileID string) ([]byte, error) { + downloadURL := strings.TrimRight(b2.downloadURL, "/") + + "/b2api/v3/b2_download_file_by_id?fileId=" + url.QueryEscape(fileID) + headers := make(http.Header) + headers.Set("Authorization", b2.authorization) + response, err := b2.request(http.MethodGet, downloadURL, nil, headers, false) + if err != nil { + return nil, err + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, 64<<20+1)) + if err != nil { + return nil, fmt.Errorf("read downloaded B2 ciphertext: %w", err) + } + if len(data) > 64<<20 { + return nil, fmt.Errorf("synthetic B2 test archive exceeded the 64 MiB limit") + } + return data, nil +} + +func (b2 *b2TestClient) deleteExactVersion(fileID, objectName string) error { + var deleted b2TestFileVersion + if err := b2.call("b2_delete_file_version", map[string]string{ + "fileId": fileID, "fileName": objectName, + }, &deleted); err != nil { + return err + } + if deleted.FileID != fileID || deleted.FileName != objectName { + return fmt.Errorf("B2 delete response did not match the exact test version") + } + versions, err := b2.listFileVersions(objectName) + if err != nil { + return fmt.Errorf("verify exact test version cleanup: %w", err) + } + for _, version := range versions { + if version.FileID == fileID { + return fmt.Errorf("B2 test file version remains after deletion") + } + } + return nil +} + +func (b2 *b2TestClient) cleanupUniqueTestObject(objectName, expectedID string) error { + versions, err := b2.listFileVersions(objectName) + if err != nil { + return fmt.Errorf("list disposable test object versions: %w", err) + } + if len(versions) == 0 { + return nil + } + if expectedID != "" { + if len(versions) != 1 || versions[0].FileID != expectedID { + return fmt.Errorf("B2 test object versions no longer match the recorded ID") + } + return b2.deleteExactVersion(expectedID, objectName) + } + if len(versions) != 1 || versions[0].FileID == "" || + versions[0].FileName != objectName || versions[0].Action != "upload" { + return fmt.Errorf("cannot safely identify the version created by this test") + } + return b2.deleteExactVersion(versions[0].FileID, objectName) +} diff --git a/cmd/closed-staging-tip/main.go b/cmd/closed-staging-tip/main.go new file mode 100644 index 0000000..3d11a79 --- /dev/null +++ b/cmd/closed-staging-tip/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "syscall" + + "github.com/aperod/aperod/store" +) + +const stagingRoot = "/opt/aperod/data/testnet/.backup-staging" + +var allowedCheckpoint = regexp.MustCompile( + `^` + regexp.QuoteMeta(stagingRoot) + `/checkpoint-(1189583511|1235802059|147415538)/chain\.db$`, +) + +type tipReport struct { + Height uint64 `json:"height"` + Hash string `json:"hash"` + HeightIndexMatches bool `json:"height_index_matches"` +} + +func allowedDatabasePath(path string) bool { + return allowedCheckpoint.MatchString(path) +} + +func allowedDatabasePathUnder(path, root string) bool { + pattern := regexp.MustCompile( + `^` + regexp.QuoteMeta(filepath.Clean(root)) + + `/checkpoint-(1189583511|1235802059|147415538)/chain\.db$`, + ) + return pattern.MatchString(path) +} + +func requireOwnedRegular(path string, expectedUID uint32) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != expectedUID { + if expectedUID == 0 { + return fmt.Errorf("%s is not root-owned", path) + } + return fmt.Errorf("%s is not owned by uid %d", path, expectedUID) + } + return nil +} + +func inspectCheckpoint(input, root string, expectedUID uint32) (tipReport, error) { + input = filepath.Clean(input) + resolved, err := filepath.EvalSymlinks(input) + if err != nil || resolved != input || !allowedDatabasePathUnder(resolved, root) { + return tipReport{}, fmt.Errorf("refusing any path outside the three approved staging checkpoints") + } + database, err := os.Lstat(resolved) + if err != nil || !database.IsDir() || database.Mode()&os.ModeSymlink != 0 { + return tipReport{}, fmt.Errorf("existing non-symlink LevelDB directory required") + } + if err := requireOwnedRegular(filepath.Join(resolved, "CURRENT"), expectedUID); err != nil { + return tipReport{}, err + } + if err := requireOwnedRegular(filepath.Join(resolved, "LOCK"), expectedUID); err != nil { + return tipReport{}, err + } + + hash, height, err := store.ReadTipOnly(resolved) + if err != nil { + return tipReport{}, err + } + return tipReport{ + Height: height, Hash: fmt.Sprintf("%x", hash[:]), HeightIndexMatches: true, + }, nil +} + +func main() { + if len(os.Args) != 2 { + panic("expected one approved staging checkpoint database") + } + report, err := inspectCheckpoint(os.Args[1], stagingRoot, 0) + if err != nil { + panic(err) + } + if err := json.NewEncoder(os.Stdout).Encode(report); err != nil { + panic(err) + } +} diff --git a/cmd/closed-staging-tip/main_test.go b/cmd/closed-staging-tip/main_test.go new file mode 100644 index 0000000..95590b3 --- /dev/null +++ b/cmd/closed-staging-tip/main_test.go @@ -0,0 +1,707 @@ +package main + +import ( + "archive/tar" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + chaincrypto "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestAllowedDatabasePath(t *testing.T) { + for _, id := range []string{ + "checkpoint-1189583511", + "checkpoint-1235802059", + "checkpoint-147415538", + } { + path := stagingRoot + "/" + id + "/chain.db" + if !allowedDatabasePath(path) { + t.Errorf("approved checkpoint path rejected: %s", path) + } + } + for _, path := range []string{ + "/opt/aperod/data/testnet/chain.db", + stagingRoot + "/checkpoint-unlisted/chain.db", + stagingRoot + "/checkpoint-1189583511/manifest.json", + stagingRoot + "/checkpoint-1189583511/chain.db/child", + "/tmp/checkpoint-1189583511/chain.db", + } { + if allowedDatabasePath(path) { + t.Errorf("unapproved path accepted: %s", path) + } + } +} + +func TestSyntheticPinnedArchiveRestore(t *testing.T) { + sandbox := t.TempDir() + sandbox = filepath.Clean(sandbox) + if sandbox == stagingRoot || strings.HasPrefix(sandbox, stagingRoot+string(os.PathSeparator)) { + t.Fatalf("test fixture unexpectedly uses the production staging root: %s", sandbox) + } + + const checkpointID = "checkpoint-1189583511" + const fixtureIdentity = "Aperod isolated restore rehearsal" + height := uint64(42) + hash := chaincrypto.Hash32{} + for i := range hash { + hash[i] = byte(i + 1) + } + hashHex := fmt.Sprintf("%x", hash[:]) + + sourceRoot := filepath.Join(sandbox, "source") + sourceCheckpoint := filepath.Join(sourceRoot, checkpointID) + sourceDB := filepath.Join(sourceCheckpoint, "chain.db") + if err := os.MkdirAll(sourceCheckpoint, 0700); err != nil { + t.Fatal(err) + } + createSyntheticCheckpointDB(t, sourceDB, height, hash) + manifest, err := json.Marshal(map[string]any{ + "success": true, "tip_height": height, "tip_hash": hashHex, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceCheckpoint, "manifest.json"), manifest, 0600); err != nil { + t.Fatal(err) + } + sourceFiles := syntheticFileInventory(t, sourceCheckpoint) + + sourceReport, err := inspectCheckpoint(sourceDB, sourceRoot, uint32(os.Getuid())) + if err != nil { + t.Fatalf("verify synthetic source checkpoint: %v", err) + } + if sourceReport.Height != height || sourceReport.Hash != hashHex || !sourceReport.HeightIndexMatches { + t.Fatalf("unexpected source tip report: %+v", sourceReport) + } + if _, err := inspectCheckpoint(sourceDB, sourceRoot, uint32(os.Getuid())+1); err == nil { + t.Fatal("checkpoint control files with the wrong owner were accepted") + } + + archiveBytes := tarSyntheticCheckpoint(t, sourceCheckpoint) + archiveKeyHome := filepath.Join(sandbox, "archive-key-home") + if err := os.Mkdir(archiveKeyHome, 0700); err != nil { + t.Fatal(err) + } + runFixtureGPG(t, archiveKeyHome, nil, + "--pinentry-mode", "loopback", "--passphrase", "", + "--quick-generate-key", fixtureIdentity, "rsa2048", "encr", "0") + fingerprint := fixtureGPGFingerprint(t, archiveKeyHome) + ciphertextPath := filepath.Join(sandbox, "synthetic-checkpoint.tar.gpg") + runFixtureGPG(t, archiveKeyHome, bytes.NewReader(archiveBytes), + "--trust-model", "always", "--compress-algo", "none", + "--output", ciphertextPath, "--recipient", fingerprint, "--encrypt") + ciphertext, err := os.ReadFile(ciphertextPath) + if err != nil { + t.Fatal(err) + } + secretKey := runFixtureGPG(t, archiveKeyHome, nil, "--export-secret-keys", fingerprint) + restoreKeyHome := filepath.Join(sandbox, "offhost-restore-key-home") + if err := os.Mkdir(restoreKeyHome, 0700); err != nil { + t.Fatal(err) + } + runFixtureGPG(t, restoreKeyHome, bytes.NewReader(secretKey), "--import") + if err := os.RemoveAll(archiveKeyHome); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(archiveKeyHome); !os.IsNotExist(err) { + t.Fatalf("archive-side key home still exists after off-host key-copy setup: %v", err) + } + ciphertextHash := sha256.Sum256(ciphertext) + fileName := "retained-checkpoints/rehearsal/" + hashHex + ".tar.gpg" + const pinnedVersionID = "synthetic-b2-version-002" + versions := map[string]fixtureB2Version{ + "synthetic-b2-version-001": { + fileID: "synthetic-b2-version-001", fileName: fileName, data: []byte("older version"), + }, + pinnedVersionID: { + fileID: pinnedVersionID, fileName: fileName, data: ciphertext, + }, + "synthetic-b2-version-003": { + fileID: "synthetic-b2-version-003", fileName: fileName, data: []byte("newer version"), + }, + } + pinnedBytes, err := selectFixtureB2Version(versions, fixtureB2Receipt{ + fileID: pinnedVersionID, fileName: fileName, + ciphertextSHA256: hex.EncodeToString(ciphertextHash[:]), + }) + if err != nil { + t.Fatalf("select exact synthetic B2 version: %v", err) + } + + restoredTar := runFixtureGPG(t, restoreKeyHome, bytes.NewReader(pinnedBytes), "--decrypt") + restoreParent := filepath.Join(sandbox, "fresh-restore") + if err := os.MkdirAll(restoreParent, 0700); err != nil { + t.Fatal(err) + } + restoreRoot := filepath.Join(restoreParent, checkpointID) + if err := extractSyntheticTar(restoredTar, restoreRoot); err != nil { + t.Fatalf("extract synthetic archive: %v", err) + } + + restoredManifest, err := os.ReadFile(filepath.Join(restoreRoot, "manifest.json")) + if err != nil { + t.Fatal(err) + } + var restoredMetadata struct { + Success bool `json:"success"` + TipHeight uint64 `json:"tip_height"` + TipHash string `json:"tip_hash"` + } + if err := json.Unmarshal(restoredManifest, &restoredMetadata); err != nil { + t.Fatal(err) + } + if !restoredMetadata.Success || restoredMetadata.TipHeight != height || restoredMetadata.TipHash != hashHex { + t.Fatalf("restored manifest does not match the synthetic tip: %+v", restoredMetadata) + } + restoredFiles := syntheticFileInventory(t, restoreRoot) + if !reflect.DeepEqual(restoredFiles, sourceFiles) { + t.Fatalf("restored member inventory differs from source:\nsource: %v\nrestored: %v", + sourceFiles, restoredFiles) + } + + restoredDB := filepath.Join(restoreRoot, "chain.db") + report, err := inspectCheckpoint(restoredDB, filepath.Dir(restoreRoot), uint32(os.Getuid())) + if err != nil { + t.Fatalf("closed-staging tip verification failed after isolated restore: %v", err) + } + if report.Height != restoredMetadata.TipHeight || report.Hash != restoredMetadata.TipHash || + !report.HeightIndexMatches { + t.Fatalf("restored database tip differs from its manifest: %+v", report) + } + if afterVerify := syntheticFileInventory(t, restoreRoot); !reflect.DeepEqual(afterVerify, restoredFiles) { + t.Fatalf("read-only tip verification changed restored files:\nbefore: %v\nafter: %v", + restoredFiles, afterVerify) + } + if sourceAfter := syntheticFileInventory(t, sourceCheckpoint); !reflect.DeepEqual(sourceAfter, sourceFiles) { + t.Fatalf("synthetic source changed during restore rehearsal:\nbefore: %v\nafter: %v", + sourceFiles, sourceAfter) + } +} + +type fixtureB2Version struct { + fileID string + fileName string + data []byte +} + +type fixtureB2Receipt struct { + fileID string + fileName string + ciphertextSHA256 string +} + +func selectFixtureB2Version(versions map[string]fixtureB2Version, receipt fixtureB2Receipt) ([]byte, error) { + version, ok := versions[receipt.fileID] + if !ok || version.fileID != receipt.fileID || version.fileName != receipt.fileName { + return nil, fmt.Errorf("exact pinned object version identity does not match") + } + digest := sha256.Sum256(version.data) + if hex.EncodeToString(digest[:]) != receipt.ciphertextSHA256 { + return nil, fmt.Errorf("exact pinned object version ciphertext digest does not match") + } + return version.data, nil +} + +func createSyntheticCheckpointDB(t *testing.T, path string, height uint64, hash chaincrypto.Hash32) { + t.Helper() + db, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + if err := db.PutBlock(hash, &store.StoredBlock{Height: height, Hash: hash}); err != nil { + _ = db.Close() + t.Fatal(err) + } + var heightBytes [8]byte + binary.LittleEndian.PutUint64(heightBytes[:], height) + if err := db.PutMetaSync("tip/hash", hash[:]); err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := db.PutMetaSync("tip/height", heightBytes[:]); err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } +} + +func syntheticFileInventory(t *testing.T, root string) map[string]string { + t.Helper() + files := make(map[string]string) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unexpected non-regular fixture file: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + digest := sha256.Sum256(data) + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + files[filepath.ToSlash(relative)] = fmt.Sprintf("%s:%04o", + hex.EncodeToString(digest[:]), info.Mode().Perm()) + return nil + }) + if err != nil { + t.Fatal(err) + } + return files +} + +func tarSyntheticCheckpoint(t *testing.T, source string) []byte { + t.Helper() + var output bytes.Buffer + writer := tar.NewWriter(&output) + err := filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == source { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("unexpected synthetic archive member type: %s", path) + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name, err = filepath.Rel(source, path) + if err != nil { + return err + } + header.Name = filepath.ToSlash(header.Name) + if info.IsDir() { + header.Name += "/" + } + if err := writer.WriteHeader(header); err != nil { + return err + } + if info.IsDir() { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(writer, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) + if err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func runFixtureGPG(t *testing.T, home string, input io.Reader, args ...string) []byte { + t.Helper() + commandArgs := []string{ + "--homedir", home, "--batch", "--yes", "--no-tty", + } + commandArgs = append(commandArgs, args...) + cmd := exec.Command("gpg", commandArgs...) + if input != nil { + cmd.Stdin = input + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("fixture-only GPG operation failed: %v: %s", err, stderr.String()) + } + return stdout.Bytes() +} + +func fixtureGPGFingerprint(t *testing.T, home string) string { + t.Helper() + output := runFixtureGPG(t, home, nil, "--with-colons", "--list-secret-keys") + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Split(line, ":") + if len(fields) > 9 && fields[0] == "fpr" { + return fields[9] + } + } + t.Fatal("temporary GPG home has no fixture secret-key fingerprint") + return "" +} + +type syntheticTarMember struct { + name string + typeflag byte + linkname string + data []byte +} + +type parsedSyntheticTarMember struct { + name string + dir bool + mode os.FileMode + data []byte +} + +func extractSyntheticTar(archiveBytes []byte, destination string) error { + parent := filepath.Dir(destination) + parentInfo, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("restore parent: %w", err) + } + if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("restore parent must be a real directory") + } + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil || resolvedParent != filepath.Clean(parent) { + return fmt.Errorf("restore parent must not traverse symlinks") + } + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("restore destination already exists") + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect restore destination: %w", err) + } + + reader := tar.NewReader(bytes.NewReader(archiveBytes)) + seen := make(map[string]parsedSyntheticTarMember) + members := make([]parsedSyntheticTarMember, 0) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read archive header: %w", err) + } + isDir := header.Typeflag == tar.TypeDir + cleanName, err := cleanSyntheticTarName(header.Name, isDir) + if err != nil { + return fmt.Errorf("unsafe archive member %q: %w", header.Name, err) + } + if _, exists := seen[cleanName]; exists { + return fmt.Errorf("duplicate archive member %q", header.Name) + } + member := parsedSyntheticTarMember{ + name: cleanName, dir: isDir, mode: os.FileMode(header.Mode) & 0777, + } + switch header.Typeflag { + case tar.TypeDir: + if header.Size != 0 { + return fmt.Errorf("directory archive member %q has a payload", header.Name) + } + case tar.TypeReg, tar.TypeRegA: + if header.Size < 0 || header.Size > int64(len(archiveBytes)) { + return fmt.Errorf("archive member %q has invalid size %d", header.Name, header.Size) + } + member.data, err = io.ReadAll(reader) + if err != nil { + return fmt.Errorf("read archive member %q: %w", header.Name, err) + } + if int64(len(member.data)) != header.Size { + return fmt.Errorf("archive member %q has %d bytes, expected %d", + header.Name, len(member.data), header.Size) + } + default: + return fmt.Errorf("unsupported archive member type %d for %q", header.Typeflag, header.Name) + } + seen[cleanName] = member + members = append(members, member) + } + for _, required := range []string{ + "manifest.json", filepath.Join("chain.db", "CURRENT"), filepath.Join("chain.db", "LOCK"), + } { + member, exists := seen[required] + if !exists || member.dir { + return fmt.Errorf("synthetic restore omitted required regular file %q", required) + } + } + for _, member := range members { + if member.dir { + continue + } + prefix := member.name + string(os.PathSeparator) + for otherName := range seen { + if strings.HasPrefix(otherName, prefix) { + return fmt.Errorf("archive file %q conflicts with child member %q", member.name, otherName) + } + } + for parentName := filepath.Dir(member.name); parentName != "."; parentName = filepath.Dir(parentName) { + if parent, exists := seen[parentName]; exists && !parent.dir { + return fmt.Errorf("archive member %q is beneath non-directory %q", member.name, parentName) + } + } + } + + stage, err := os.MkdirTemp(parent, ".checkpoint-restore-") + if err != nil { + return fmt.Errorf("create restore staging directory: %w", err) + } + defer os.RemoveAll(stage) + + directories := make([]parsedSyntheticTarMember, 0) + for _, member := range members { + target := filepath.Join(stage, member.name) + if member.dir { + if err := os.MkdirAll(target, 0700); err != nil { + return fmt.Errorf("create archive directory %q: %w", member.name, err) + } + directories = append(directories, member) + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return fmt.Errorf("create parent for archive member %q: %w", member.name, err) + } + if err := os.WriteFile(target, member.data, 0600); err != nil { + return fmt.Errorf("write archive member %q: %w", member.name, err) + } + if err := os.Chmod(target, member.mode); err != nil { + return fmt.Errorf("set mode for archive member %q: %w", member.name, err) + } + } + sort.Slice(directories, func(i, j int) bool { + return strings.Count(directories[i].name, string(os.PathSeparator)) > + strings.Count(directories[j].name, string(os.PathSeparator)) + }) + for _, directory := range directories { + if err := os.Chmod(filepath.Join(stage, directory.name), directory.mode); err != nil { + return fmt.Errorf("set mode for archive directory %q: %w", directory.name, err) + } + } + if err := os.Rename(stage, destination); err != nil { + return fmt.Errorf("publish restored checkpoint directory: %w", err) + } + return nil +} + +func cleanSyntheticTarName(name string, isDir bool) (string, error) { + if name == "" || strings.Contains(name, `\`) { + return "", fmt.Errorf("empty names and backslash separators are not supported") + } + if isDir { + name = strings.TrimSuffix(name, "/") + } + if name == "" || strings.HasPrefix(name, "/") || filepath.IsAbs(name) || + filepath.VolumeName(name) != "" { + return "", fmt.Errorf("absolute paths are not allowed") + } + for _, component := range strings.Split(name, "/") { + if component == "" || component == "." || component == ".." { + return "", fmt.Errorf("non-canonical path component") + } + } + cleanName := filepath.Clean(filepath.FromSlash(name)) + if cleanName == "." || cleanName == ".." || filepath.IsAbs(cleanName) || + strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes extraction root") + } + return cleanName, nil +} + +func syntheticTarBytes(t *testing.T, members ...syntheticTarMember) []byte { + t.Helper() + var output bytes.Buffer + writer := tar.NewWriter(&output) + for _, member := range members { + mode := int64(0600) + if member.typeflag == tar.TypeDir { + mode = 0700 + } + header := &tar.Header{ + Name: member.name, Typeflag: member.typeflag, Linkname: member.linkname, + Mode: mode, Size: int64(len(member.data)), + } + if member.typeflag == tar.TypeDir || member.typeflag == tar.TypeSymlink || + member.typeflag == tar.TypeLink { + header.Size = 0 + } + if err := writer.WriteHeader(header); err != nil { + t.Fatalf("write synthetic tar header %q: %v", member.name, err) + } + if member.typeflag == tar.TypeReg || member.typeflag == tar.TypeRegA { + if _, err := writer.Write(member.data); err != nil { + t.Fatalf("write synthetic tar member %q: %v", member.name, err) + } + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close synthetic tar: %v", err) + } + return output.Bytes() +} + +func requiredSyntheticTarMembers() []syntheticTarMember { + return []syntheticTarMember{ + {name: "manifest.json", typeflag: tar.TypeReg, data: []byte(`{"success":true}`)}, + {name: "chain.db/", typeflag: tar.TypeDir}, + {name: "chain.db/CURRENT", typeflag: tar.TypeReg, data: []byte("MANIFEST-000001\n")}, + {name: "chain.db/LOCK", typeflag: tar.TypeReg}, + } +} + +func TestExtractSyntheticTarRejectsUnsafeMembersWithoutWrites(t *testing.T) { + tests := []struct { + name string + malformed func(root string) ([]syntheticTarMember, string) + }{ + { + name: "absolute path", + malformed: func(root string) ([]syntheticTarMember, string) { + target := filepath.Join(root, "absolute-escape") + return []syntheticTarMember{ + {name: target, typeflag: tar.TypeReg, data: []byte("outside")}, + }, target + }, + }, + { + name: "parent traversal", + malformed: func(root string) ([]syntheticTarMember, string) { + return []syntheticTarMember{ + {name: "../traversal-escape", typeflag: tar.TypeReg, data: []byte("outside")}, + }, filepath.Join(root, "traversal-escape") + }, + }, + { + name: "duplicate names", + malformed: func(string) ([]syntheticTarMember, string) { + return []syntheticTarMember{ + {name: "duplicate.txt", typeflag: tar.TypeReg, data: []byte("first")}, + {name: "duplicate.txt", typeflag: tar.TypeReg, data: []byte("second")}, + }, "" + }, + }, + { + name: "symbolic link", + malformed: func(root string) ([]syntheticTarMember, string) { + return []syntheticTarMember{ + {name: "link", typeflag: tar.TypeSymlink, linkname: "../symlink-escape"}, + }, filepath.Join(root, "symlink-escape") + }, + }, + { + name: "hard link", + malformed: func(root string) ([]syntheticTarMember, string) { + return []syntheticTarMember{ + {name: "link", typeflag: tar.TypeLink, linkname: "../hardlink-escape"}, + }, filepath.Join(root, "hardlink-escape") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + destination := filepath.Join(root, "restore") + malformed, outside := test.malformed(root) + if outside != "" { + if _, err := os.Lstat(outside); !os.IsNotExist(err) { + t.Fatalf("outside target unexpectedly exists before test: %v", err) + } + } + + members := requiredSyntheticTarMembers() + members = append(members, + syntheticTarMember{name: "partial-before-invalid.txt", typeflag: tar.TypeReg, data: []byte("must not be written")}, + ) + members = append(members, malformed...) + + err := extractSyntheticTar(syntheticTarBytes(t, members...), destination) + if err == nil { + t.Fatal("malformed archive was accepted") + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("rejected archive created or partially restored destination: %v", err) + } + if outside != "" { + if _, err := os.Lstat(outside); !os.IsNotExist(err) { + t.Fatalf("rejected archive wrote outside restore root: %v", err) + } + } + remaining, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(remaining) != 0 { + t.Fatalf("rejected archive left staging files outside destination: %v", remaining) + } + }) + } +} + +func TestAllowedDatabasePathUnderTemporaryRoot(t *testing.T) { + root := t.TempDir() + checkpoint := filepath.Join(root, "checkpoint-1189583511", "chain.db") + if !allowedDatabasePathUnder(checkpoint, root) { + t.Fatalf("approved fixture path rejected: %s", checkpoint) + } + if allowedDatabasePathUnder(checkpoint, stagingRoot) { + t.Fatalf("fixture path unexpectedly accepted under production root") + } +} + +func TestSelectFixtureB2VersionRequiresExactIDNameAndDigest(t *testing.T) { + data := []byte("fixture ciphertext") + hash := sha256.Sum256(data) + receipt := fixtureB2Receipt{ + fileID: "fixture-version-7", fileName: "retained-checkpoints/fixture.tar.gpg", + ciphertextSHA256: hex.EncodeToString(hash[:]), + } + versions := map[string]fixtureB2Version{ + receipt.fileID: {fileID: receipt.fileID, fileName: receipt.fileName, data: data}, + "fixture-version-8": { + fileID: "fixture-version-8", fileName: receipt.fileName, data: []byte("other version"), + }, + } + selected, err := selectFixtureB2Version(versions, receipt) + if err != nil || !bytes.Equal(selected, data) { + t.Fatalf("exact pinned fixture version rejected: %v", err) + } + for name, altered := range map[string]fixtureB2Receipt{ + "version id": {fileID: "fixture-version-8", fileName: receipt.fileName, + ciphertextSHA256: receipt.ciphertextSHA256}, + "object name": {fileID: receipt.fileID, fileName: "unexpected.tar.gpg", + ciphertextSHA256: receipt.ciphertextSHA256}, + "ciphertext digest": {fileID: receipt.fileID, fileName: receipt.fileName, + ciphertextSHA256: strings.Repeat("0", 64)}, + } { + t.Run(name, func(t *testing.T) { + if _, err := selectFixtureB2Version(versions, altered); err == nil { + t.Fatalf("accepted mismatched %s", name) + } + }) + } +} 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..d06ba1c 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -112,6 +112,7 @@ func buildP2PConfig( AllowedPeers: cfg.P2P.AllowedPeers, PeerWhitelist: cfg.P2P.PeerWhitelist, MaxPendingHandshakes: cfg.P2P.MaxPendingHandshakes, + HandshakeTimeout: cfg.P2P.HandshakeTimeout, BadBlockHeightLead: cfg.P2P.BadBlockHeightLead, BadBlockBanThreshold: cfg.P2P.BadBlockBanThreshold, BadBlockBanDuration: cfg.P2P.BadBlockBanDuration, @@ -208,18 +209,9 @@ func finalityMsgFromP2P(vote p2p.VoteMsg) (consensus.FinalizeMsg, error) { // several minutes on large databases. It prints before/after size estimates // to stdout so operators can see how much space was recovered. func runCompactDB() error { - dataDir := "" - args := os.Args[1:] - for i, arg := range args { - switch { - case strings.HasPrefix(arg, "--data-dir="): - dataDir = strings.TrimPrefix(arg, "--data-dir=") - case arg == "--data-dir" && i+1 < len(args): - dataDir = args[i+1] - } - } - if dataDir == "" { - return fmt.Errorf("--compact-db requires --data-dir=") + dataDir, err := explicitMaintenanceDataDir(os.Args[1:], "--compact-db") + if err != nil { + return err } absDataDir, err := filepath.Abs(dataDir) @@ -574,18 +566,9 @@ func storeHeightIndexSentinel(dataDir string) error { // the auto-repair path inside run() knows the index has been verified. The // file lives outside chain.db/ so it is NOT copied by a chain.db-only rsync. func runRepairHeightIndex() error { - dataDir := "" - args := os.Args[1:] - for i, arg := range args { - switch { - case strings.HasPrefix(arg, "--data-dir="): - dataDir = strings.TrimPrefix(arg, "--data-dir=") - case arg == "--data-dir" && i+1 < len(args): - dataDir = args[i+1] - } - } - if dataDir == "" { - return fmt.Errorf("--repair-height-index requires --data-dir=") + dataDir, err := explicitMaintenanceDataDir(os.Args[1:], "--repair-height-index") + if err != nil { + return err } absDataDir, err := filepath.Abs(dataDir) @@ -906,8 +889,8 @@ func run() error { // // Fix: if the system config exists at the canonical production path and no // --config was given, print a clear warning, switch to the system config, - // and continue. If the system config does NOT exist this guard is a no-op - // so the binary still works correctly in development / CI environments. + // and continue. If the system config does NOT exist, require an explicit + // --config instead of allowing maintenance against the development default. const systemConfigPath = "/etc/aperod/node.yaml" maintenanceMode := repairDB || resetTip || rebuildKeyImages || rebuildSpentIndex if maintenanceMode && !configFlagExplicit { @@ -1237,6 +1220,7 @@ func run() error { mempoolCfg := core.DefaultMempoolConfig() mempoolCfg.RingCTV4ActivationHeight = cfg.Consensus.RingCTV4ActivationHeight mempoolCfg.RingCTCLSAGActivationHeight = cfg.Consensus.RingCTCLSAGActivationHeight + mempoolCfg.RingCTAmountBindingActivationHeight = cfg.Consensus.RingCTAmountBindingActivationHeight mempoolCfg.AVMActivationHeight = cfg.Consensus.AVMActivationHeight mempoolCfg.CurrentHeight = chain.Height if cfg.Consensus.AVMActivationHeight != 0 { @@ -1258,7 +1242,6 @@ func run() error { // 700 MB in-memory sorted slice, eliminating the main RSS baseline // and the ≈768 KB/h growth that accumulated between restarts. utxos := core.NewUTXOSetWithDB(db) - utxos.SetCLSAGActivationHeight(cfg.Consensus.RingCTCLSAGActivationHeight) if cfg.Consensus.RingCTCLSAGActivationHeight != 0 { if err := db.EnsureRingMemberIndex(); err != nil { return fmt.Errorf("prepare CLSAG ring-member index: %w", err) @@ -1398,6 +1381,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 @@ -1414,11 +1405,11 @@ func run() error { var apiSrv *api.Server if cfg.API.Enabled && cfg.API.ListenAddr != "" { apiSrv = api.NewServer(cfg.API.ListenAddr, chain, mempool, utxos, log) + apiSrv.SetRewardAddress(cfg.Consensus.RewardAddress) apiSrv.SetStakedCollateralRingExclusionActivationHeight( cfg.Consensus.StakedCollateralRingExclusionActivationHeight, ) apiSrv.SetAllowedOrigins(cfg.API.CORS) - apiSrv.SetNodeViewKey(cfg.Consensus.ViewKey) apiSrv.SetStore(db) var apiGuardianAnchor crypto.Hash32 if raw, err := hex.DecodeString(cfg.Consensus.GuardianFundChainAnchor); err == nil && len(raw) == 32 { @@ -2492,7 +2483,6 @@ func run() error { if !utxoFromIndex { // Partial load failed — clear and fall back to block scan. utxos = core.NewUTXOSetWithDB(db) - utxos.SetCLSAGActivationHeight(cfg.Consensus.RingCTCLSAGActivationHeight) utxos.OnUTXOSpent = func(txHash crypto.Hash32, outIdx uint32) { if spentErr := db.MarkUTXOSpent(txHash, outIdx); spentErr != nil { log.Warn("failed to persist spent UTXO", "err", spentErr) @@ -2559,11 +2549,17 @@ func run() error { completeH, _, _ := db.LoadTxIdxCompleteHeight() backfillTarget := startLoad - 1 if completeH < backfillTarget { + pruneBelowHeight := txIndexPruneBelowHeight( + tipHeight, + cfg.Pruning.Mode, + cfg.Pruning.KeepBlocks, + ) log.Info("tx index background backfill queued", "from_height", completeH+1, "to_height", backfillTarget, + "prune_below_height", pruneBelowHeight, ) - go backfillTxIdxRange(completeH, backfillTarget, db, log) + go backfillTxIdxRange(completeH, backfillTarget, pruneBelowHeight, db, log) } } } @@ -2735,31 +2731,33 @@ 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, - SaleMigration: saleMigration, - GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, - GenesisVestingFunding: genesisVestingFunding, - BlockTime: cfg.Consensus.BlockTime, - BFTThreshold: genesisConfig.BFTThreshold, - Validators: validators, - Registry: registry, - MyKey: consensusMyKey, - RewardAddress: cfg.Consensus.RewardAddress, - StopProducingAtHeight: cfg.Consensus.StopProducingAtHeight, - BlockRewardNAPR: cfg.Consensus.BlockRewardNAPR, - StakingPoolNAPR: cfg.Consensus.StakingPoolNAPR, - TailRewardNAPR: cfg.Consensus.TailRewardNAPR, - Store: db, - OracleURL: cfg.Consensus.OracleURL, - OracleMaxDeviation: cfg.Consensus.OracleMaxDeviation, - RingCTV4ActivationHeight: cfg.Consensus.RingCTV4ActivationHeight, - RingCTCLSAGActivationHeight: cfg.Consensus.RingCTCLSAGActivationHeight, + ValidatorAuthorityFork: authorityFork, + FinalityMigration: finalityMigration, + FinalityMigrationTrustAuthorities: finalityMigrationTrustAuthorities, + LPoDMigration: lpodMigration, + LPoDDailyUpgradeHeight: cfg.Consensus.LPoDDailyUpgradeHeight, + SaleMigration: saleMigration, + GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, + GenesisVestingFunding: genesisVestingFunding, + BlockTime: cfg.Consensus.BlockTime, + BFTThreshold: genesisConfig.BFTThreshold, + Validators: validators, + Registry: registry, + MyKey: consensusMyKey, + RewardAddress: cfg.Consensus.RewardAddress, + StopProducingAtHeight: cfg.Consensus.StopProducingAtHeight, + BlockRewardNAPR: cfg.Consensus.BlockRewardNAPR, + StakingPoolNAPR: cfg.Consensus.StakingPoolNAPR, + TailRewardNAPR: cfg.Consensus.TailRewardNAPR, + Store: db, + OracleURL: cfg.Consensus.OracleURL, + OracleMaxDeviation: cfg.Consensus.OracleMaxDeviation, + RingCTV4ActivationHeight: cfg.Consensus.RingCTV4ActivationHeight, + RingCTCLSAGActivationHeight: cfg.Consensus.RingCTCLSAGActivationHeight, + RingCTAmountBindingActivationHeight: cfg.Consensus.RingCTAmountBindingActivationHeight, StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRingExclusionActivationHeight, AVMActivationHeight: cfg.Consensus.AVMActivationHeight, AVMGasBurnActivationHeight: cfg.Consensus.AVMGasBurnActivationHeight, @@ -3000,6 +2998,11 @@ func run() error { }(periodicSnap, h, periodicActive) }, }, chain, mempool, log) + if failed, err := engine.ReconcileRewardAddressAdminMintIntents(); err != nil { + return fmt.Errorf("admin mint startup reconciliation: %w", err) + } else if failed > 0 { + log.Warn("failed queued admin mints to validator reward address at startup", "count", failed) + } if apiSrv != nil { apiSrv.SetLPoDConfig(lpodMigration, engine.IsFinalizedHash) apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalizedHash) @@ -3027,6 +3030,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 @@ -3161,13 +3165,22 @@ func run() error { }() // Nightly restart scheduler: at maintenance.restart_at (UTC, HH:MM) the - // node sends SIGTERM to itself for a graceful RAM-reclaim restart. Go - // heap fragmentation grows ~1.3 GB/h in production; a nightly SIGTERM - // triggers a clean snapshot-save → restart before memory pressure builds. - // The scheduler respects the stop channel so a concurrent external - // SIGTERM (operator or watchdog) simply cancels the pending self-restart. - if os.Getenv("SCHED_RESTART_ENABLED") == "true" && cfg.Maintenance.RestartAt != "" { + // node sends SIGTERM to itself for a graceful RAM-reclaim restart. The + // explicit node.yaml switch and existing deployment environment gate are + // both reflected in a startup log so operators can see the effective state. + // The scheduler respects stop so an external SIGTERM cancels the pending restart. + if logNightlyRestartSchedulerStatus( + log, + cfg.Maintenance.RestartEnabled, + cfg.Maintenance.RestartAt, + os.Getenv("SCHED_RESTART_ENABLED") == "true", + ) { h, m, _ := config.ParseRestartAt(cfg.Maintenance.RestartAt) // already validated by Validate() + if apiSrv != nil { + if err := apiSrv.SetNightlyRestartSchedule(cfg.Maintenance.RestartAt); err != nil { + return fmt.Errorf("nightly restart: failed to expose active schedule through status API: %w", err) + } + } go runNightlyRestartScheduler(stop, h, m, time.Now, time.After, func() { log.Info("nightly restart: sending SIGTERM to self for scheduled RAM-reclaim restart", "restart_at", fmt.Sprintf("%02d:%02d UTC", h, m)) @@ -3346,8 +3359,7 @@ func run() error { ) if err := host.Start(); err != nil { - log.Error("p2p failed to start", "err", err) - // Non-fatal: node runs standalone if P2P fails. + return fmt.Errorf("p2p failed to start; refusing to run a node without its configured peer network: %w", err) } else { log.Info("p2p started", "listen", tcpAddr, "bootnodes", len(bootnodes)) defer host.Stop() @@ -5066,38 +5078,95 @@ func replayPostSnapshotGap( return } -// backfillTxIdxRange writes t/ LevelDB entries for every transaction in every -// block from fromH+1 through toH. -// -// It halts at the first unrecoverable error — block missing from the store, -// JSON decode failure, or PutTxIdx write failure — to avoid advancing the -// txidx_complete_height marker past heights whose t/ entries were not actually -// written. Only the last successfully indexed height is saved to the marker, -// so the next restart retries the failed range rather than treating it as done. +// txIndexPruneBelowHeight returns the exclusive lower bound below which light +// pruning removes transaction data. It mirrors Chain.PruneOldData; archive +// mode and chains not yet beyond KeepBlocks have no pruned prefix. +func txIndexPruneBelowHeight(tipHeight uint64, mode string, keepBlocks uint64) uint64 { + if mode != "light" || tipHeight <= keepBlocks { + return 0 + } + return tipHeight - keepBlocks +} + +// backfillTxIdxRange writes t/ LevelDB entries for every available transaction +// in every block from fromH+1 through toH. Missing block bodies are warned +// about and skipped so one unavailable historical body cannot block indexing +// later blocks. Store read errors, JSON decode failures, and PutTxIdx write +// failures still halt the backfill. The completion marker records the highest +// height examined, including heights whose bodies were unavailable. // // This function is called in a background goroutine by the snapshot fast-path // startup; it is package-level (not inlined) so tests can exercise it directly. -func backfillTxIdxRange(fromH, toH uint64, db *store.DB, log *slog.Logger) { +func backfillTxIdxRange(fromH, toH, pruneBelowHeight uint64, db *store.DB, log *slog.Logger) { const progressInterval = uint64(10_000) lastGood := fromH + var missingStart, missingEnd, missingCount uint64 + flushMissingRange := func() { + if missingCount == 0 { + return + } + log.Info("tx index background backfill: missing block data range skipped", + "from_height", missingStart, + "to_height", missingEnd, + "missing_blocks", missingCount, + ) + missingStart, missingEnd, missingCount = 0, 0, 0 + } for h := fromH + 1; h <= toH; h++ { raw, fetchErr := db.GetRawBlockByHeight(h) if fetchErr != nil { // Real LevelDB read error — halt and retry the whole range next restart. + flushMissingRange() log.Warn("tx index background backfill: block fetch error — halting; will retry next restart", "height", h, "err", fetchErr) _ = db.StoreTxIdxCompleteHeight(lastGood) return } if raw == nil { - // Height entry exists but raw-block data is absent (e.g. post-repair - // gap or pruned block). Nothing to index; skip and continue so all - // subsequent blocks are not permanently blocked by this gap. - log.Warn("tx index background backfill: block data missing — skipping height", - "height", h) - lastGood = h // mark as processed so we don't retry this gap forever + if h < pruneBelowHeight { + // A missing body below the configured light-pruning boundary is + // expected. Mark it processed immediately so a later restart does + // not retry the same pruned height forever. + log.Warn("tx index background backfill: block pruned, skipping", + "height", h, + "prune_below_height", pruneBelowHeight, + ) + lastGood = h + if markerErr := db.StoreTxIdxCompleteHeight(lastGood); markerErr != nil { + log.Warn("tx index background backfill: failed to persist pruned skip — halting", + "height", h, "err", markerErr) + return + } + continue + } + // A nil body with no LevelDB read error is not actionable by this + // indexer. Skip it, persist progress, and continue so subsequent + // transactions keep their fast-path index entries. Warn once when a + // gap begins, then aggregate its final range to avoid flooding logs + // when a historical range is unavailable. + if missingCount == 0 { + missingStart = h + log.Warn("tx index background backfill: missing block data; skipping", + "height", h, + "prune_below_height", pruneBelowHeight, + ) + } + missingEnd = h + missingCount++ + lastGood = h + if h%progressInterval == 0 { + if markerErr := db.StoreTxIdxCompleteHeight(lastGood); markerErr != nil { + flushMissingRange() + log.Warn("tx index background backfill: failed to persist missing-body skip — halting", + "height", h, "err", markerErr) + return + } + log.Info("tx index background backfill progress", + "height", h, "target", toH, "skipped_missing_blocks", missingCount) + } continue } + flushMissingRange() var b core.Block if jsonErr := json.Unmarshal(raw, &b); jsonErr != nil { log.Warn("tx index background backfill: block decode failed — halting; will retry next restart", @@ -5121,6 +5190,7 @@ func backfillTxIdxRange(fromH, toH uint64, db *store.DB, log *slog.Logger) { "height", h, "target", toH) } } + flushMissingRange() _ = db.StoreTxIdxCompleteHeight(toH) log.Info("tx index background backfill complete", "from_height", fromH+1, diff --git a/cmd/node/maintenance_args.go b/cmd/node/maintenance_args.go new file mode 100644 index 0000000..de2043a --- /dev/null +++ b/cmd/node/maintenance_args.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "strings" +) + +// explicitMaintenanceDataDir is shared by the early, config-free mutation +// commands. Never infer their target from ./data, environment or node.yaml: +// selecting a chain for compaction/repair must be an explicit operator action. +func explicitMaintenanceDataDir(args []string, command string) (string, error) { + var dataDir string + found := false + for i := 0; i < len(args); i++ { + arg := args[i] + if arg != "--data-dir" && !strings.HasPrefix(arg, "--data-dir=") { + continue + } + if found { + return "", fmt.Errorf("%s: --data-dir must be supplied exactly once; refusing an ambiguous maintenance target", command) + } + found = true + if arg == "--data-dir" { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return "", fmt.Errorf("%s: --data-dir requires a path, not another flag", command) + } + i++ + dataDir = args[i] + } else { + dataDir = strings.TrimPrefix(arg, "--data-dir=") + } + if strings.TrimSpace(dataDir) == "" { + return "", fmt.Errorf("%s: --data-dir must not be empty", command) + } + } + if !found { + return "", fmt.Errorf("%s requires --data-dir=; refusing to infer a maintenance target from ./data or --config", command) + } + return dataDir, nil +} diff --git a/cmd/node/maintenance_args_test.go b/cmd/node/maintenance_args_test.go new file mode 100644 index 0000000..cc24d94 --- /dev/null +++ b/cmd/node/maintenance_args_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestExplicitMaintenanceDataDir(t *testing.T) { + for _, command := range []string{"--compact-db", "--repair-height-index"} { + t.Run(command, func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + wantErr string + }{ + {name: "missing", wantErr: "requires --data-dir="}, + {name: "config is not a data directory", args: []string{"--config", "/etc/aperod/node.yaml"}, wantErr: "requires --data-dir="}, + {name: "missing separate value", args: []string{"--data-dir"}, wantErr: "requires a path"}, + {name: "next flag is not a path", args: []string{"--data-dir", "--repair-db"}, wantErr: "requires a path"}, + {name: "empty separate value", args: []string{"--data-dir", ""}, wantErr: "must not be empty"}, + {name: "empty equals value", args: []string{"--data-dir="}, wantErr: "must not be empty"}, + {name: "whitespace is not a path", args: []string{"--data-dir= \t"}, wantErr: "must not be empty"}, + {name: "conflicting duplicate", args: []string{"--data-dir=/first", "--data-dir", "/second"}, wantErr: "exactly once"}, + {name: "identical duplicate", args: []string{"--data-dir", "/first", "--data-dir=/first"}, wantErr: "exactly once"}, + {name: "empty duplicate cannot override target", args: []string{"--data-dir=/first", "--data-dir="}, wantErr: "exactly once"}, + {name: "equals", args: []string{"--data-dir=/chosen/chain"}, want: "/chosen/chain"}, + {name: "separate", args: []string{"--data-dir", "/chosen/chain"}, want: "/chosen/chain"}, + {name: "relative path", args: []string{"--data-dir=./chosen"}, want: "./chosen"}, + {name: "space preserved", args: []string{"--data-dir", "/chosen/ chain "}, want: "/chosen/ chain "}, + {name: "before command", args: []string{"--data-dir", "/chosen", command}, want: "/chosen"}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{command}, tc.args...) + got, err := explicitMaintenanceDataDir(args, command) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), command) || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got path %q, error %v; want command-specific error containing %q", got, err, tc.wantErr) + } + if got != "" { + t.Errorf("invalid arguments exposed a target %q", got) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, tc.want) + } + }) + } + }) + } +} + +func TestEarlyMaintenanceCommandsRejectAmbiguousTargetsBeforeOpeningDB(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + t.Setenv("APEROD_DATA_DIR", filepath.Join(root, "chosen")) + before := make(map[string]map[string][32]byte) + for _, name := range []string{"data", "--repair-db", "chosen", "other"} { + dataDir := filepath.Join(root, name) + createMaintenanceTestDB(t, dataDir, 100) + before[dataDir] = maintenanceDBState(t, dataDir) + } + for _, command := range []string{"--compact-db", "--repair-height-index"} { + for _, tc := range []struct { + name string + args []string + want string + }{ + {name: "no target", want: "requires --data-dir="}, + {name: "no value", args: []string{"--data-dir"}, want: "requires a path"}, + {name: "empty value", args: []string{"--data-dir="}, want: "must not be empty"}, + {name: "flag names an existing directory", args: []string{"--data-dir", "--repair-db"}, want: "requires a path"}, + {name: "two existing targets", args: []string{"--data-dir=chosen", "--data-dir=other"}, want: "exactly once"}, + } { + t.Run(command+"/"+tc.name, func(t *testing.T) { + oldArgs := os.Args + os.Args = append([]string{"aperod-node", command}, tc.args...) + t.Cleanup(func() { os.Args = oldArgs }) + err := run() + if err == nil || !strings.Contains(err.Error(), command) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("wanted target-argument refusal %q, got %v", tc.want, err) + } + for dataDir, state := range before { + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, state) { + t.Errorf("invalid maintenance arguments changed %s", dataDir) + } + } + }) + } + } +} diff --git a/cmd/node/maintenance_config_switch_linux_test.go b/cmd/node/maintenance_config_switch_linux_test.go new file mode 100644 index 0000000..5aa93a1 --- /dev/null +++ b/cmd/node/maintenance_config_switch_linux_test.go @@ -0,0 +1,204 @@ +//go:build linux + +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// This helper executes the real dispatcher, not a copy of the argument parser. +// The parent puts ONLY the helper's /etc in a private mount namespace. +func TestMaintenanceConfigSwitchHelper(t *testing.T) { + if os.Getenv("APEROD_MAINTENANCE_CONFIG_HELPER") != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{"aperod-node"}, os.Args[i+1:]...) + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "aperod-node: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + } + t.Fatal("maintenance helper requires -- followed by node arguments") +} + +func TestMaintenanceConfigAutoSwitchOpensSelectedChain(t *testing.T) { + // A dedicated Linux CI job can make unavailable isolation a hard failure + // instead of reporting a skipped integration test as complete coverage. + unavailable := func(format string, args ...any) { + t.Helper() + if os.Getenv("APEROD_REQUIRE_MAINTENANCE_NAMESPACE") == "1" { + t.Fatalf(format, args...) + } + t.Skipf(format, args...) + } + unshare, err := exec.LookPath("unshare") + if err != nil { + unavailable("mount-namespace integration test requires unshare: %v", err) + } + if _, err := exec.LookPath("mount"); err != nil { + unavailable("mount-namespace integration test requires mount: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + probe := exec.CommandContext(ctx, unshare, "--user", "--map-root-user", "--mount", + "sh", "-eu", "-c", "mount --make-rprivate /") + if output, err := probe.CombinedOutput(); err != nil { + unavailable("private user/mount namespaces unavailable: %v\n%s", err, output) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + mode string + explicit bool + configFirst bool + missingSystem bool + corruptSystem bool + requireDataDir bool + }{ + {name: "repair without config opens system chain"}, + {name: "reset-tip without config opens system chain", mode: "--reset-tip"}, + {name: "key-image rebuild without config opens system chain", mode: "--rebuild-key-images"}, + {name: "spent-index rebuild without config opens system chain", mode: "--rebuild-spent-index"}, + {name: "explicit config before repair wins", explicit: true, configFirst: true}, + {name: "explicit config after repair ignores broken system config", explicit: true, corruptSystem: true}, + {name: "missing system config refuses local fallback", missingSystem: true}, + {name: "compact refuses implicit data directory despite system config", mode: "--compact-db", requireDataDir: true}, + {name: "height-index repair refuses implicit data directory despite system config", mode: "--repair-height-index", requireDataDir: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + fakeEtc := filepath.Join(root, "etc") + localData := filepath.Join(root, "local-chain") + systemData := filepath.Join(root, "system-chain") + explicitData := filepath.Join(root, "explicit-chain") + before := make(map[string]map[string][32]byte) + for i, dataDir := range []string{localData, systemData, explicitData} { + createMaintenanceTestDB(t, dataDir, uint64((i+1)*100)) + // Stop at the read-only stale-target preflight, after the real + // LevelDB tip has been read but before RecoverFile or networking. + marker := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-20000.json.gz", snapVersion)) + if err := os.WriteFile(marker, []byte("stale-target marker"), 0600); err != nil { + t.Fatal(err) + } + before[dataDir] = maintenanceDBState(t, dataDir) + } + writeConfig := func(path, dataDir string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("network: testnet\ndata_dir: %q\n", dataDir) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + // A valid local default makes removal of the auto-switch guard + // observable: it would inspect tip 100 instead of the system tip 200. + writeConfig(filepath.Join(root, "config", "testnet.yaml"), localData) + explicitConfig := filepath.Join(root, "explicit.yaml") + writeConfig(explicitConfig, explicitData) + if err := os.MkdirAll(fakeEtc, 0700); err != nil { + t.Fatal(err) + } + if !tc.missingSystem { + systemConfig := filepath.Join(fakeEtc, "aperod", "node.yaml") + writeConfig(systemConfig, systemData) + if tc.corruptSystem { + if err := os.WriteFile(systemConfig, []byte("invalid: [yaml"), 0600); err != nil { + t.Fatal(err) + } + } + } + + mode := tc.mode + if mode == "" { + mode = "--repair-db" + } + nodeArgs := []string{mode} + if tc.explicit { + if tc.configFirst { + nodeArgs = []string{"--config", explicitConfig, mode} + } else { + nodeArgs = append(nodeArgs, "--config", explicitConfig) + } + } + // All mounts are private and disappear with the child. Never write + // the host's /etc/aperod/node.yaml, even when it already exists. + script := `mount --make-rprivate / +mount --bind "$1" /etc +shift +exec "$@"` + args := []string{"--user", "--map-root-user", "--mount", "sh", "-eu", "-c", + script, "maintenance-config-test", fakeEtc, executable, + "-test.run=^TestMaintenanceConfigSwitchHelper$", "--"} + args = append(args, nodeArgs...) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, unshare, args...) + cmd.Dir = root + cmd.Env = append(os.Environ(), "APEROD_MAINTENANCE_CONFIG_HELPER=1", "APEROD_DATA_DIR=") + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("maintenance subprocess timed out: %v\n%s", ctx.Err(), output) + } + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + t.Fatalf("expected read-only maintenance refusal (exit 1), got %v\n%s", err, output) + } + text := string(output) + warned := strings.Contains(text, "WARNING: --config was not supplied; defaulting to /etc/aperod/node.yaml") + if warned != (!tc.explicit && !tc.missingSystem && !tc.requireDataDir) { + t.Fatalf("unexpected auto-switch warning (present=%v):\n%s", warned, text) + } + if tc.requireDataDir { + if !strings.Contains(text, mode+" requires --data-dir=") || + strings.Contains(text, "MAINTENANCE PREFLIGHT") { + t.Fatalf("early maintenance command did not refuse an implicit target:\n%s", text) + } + } else if tc.missingSystem { + if !strings.Contains(text, "MAINTENANCE REFUSED: --config was not supplied") || + strings.Contains(text, "MAINTENANCE PREFLIGHT") { + t.Fatalf("missing system config did not refuse before opening a chain:\n%s", text) + } + } else { + wantConfig, wantData, wantHeight := "/etc/aperod/node.yaml", systemData, 200 + if tc.explicit { + wantConfig, wantData, wantHeight = explicitConfig, explicitData, 300 + } + for _, want := range []string{ + "resolved_config: " + wantConfig, + "absolute_data_dir: " + wantData, + fmt.Sprintf("tip_height: %d\n", wantHeight), + "appears stale or internally inconsistent", + } { + if !strings.Contains(text, want) { + t.Errorf("selected-chain evidence missing %q:\n%s", want, text) + } + } + if strings.Contains(text, "absolute_data_dir: "+localData) { + t.Errorf("maintenance selected the local default chain:\n%s", text) + } + } + for dataDir, state := range before { + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, state) { + t.Errorf("maintenance changed fixture database %s before refusing", dataDir) + } + } + }) + } +} 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/nightly_restart.go b/cmd/node/nightly_restart.go index 5c9ebd9..7a2ca40 100644 --- a/cmd/node/nightly_restart.go +++ b/cmd/node/nightly_restart.go @@ -4,18 +4,43 @@ import ( "fmt" "log/slog" "time" + + "github.com/aperod/aperod/config" ) // durationUntilRestartAt returns how long until the next occurrence of // hour:minute UTC, given now. The result is always in the range (0, 24h]: // if now is exactly at hour:minute the next occurrence is 24 h from now. func durationUntilRestartAt(now time.Time, hour, minute int) time.Duration { - now = now.UTC() - target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, time.UTC) - if !target.After(now) { - target = target.Add(24 * time.Hour) + return config.NextRestartAt(now, hour, minute).Sub(now) +} + +// logNightlyRestartSchedulerStatus reports the effective in-process scheduler +// state and returns whether the scheduler should be started. The environment +// gate is retained for existing deployments; maintenance.restart_enabled is +// the explicit node.yaml opt-out. +func logNightlyRestartSchedulerStatus( + log *slog.Logger, + restartEnabled bool, + restartAt string, + envEnabled bool, +) bool { + var disabledReason string + switch { + case !restartEnabled: + disabledReason = "maintenance.restart_enabled is false" + case restartAt == "": + disabledReason = "maintenance.restart_at is empty" + case !envEnabled: + disabledReason = "SCHED_RESTART_ENABLED is not true" + } + if disabledReason != "" { + log.Info("nightly restart: in-process scheduler disabled", "reason", disabledReason) + return false } - return target.Sub(now) + + log.Info("nightly restart: in-process scheduler active", "restart_at", restartAt) + return true } // runNightlyRestartScheduler sleeps until the configured restart window and diff --git a/cmd/node/nightly_restart_test.go b/cmd/node/nightly_restart_test.go index 47cf5f5..4bd9de3 100644 --- a/cmd/node/nightly_restart_test.go +++ b/cmd/node/nightly_restart_test.go @@ -12,6 +12,7 @@ package main // restart during a concurrent graceful shutdown). import ( + "log/slog" "testing" "time" @@ -118,6 +119,81 @@ func TestParseRestartAt(t *testing.T) { } } +func TestLogNightlyRestartSchedulerStatus(t *testing.T) { + tests := []struct { + name string + restartEnable bool + restartAt string + envEnabled bool + wantActive bool + wantReason string + }{ + { + name: "disabled by node.yaml", + restartEnable: false, + envEnabled: true, + wantReason: "maintenance.restart_enabled is false", + }, + { + name: "disabled by empty restart time", + restartEnable: true, + envEnabled: true, + wantReason: "maintenance.restart_at is empty", + }, + { + name: "disabled by deployment environment gate", + restartEnable: true, + restartAt: "04:00", + envEnabled: false, + wantReason: "SCHED_RESTART_ENABLED is not true", + }, + { + name: "active", + restartEnable: true, + restartAt: "04:00", + envEnabled: true, + wantActive: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + capture := &captureHandler{} + log := slog.New(capture) + gotActive := logNightlyRestartSchedulerStatus( + log, tc.restartEnable, tc.restartAt, tc.envEnabled, + ) + if gotActive != tc.wantActive { + t.Fatalf("active = %t, want %t", gotActive, tc.wantActive) + } + + wantMessage := "nightly restart: in-process scheduler disabled" + if tc.wantActive { + wantMessage = "nightly restart: in-process scheduler active" + } + rec, ok := capture.find(wantMessage) + if !ok { + t.Fatalf("expected startup log %q", wantMessage) + } + if rec.Level != slog.LevelInfo { + t.Errorf("startup log level = %v, want INFO", rec.Level) + } + if tc.wantReason != "" { + var gotReason string + rec.Attrs(func(attr slog.Attr) bool { + if attr.Key == "reason" { + gotReason = attr.Value.String() + } + return true + }) + if gotReason != tc.wantReason { + t.Errorf("disabled reason = %q, want %q", gotReason, tc.wantReason) + } + } + }) + } +} + // ─── runNightlyRestartScheduler ─────────────────────────────────────────────── // TestNightlyRestartScheduler_FiresAtCorrectDelay verifies that the scheduler diff --git a/cmd/node/p2p_config_mapping_test.go b/cmd/node/p2p_config_mapping_test.go index b239aba..a6d22d9 100644 --- a/cmd/node/p2p_config_mapping_test.go +++ b/cmd/node/p2p_config_mapping_test.go @@ -14,6 +14,37 @@ import ( "github.com/aperod/aperod/config" ) +func TestBuildP2PConfig_HandshakeTimeoutFromYAML(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + want time.Duration + }{ + {"production default", "p2p:\n max_peers: 10\n", 3 * time.Second}, + {"custom", "p2p:\n handshake_timeout: 2s\n", 2 * time.Second}, + {"fractional", "p2p:\n handshake_timeout: 1500ms\n", 1500 * time.Millisecond}, + {"explicit host fallback", "p2p:\n handshake_timeout: 0s\n", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.yaml") + if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + mapped := buildP2PConfig(cfg, "127.0.0.1:0", nil, "test-node", nil, "") + if mapped.HandshakeTimeout != tc.want { + t.Fatalf("mapped handshake timeout = %v, want %v", mapped.HandshakeTimeout, tc.want) + } + }) + } +} + func TestBuildP2PConfig_GetBlockStallTimeoutFromYAML(t *testing.T) { dir := t.TempDir() yamlPath := filepath.Join(dir, "node.yaml") diff --git a/cmd/node/registry_bootstrap.go b/cmd/node/registry_bootstrap.go index 120fb0e..87dcab3 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) @@ -161,8 +170,12 @@ func replayOperatorRegistryBootstrap(db *store.DB, utxos *core.UTXOSet, registry return fmt.Errorf("registry bootstrap decode block %d: %w", height, err) } index, found, err := db.GetCanonicalHash(height) + // ValidatorPub is the collateral identity. After a pinned authority + // fork activates, blocks for that identity must be verified with its + // height-scoped replacement key, matching runtime consensus. + authority := registry.SignatureAuthority(block.Header.ValidatorPub, height) if err != nil || !found || block.Header.Height != height || block.Hash() != index || block.Header.PrevHash != previous || - !block.Header.VerifySignature() || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + !block.Header.VerifySignature(authority) || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { return fmt.Errorf("registry bootstrap block %d fails canonical continuity/signature/Merkle checks", height) } if !bootstrapProposerAt(registry.GetActiveValidators(), block.Header.ValidatorPub, block.Header.Round) { 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..c2aa97b 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 replay 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 wrong-round-slot database: %v", err) + } + }() priv1, pub1, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) @@ -162,12 +170,94 @@ func TestBootstrapWrongRoundSlot(t *testing.T) { } } +func TestBootstrapUsesHeightScopedValidatorAuthority(t *testing.T) { + for _, tc := range []struct { + name string + configureFork bool + signer string + wantSuccess bool + }{ + {name: "pinned replacement accepted", configureFork: true, signer: "replacement", wantSuccess: true}, + {name: "old identity rejected after activation", configureFork: true, signer: "identity"}, + {name: "replacement rejected without pinned fork", signer: "replacement"}, + } { + t.Run(tc.name, func(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close authority replay database: %v", err) + } + }() + + identityPriv, identityPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + replacementPriv, replacementPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + anchor := makeSignedBlk(t, 1, crypto.Hash32{}, identityPriv, identityPub) + putRawBlk(t, db, anchor) + + registry := core.NewValidatorRegistry() + registry.InitFromGenesis([]crypto.ValidatorPubKey{identityPub}, core.MinStakeNAPR*10) + fork := &core.ValidatorAuthorityFork{ + Version: 1, + Genesis: crypto.HashStr("authority-replay-genesis"), + Height: 2, + Parent: anchor.Hash(), + Identity: identityPub, + Replacement: replacementPub, + RegistrySHA256: crypto.HashStr("authority-replay-registry"), + } + fork.Possession, err = replacementPriv.Sign(fork.Message()) + if err != nil { + t.Fatal(err) + } + if tc.configureFork { + if err := registry.ConfigureSignatureAuthority(fork, 2); err != nil { + t.Fatal(err) + } + } + + signer := identityPriv + if tc.signer == "replacement" { + signer = replacementPriv + } + block := makeSignedBlk(t, 2, anchor.Hash(), signer, identityPub) + putRawBlk(t, db, block) + if err := db.PutTip(block.Hash(), 2); err != nil { + t.Fatal(err) + } + + snapshot := &startupSnapshot{TipHeight: 1} + utxos := core.NewUTXOSetWithDB(db) + utxos.RestoreFromSnapshot(snapshot.UTXOs) + err = replayOperatorRegistryBootstrap(db, utxos, registry, snapshot, 2, silentLog()) + if tc.wantSuccess && err != nil { + t.Fatalf("pinned replacement signature rejected: %v", err) + } + if !tc.wantSuccess && err == nil { + t.Fatal("block signed by an unauthorized key was accepted") + } + }) + } +} + func TestBootstrapAuthenticationBeforeDecompressionAndNullEntries(t *testing.T) { db, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap authentication 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 +288,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 real archive database: %v", err) + } + }() _, tip, err := db.GetTip() if err != nil { t.Fatal(err) @@ -250,7 +344,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 err := db.Close(); err != nil { + t.Errorf("close diagnostic registry database: %v", err) + } } } @@ -261,7 +357,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 +404,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 +437,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 +540,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 +593,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/scan.go b/cmd/node/scan.go index a50b9f8..f846615 100644 --- a/cmd/node/scan.go +++ b/cmd/node/scan.go @@ -91,6 +91,9 @@ type startupScanParams struct { // Tests inject a recording function here to assert on the exact boundary // sequence without running a real GC cycle. Production callers leave it nil. GCHook func(utxoCount uint64) + // GCHookInterval overrides the production cadence when GCHook is set. + // Zero preserves the production interval. + GCHookInterval uint64 } // runStartupScan performs the unified startup block scan: @@ -106,8 +109,12 @@ type startupScanParams struct { // The caller is responsible for loading the key-image index before calling // this function (when KiFromIndex is true). func runStartupScan(p startupScanParams) (startupScanResult, error) { - const syncProgressInterval = uint64(1000) // report every 1 000 blocks - const gcUTXOInterval = uint64(50000) // force GC every 50 000 UTXOs processed + const syncProgressInterval = uint64(1000) // report every 1 000 blocks + const defaultGCUTXOInterval = uint64(50000) // force GC every 50 000 UTXOs processed + gcUTXOInterval := defaultGCUTXOInterval + if p.GCHook != nil && p.GCHookInterval != 0 { + gcUTXOInterval = p.GCHookInterval + } // ── DB-index fast path ──────────────────────────────────────────────── // When UTXOFromIndex is true, both the active UTXO set and the spent diff --git a/cmd/node/scan_test.go b/cmd/node/scan_test.go index 5ee7a44..5b37a97 100644 --- a/cmd/node/scan_test.go +++ b/cmd/node/scan_test.go @@ -1,21 +1,22 @@ package main // scan_test.go — verifies that the periodic GC hook in runStartupScan fires at -// the correct cumulative UTXO output boundaries (every gcUTXOInterval = 50 000 -// outputs) regardless of how those outputs are distributed across blocks. +// the correct cumulative UTXO output boundaries regardless of how outputs are +// distributed across blocks. Tests use a smaller injected interval than the +// production 50 000-output cadence to keep the LevelDB integration fixtures fast. // // Two scenarios are covered: // // 1. Varied output counts across blocks (TestStartupScanGCCadenceVariedBlocks) -// Blocks cycle through [100, 500, 1000] outputs each. The total is designed -// to cross the 50 000-UTXO boundary exactly twice (at 50 000 and 100 000), +// Blocks cycle through [1, 5, 10] outputs each. The total is designed +// to cross the 50-output boundary exactly twice (at 50 and 100), // and no single block crosses more than one boundary. The test asserts that -// GCHook is called with utxoCount == 50 000 and then 100 000, in that order. +// GCHook is called with utxoCount == 50 and then 100, in that order. // // 2. Single block crossing multiple thresholds // (TestStartupScanGCCadenceSingleBlockMultipleThresholds) -// A single block holds 150 001 outputs. The inner output loop must call -// GCHook with utxoCount values 50 000, 100 000, and 150 000, in that order. +// A single block holds 151 outputs. The inner output loop must call GCHook +// with utxoCount values 50, 100, and 150, in that order. import ( "encoding/json" @@ -124,24 +125,21 @@ func buildVariedChain( return db, tip } -// gcTestInterval mirrors the unexported gcUTXOInterval const from scan.go so -// that test assertions can reference the same value without hard-coding magic -// numbers. -const gcTestInterval = uint64(50000) +const gcTestInterval = uint64(50) // TestStartupScanGCCadenceVariedBlocks confirms that the GC hook is called -// with utxoCount == 50 000 and then 100 000, in that order, when blocks have -// varying output counts that together cross those boundaries. +// with utxoCount == 50 and then 100, in that order, when blocks have varying +// output counts that together cross those boundaries. // // Chain design: -// - Blocks cycle through [100, 500, 1000] outputs (1 600 per 3-block cycle). -// - 63 complete cycles → 189 blocks, 100 800 total outputs. -// - Max outputs in a single block is 1 000 — well below gcTestInterval — +// - Blocks cycle through [1, 5, 10] outputs (16 per 3-block cycle). +// - 7 complete cycles → 21 blocks, 112 total outputs. +// - Max outputs in a single block is 10 — well below gcTestInterval — // so each boundary is crossed by accumulation across blocks, not within one. -// - Expected hook sequence: [50 000, 100 000]. +// - Expected hook sequence: [50, 100]. func TestStartupScanGCCadenceVariedBlocks(t *testing.T) { - const numCycles = 63 // 63 × 1 600 = 100 800 outputs → crosses 50 k and 100 k - outputPattern := []int{100, 500, 1000} + const numCycles = 7 // 7 × 16 = 112 outputs → crosses 50 and 100 + outputPattern := []int{1, 5, 10} counts := make([]int, 0, numCycles*len(outputPattern)) for c := 0; c < numCycles; c++ { @@ -153,7 +151,7 @@ func TestStartupScanGCCadenceVariedBlocks(t *testing.T) { totalOutputs += uint64(n) } // Self-check: the chain design must cross gcTestInterval exactly twice. - if want := uint64(numCycles * 1600); totalOutputs != want { + if want := uint64(numCycles * 16); totalOutputs != want { t.Fatalf("chain design error: totalOutputs=%d, want %d", totalOutputs, want) } @@ -186,17 +184,18 @@ func TestStartupScanGCCadenceVariedBlocks(t *testing.T) { var snapWg sync.WaitGroup _, scanErr := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tip.Header.Height, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - InitTxTotal: 0, - Log: discardLog(), - SnapshotWg: &snapWg, - GCHook: hook, + DataDir: dir, + TipHeight: tip.Header.Height, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + InitTxTotal: 0, + Log: discardLog(), + SnapshotWg: &snapWg, + GCHook: hook, + GCHookInterval: gcTestInterval, }) snapWg.Wait() @@ -228,16 +227,16 @@ func TestStartupScanGCCadenceVariedBlocks(t *testing.T) { // boundary. // // Chain design: -// - One block at height 1 with 150 001 outputs. -// - The inner loop must call GCHook(50 000), GCHook(100 000), GCHook(150 000) +// - One block at height 1 with 151 outputs. +// - The inner loop must call GCHook(50), GCHook(100), GCHook(150) // in that order, all while processing the outputs of this one block. func TestStartupScanGCCadenceSingleBlockMultipleThresholds(t *testing.T) { - const numOutputs = 150001 // 3 × gcTestInterval + 1 → fires at 50k, 100k, 150k + const numOutputs = 151 // 3 × gcTestInterval + 1 wantSequence := []uint64{ - 1 * gcTestInterval, // 50 000 - 2 * gcTestInterval, // 100 000 - 3 * gcTestInterval, // 150 000 + 1 * gcTestInterval, // 50 + 2 * gcTestInterval, // 100 + 3 * gcTestInterval, // 150 } priv, pub, err := crypto.GenerateValidatorKey() @@ -264,17 +263,18 @@ func TestStartupScanGCCadenceSingleBlockMultipleThresholds(t *testing.T) { var snapWg sync.WaitGroup _, scanErr := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tip.Header.Height, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - InitTxTotal: 0, - Log: discardLog(), - SnapshotWg: &snapWg, - GCHook: hook, + DataDir: dir, + TipHeight: tip.Header.Height, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + InitTxTotal: 0, + Log: discardLog(), + SnapshotWg: &snapWg, + GCHook: hook, + GCHookInterval: gcTestInterval, }) snapWg.Wait() diff --git a/cmd/node/scan_txidx_test.go b/cmd/node/scan_txidx_test.go index 73490d3..aceaf9f 100644 --- a/cmd/node/scan_txidx_test.go +++ b/cmd/node/scan_txidx_test.go @@ -18,6 +18,7 @@ package main // updated so the background backfill goroutine can resume from that point). import ( + "bytes" "encoding/json" "fmt" "path/filepath" @@ -513,32 +514,20 @@ func TestScan_CheckpointResume_MarkerAdvancedWhenContiguous(t *testing.T) { } } -// TestBackfillTxIdxRange_SkipsMissingBlockData verifies that backfillTxIdxRange -// skips a height whose raw-block data is absent from the store (GetRawBlockByHeight -// returns nil, nil) and continues indexing all subsequent blocks. -// -// This matters in production: after --repair-db some heights may have their -// height-index entry (b/ prefix) intact but the corresponding raw-block data -// (r/ prefix) absent. The old behavior halted permanently at such a height; -// the new behavior skips the gap and indexes the remaining chain so the tx -// index marker eventually reaches tipHeight. -func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { - t.Parallel() - dir := t.TempDir() - +func buildBackfillTxIdxDBWithGap(t *testing.T, totalBlocks, missingHeight int) (*store.DB, map[int]core.Transaction) { + t.Helper() priv, pub, err := crypto.GenerateValidatorKey() if err != nil { t.Fatalf("GenerateValidatorKey: %v", err) } - // Build a 5-block chain but deliberately omit block at height 3 to - // simulate raw-block data absent for that height. - db, err2 := store.Open(fmt.Sprintf("%s/chain.db", dir)) - if err2 != nil { - t.Fatalf("store.Open: %v", err2) + db, err := store.Open(filepath.Join(t.TempDir(), "chain.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) + txsByHeight := make(map[int]core.Transaction, totalBlocks) makeTx := func(seed int) core.Transaction { blind, berr := crypto.NewBlindFactor() if berr != nil { @@ -554,6 +543,17 @@ func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { } } + storeRaw := func(b *core.Block) { + t.Helper() + raw, marshalErr := json.Marshal(b) + if marshalErr != nil { + t.Fatalf("marshal block h=%d: %v", b.Header.Height, marshalErr) + } + if putErr := db.PutRawBlock(b.Hash(), b.Header.Height, raw); putErr != nil { + t.Fatalf("PutRawBlock h=%d: %v", b.Header.Height, putErr) + } + } + genesis := &core.Block{ Header: core.BlockHeader{ Height: 0, @@ -565,20 +565,9 @@ func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { if serr := genesis.Header.Sign(priv); serr != nil { t.Fatalf("Sign genesis: %v", serr) } - storeRaw := func(b *core.Block) { - raw, _ := json.Marshal(b) - h := b.Hash() - if perr := db.PutRawBlock(h, b.Header.Height, raw); perr != nil { - t.Fatalf("PutRawBlock h=%d: %v", b.Header.Height, perr) - } - } storeRaw(genesis) parent := genesis - const totalBlocks = 5 - const missingHeight = 3 - // txsByHeight records the transactions stored at each height for later verification. - txsByHeight := make(map[int]core.Transaction) for i := 1; i <= totalBlocks; i++ { txs := []core.Transaction{makeTx(i)} hdr := core.BlockHeader{ @@ -594,15 +583,71 @@ func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { } blk := &core.Block{Header: hdr, Txs: txs} txsByHeight[i] = txs[0] - if i != missingHeight { // omit block 3 to simulate a raw-data gap + if i != missingHeight { storeRaw(blk) + } else { + // Keep h→hash but omit the body, matching the retained LevelDB + // state that makes GetRawBlockByHeight return (nil, nil). + if repairErr := db.RepairHeightIndex(uint64(i), blk.Hash()); repairErr != nil { + t.Fatalf("retain height index without body h=%d: %v", i, repairErr) + } } parent = blk } + return db, txsByHeight +} + +func TestTxIndexPruneBelowHeight(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tipHeight uint64 + mode string + keep uint64 + want uint64 + }{ + {name: "light mode cutoff", tipHeight: 100, mode: "light", keep: 20, want: 80}, + {name: "light mode not yet prunable", tipHeight: 20, mode: "light", keep: 20, want: 0}, + {name: "archive mode never skips missing blocks", tipHeight: 100, mode: "archive", keep: 20, want: 0}, + {name: "zero retained blocks", tipHeight: 100, mode: "light", keep: 0, want: 100}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := txIndexPruneBelowHeight(tc.tipHeight, tc.mode, tc.keep); got != tc.want { + t.Errorf("txIndexPruneBelowHeight(%d, %q, %d) = %d, want %d", + tc.tipHeight, tc.mode, tc.keep, got, tc.want) + } + }) + } +} + +// TestBackfillTxIdxRange_SkipsPrunedMissingBlockData verifies that a missing +// block body below the light-pruning cutoff is warned about and skipped, while +// later retained blocks are still indexed. +func TestBackfillTxIdxRange_SkipsPrunedMissingBlockData(t *testing.T) { + t.Parallel() + const totalBlocks, missingHeight = 5, 3 + db, txsByHeight := buildBackfillTxIdxDBWithGap(t, totalBlocks, missingHeight) + if raw, err := db.GetRawBlockByHeight(missingHeight); err != nil || raw != nil { + t.Fatalf("expected height %d to have a missing raw body, got %d bytes, err=%v", missingHeight, len(raw), err) + } - // Run the backfill from 0 through 5. Block 3 data is absent but the - // backfill should skip it (WARN) and continue indexing 4 and 5. - backfillTxIdxRange(0, totalBlocks, db, silentLog()) + var logBuf bytes.Buffer + log := newCaptureLogger(&logBuf) + // Pruning removes heights strictly below this cutoff, so height 3 is + // expected to be absent when the first retained height is 4. + backfillTxIdxRange(0, totalBlocks, missingHeight+1, db, log) + + const prunedMsg = "tx index background backfill: block pruned, skipping" + if !logContainsMsg(&logBuf, prunedMsg) { + t.Fatalf("expected a pruned-block skip warning, got:\n%s", logBuf.String()) + } + if level, ok := logFieldValue(&logBuf, prunedMsg, "level"); !ok || level != "WARN" { + t.Fatalf("pruned skip level = %q (present=%t), want WARN; log:\n%s", level, ok, logBuf.String()) + } + if height, ok := logFieldValue(&logBuf, prunedMsg, "height"); !ok || height != "3" { + t.Fatalf("pruned skip height = %q (present=%t), want 3; log:\n%s", height, ok, logBuf.String()) + } // Marker must reach totalBlocks (backfill completed despite the gap). marker, found, loadErr := db.LoadTxIdxCompleteHeight() @@ -641,6 +686,103 @@ func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { } } +// TestBackfillTxIdxRange_SkipsMissingRetainedBlockData verifies that a nil body +// in the retained range is warned about and skipped, while later blocks are +// still indexed. +func TestBackfillTxIdxRange_SkipsMissingRetainedBlockData(t *testing.T) { + t.Parallel() + const totalBlocks, missingHeight = 5, 3 + db, txsByHeight := buildBackfillTxIdxDBWithGap(t, totalBlocks, missingHeight) + if raw, err := db.GetRawBlockByHeight(missingHeight); err != nil || raw != nil { + t.Fatalf("expected height %d to have a missing raw body, got %d bytes, err=%v", missingHeight, len(raw), err) + } + + var logBuf bytes.Buffer + log := newCaptureLogger(&logBuf) + // The cutoff is exclusive: height 3 itself is retained, not pruned. + backfillTxIdxRange(0, totalBlocks, missingHeight, db, log) + + const skipMsg = "tx index background backfill: missing block data; skipping" + if !logContainsMsg(&logBuf, skipMsg) { + t.Fatalf("expected the missing retained block to be skipped with a warning, got:\n%s", logBuf.String()) + } + if level, ok := logFieldValue(&logBuf, skipMsg, "level"); !ok || level != "WARN" { + t.Fatalf("missing-body skip level = %q (present=%t), want WARN; log:\n%s", level, ok, logBuf.String()) + } + if height, ok := logFieldValue(&logBuf, skipMsg, "height"); !ok || height != "3" { + t.Fatalf("missing-body skip height = %q (present=%t), want 3; log:\n%s", height, ok, logBuf.String()) + } + + const summaryMsg = "tx index background backfill: missing block data range skipped" + if !logContainsMsg(&logBuf, summaryMsg) { + t.Fatalf("expected a summary for the skipped range, got:\n%s", logBuf.String()) + } + if level, ok := logFieldValue(&logBuf, summaryMsg, "level"); !ok || level != "INFO" { + t.Fatalf("missing-body range summary level = %q (present=%t), want INFO; log:\n%s", level, ok, logBuf.String()) + } + if from, ok := logFieldValue(&logBuf, summaryMsg, "from_height"); !ok || from != "3" { + t.Fatalf("missing-body range start = %q (present=%t), want 3; log:\n%s", from, ok, logBuf.String()) + } + if to, ok := logFieldValue(&logBuf, summaryMsg, "to_height"); !ok || to != "3" { + t.Fatalf("missing-body range end = %q (present=%t), want 3; log:\n%s", to, ok, logBuf.String()) + } + if count, ok := logFieldValue(&logBuf, summaryMsg, "missing_blocks"); !ok || count != "1" { + t.Fatalf("missing-body count = %q (present=%t), want 1; log:\n%s", count, ok, logBuf.String()) + } + if strings.Contains(logBuf.String(), `"err":null`) { + t.Fatalf("nil body was incorrectly logged as a read error:\n%s", logBuf.String()) + } + + marker, found, loadErr := db.LoadTxIdxCompleteHeight() + if loadErr != nil { + t.Fatalf("LoadTxIdxCompleteHeight: %v", loadErr) + } + if !found || marker != uint64(totalBlocks) { + t.Fatalf("txidx_complete_height=(%d, found=%t), want (%d, true)", marker, found, totalBlocks) + } + + for _, height := range []int{1, 2, 4, 5} { + tx := txsByHeight[height] + entry, lookupErr := db.LookupTxIdx(tx.Hash()) + if lookupErr != nil { + t.Errorf("LookupTxIdx at height %d: %v", height, lookupErr) + } else if entry == nil { + t.Errorf("height %d tx was not indexed after missing block %d", height, missingHeight) + } + } + missingTx := txsByHeight[missingHeight] + entry, lookupErr := db.LookupTxIdx(missingTx.Hash()) + if lookupErr != nil { + t.Fatalf("LookupTxIdx for missing block: %v", lookupErr) + } + if entry != nil { + t.Fatal("transaction from absent block body unexpectedly has a tx index entry") + } +} + +func TestBackfillTxIdxRange_HaltsOnStoreReadError(t *testing.T) { + t.Parallel() + db, err := store.Open(filepath.Join(t.TempDir(), "closed-chain.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + var logBuf bytes.Buffer + log := newCaptureLogger(&logBuf) + backfillTxIdxRange(0, 1, 0, db, log) + + const fetchErrMsg = "tx index background backfill: block fetch error — halting; will retry next restart" + if !logContainsMsg(&logBuf, fetchErrMsg) { + t.Fatalf("expected store read failure to halt backfill, got:\n%s", logBuf.String()) + } + if !strings.Contains(logBuf.String(), `"err":`) || strings.Contains(logBuf.String(), `"err":null`) { + t.Fatalf("store read error was omitted from the halt log:\n%s", logBuf.String()) + } +} + // TestScan_PersiststxidxCompleteHeight checks that runStartupScan persists the // txidx_complete_height marker at the scan's TipHeight so the background // backfill goroutine knows that range is already covered and skips it. diff --git a/cmd/node/snapshot_shutdown_log_test.go b/cmd/node/snapshot_shutdown_log_test.go index e47df46..9e454e9 100644 --- a/cmd/node/snapshot_shutdown_log_test.go +++ b/cmd/node/snapshot_shutdown_log_test.go @@ -1298,3 +1298,69 @@ func TestCheckStartupSnapshotTiming_DBRoundtrip_PersistAcrossReopen(t *testing.T t.Fatalf("ratio_pct missing after DB reopen; full output:\n%s", buf.String()) } } + +// TestCheckStartupSnapshotTiming_DBRoundtrip_UsesPersistedTimeout verifies +// that both values written at shutdown survive a real LevelDB reopen and that +// the loaded timeout is the denominator used by the startup warning. +func TestCheckStartupSnapshotTiming_DBRoundtrip_UsesPersistedTimeout(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "chain.db") + const ( + saveMs = int64(181_000) + timeoutSec = 300.0 + ) + + priorDB, err := store.Open(dbPath) + if err != nil { + t.Fatalf("store.Open (prior run): %v", err) + } + if err := priorDB.StoreSnapshotSaveDuration(saveMs); err != nil { + _ = priorDB.Close() + t.Fatalf("StoreSnapshotSaveDuration: %v", err) + } + if err := priorDB.StoreSnapshotTimeoutSec(timeoutSec); err != nil { + _ = priorDB.Close() + t.Fatalf("StoreSnapshotTimeoutSec: %v", err) + } + if err := priorDB.Close(); err != nil { + t.Fatalf("close prior-run DB: %v", err) + } + + restartedDB, err := store.Open(dbPath) + if err != nil { + t.Fatalf("store.Open (after restart): %v", err) + } + defer restartedDB.Close() + + loadedSaveMs, saveFound, err := restartedDB.LoadSnapshotSaveDuration() + if err != nil { + t.Fatalf("LoadSnapshotSaveDuration: %v", err) + } + if !saveFound || loadedSaveMs != saveMs { + t.Fatalf("loaded save duration=(%d, found=%t), want (%d, true)", loadedSaveMs, saveFound, saveMs) + } + loadedTimeoutSec, timeoutFound, err := restartedDB.LoadSnapshotTimeoutSec() + if err != nil { + t.Fatalf("LoadSnapshotTimeoutSec: %v", err) + } + if !timeoutFound || loadedTimeoutSec != timeoutSec { + t.Fatalf("loaded timeout=(%v, found=%t), want (%v, true)", loadedTimeoutSec, timeoutFound, timeoutSec) + } + + // checkStartupSnapshotTiming reads systemd configuration. Use the timeout + // loaded from LevelDB as the synthetic effective TimeoutStopSec so this + // integration test exercises the persisted duration and denominator together. + dropinDir, service := writeTimeoutConfInDir(t, int(loadedTimeoutSec)) + var buf bytes.Buffer + checkStartupSnapshotTiming(loadedSaveMs, dropinDir, service, newCaptureLogger(&buf)) + + const wantMsg = "startup: last snapshot save duration exceeds 50% of systemd stop timeout — consider increasing TimeoutStopSec" + if !logContainsMsg(&buf, wantMsg) { + t.Fatalf("expected startup warning using persisted timeout; full output:\n%s", buf.String()) + } + if got, ok := logFieldValue(&buf, wantMsg, "timeout_stop_sec"); !ok || got != "300" { + t.Fatalf("timeout_stop_sec = %q (present=%t), want 300; log:\n%s", got, ok, buf.String()) + } + if got, ok := logFieldValue(&buf, wantMsg, "ratio_pct"); !ok || got != "60.3" { + t.Fatalf("ratio_pct = %q (present=%t), want 60.3; log:\n%s", got, ok, buf.String()) + } +} 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/snapshot_timing_restart_test.go b/cmd/node/snapshot_timing_restart_test.go new file mode 100644 index 0000000..d4182d5 --- /dev/null +++ b/cmd/node/snapshot_timing_restart_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func fetchSnapshotTimingStatus(t *testing.T, client *http.Client, url string) map[string]interface{} { + t.Helper() + + resp, err := client.Get(url + "/api/v1/status") + if err != nil { + t.Fatalf("GET /api/v1/status: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET /api/v1/status status=%d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + + var status map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + t.Fatalf("decode /api/v1/status response: %v", err) + } + return status +} + +func statusNumber(t *testing.T, status map[string]interface{}, field string) float64 { + t.Helper() + value, ok := status[field].(float64) + if !ok { + t.Fatalf("%s = %T (%v), want number", field, status[field], status[field]) + } + return value +} + +// TestSnapshotTimingFromPreviousShutdownOnRestart exercises the previous-run +// metadata round-trip and the API behavior observed immediately after restart: +// persisted timing is visible and marked as previous-run data until this +// process completes its first snapshot. +func TestSnapshotTimingFromPreviousShutdownOnRestart(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "chain.db") + const ( + previousSaveMs = int64(57_000) + timeoutSec = 300.0 + ) + + // Simulate the prior process persisting the successful shutdown snapshot. + priorDB, err := store.Open(dbPath) + if err != nil { + t.Fatalf("open prior-run DB: %v", err) + } + if err := priorDB.StoreSnapshotSaveDuration(previousSaveMs); err != nil { + _ = priorDB.Close() + t.Fatalf("persist prior snapshot duration: %v", err) + } + if err := priorDB.StoreSnapshotTimeoutSec(timeoutSec); err != nil { + _ = priorDB.Close() + t.Fatalf("persist prior snapshot timeout: %v", err) + } + if err := priorDB.Close(); err != nil { + t.Fatalf("close prior-run DB: %v", err) + } + + // Opening a fresh handle represents the new node process after restart. + restartedDB, err := store.Open(dbPath) + if err != nil { + t.Fatalf("open restarted DB: %v", err) + } + defer restartedDB.Close() + + savedMs, found, err := restartedDB.LoadSnapshotSaveDuration() + if err != nil { + t.Fatalf("load prior snapshot duration: %v", err) + } + if !found || savedMs != previousSaveMs { + t.Fatalf("loaded duration=(%d, found=%t), want (%d, true)", savedMs, found, previousSaveMs) + } + savedTimeout, timeoutFound, err := restartedDB.LoadSnapshotTimeoutSec() + if err != nil { + t.Fatalf("load prior snapshot timeout: %v", err) + } + if !timeoutFound || savedTimeout != timeoutSec { + t.Fatalf("loaded timeout=(%v, found=%t), want (%v, true)", savedTimeout, timeoutFound, timeoutSec) + } + + apiServer := api.NewServer( + ":0", + core.NewChain(), + core.NewMempool(core.DefaultMempoolConfig()), + core.NewUTXOSet(), + slog.Default(), + ) + apiServer.SetStore(restartedDB) + apiServer.SetSnapshotTimingsFromPreviousShutdown( + time.Duration(savedMs)*time.Millisecond, + savedTimeout, + ) + httpServer := httptest.NewServer(apiServer) + defer httpServer.Close() + + client := httpServer.Client() + status := fetchSnapshotTimingStatus(t, client, httpServer.URL) + if got, ok := status["snapshot_timing_from_previous_shutdown"].(bool); !ok || !got { + t.Fatalf("snapshot_timing_from_previous_shutdown = %v, want true", status["snapshot_timing_from_previous_shutdown"]) + } + if got := statusNumber(t, status, "snapshot_save_duration_ms"); got != float64(previousSaveMs) { + t.Fatalf("snapshot_save_duration_ms = %v, want %d", got, previousSaveMs) + } + if got := statusNumber(t, status, "snapshot_timeout_sec"); got != timeoutSec { + t.Fatalf("snapshot_timeout_sec = %v, want %v", got, timeoutSec) + } + if got := statusNumber(t, status, "snapshot_ratio_pct"); got != 19.0 { + t.Fatalf("snapshot_ratio_pct = %v, want 19.0", got) + } + + // The first successful snapshot in this process replaces the restored + // values and clears the previous-shutdown marker in the live status API. + const currentSaveDuration = 12_345 * time.Millisecond + apiServer.SetSnapshotTimings(currentSaveDuration, timeoutSec) + status = fetchSnapshotTimingStatus(t, client, httpServer.URL) + if _, present := status["snapshot_timing_from_previous_shutdown"]; present { + t.Fatalf("snapshot_timing_from_previous_shutdown should be omitted after first in-process snapshot, got %v", status["snapshot_timing_from_previous_shutdown"]) + } + if got := statusNumber(t, status, "snapshot_save_duration_ms"); got != float64(currentSaveDuration.Milliseconds()) { + t.Fatalf("snapshot_save_duration_ms after first snapshot = %v, want %d", got, currentSaveDuration.Milliseconds()) + } + if got := statusNumber(t, status, "snapshot_timeout_sec"); got != timeoutSec { + t.Fatalf("snapshot_timeout_sec after first snapshot = %v, want %v", got, timeoutSec) + } +} 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/cmd/wallet-wasm/transactions_js_wasm.go b/cmd/wallet-wasm/transactions_js_wasm.go index f05b92d..a7af47a 100644 --- a/cmd/wallet-wasm/transactions_js_wasm.go +++ b/cmd/wallet-wasm/transactions_js_wasm.go @@ -22,14 +22,15 @@ type wasmDecoy struct { AmountCommit string `json:"amount_commit"` } type wasmRequest struct { - Mnemonic string `json:"mnemonic"` - Account uint32 `json:"account"` - Index uint32 `json:"index"` - Amount uint64 `json:"amount_napr"` - ToAddress string `json:"to_address"` - FeePerByte uint64 `json:"fee_per_byte"` - Outputs []wasmOutput `json:"outputs"` - Decoys []wasmDecoy `json:"decoys"` + Mnemonic string `json:"mnemonic"` + Account uint32 `json:"account"` + Index uint32 `json:"index"` + Amount uint64 `json:"amount_napr"` + TxVersion core.TxVersion `json:"tx_version,omitempty"` + ToAddress string `json:"to_address"` + FeePerByte uint64 `json:"fee_per_byte"` + Outputs []wasmOutput `json:"outputs"` + Decoys []wasmDecoy `json:"decoys"` // AVM carries an already locally signed payload. The fee-paying wallet // remains responsible only for the RingCT inputs and never derives or // receives the AVM signer's private key. @@ -103,7 +104,31 @@ func ownedOutputs(r wasmRequest, keys *wallet.DerivedKeys) ([]core.OwnedUTXO, er if scanErr != nil || found == nil { continue } - hs, amount = *found, core.DecryptAmount(enc, found) + hs = *found + if x.AmountBinding != nil { + valid, verifyErr := crypto.VerifyAmountBinding(crypto.Commitment(ac), crypto.Point32(op), x.AmountBinding) + if verifyErr != nil || !valid { + return nil, fmt.Errorf("outputs[%d] amount binding is invalid: %v", i, verifyErr) + } + oneTimePriv, keyErr := crypto.AddScalars(hs, keys.Keys.Spend.Private) + if keyErr != nil { + return nil, fmt.Errorf("outputs[%d] derive one-time spend key: %w", i, keyErr) + } + amount, err = crypto.DecryptAmountBinding(x.AmountBinding, oneTimePriv) + crypto.ZeroBytes(oneTimePriv[:]) + if err != nil { + return nil, fmt.Errorf("outputs[%d] decrypt bound amount: %w", i, err) + } + } else { + if x.TxVersion == uint8(core.TxVersionAmountBinding) || + x.TxVersion == uint8(core.TxVersionAVMAmountBinding) { + return nil, fmt.Errorf("outputs[%d] is missing its required amount binding", i) + } + if x.TxVersion == 0 && enc == ([8]byte{}) { + return nil, fmt.Errorf("outputs[%d] is missing transaction-version metadata for a zero legacy amount", i) + } + amount = core.DecryptAmount(enc, found) + } blind, err = crypto.DeterministicPaymentBlind(hs, amount) } if x.BlindHex != "" { @@ -169,8 +194,12 @@ func localBuilder(r wasmRequest) (*core.TxBuilder, *wallet.DerivedKeys, error) { if err != nil { return nil, nil, err } + version := r.TxVersion + if version == 0 { + version = core.TxVersionCLSAG + } builder := core.NewTxBuilder(keys.Keys.Spend.Private, keys.Keys.View.Private, keys.Keys.Spend.Public, owned, r.FeePerByte). - WithVersion(core.TxVersionCLSAG). + WithVersion(version). WithDecoys(decoys) if r.AVM != nil { builder = builder.WithAVM(*r.AVM) diff --git a/cmd/wallet-wasm/wasm_output.go b/cmd/wallet-wasm/wasm_output.go index 51e8d12..27f0384 100644 --- a/cmd/wallet-wasm/wasm_output.go +++ b/cmd/wallet-wasm/wasm_output.go @@ -1,17 +1,23 @@ package main -import "encoding/json" +import ( + "encoding/json" + + "github.com/aperod/aperod/crypto" +) type wasmOutput struct { - TxHash string `json:"tx_hash"` - OutIdx uint32 `json:"out_idx"` - OneTimePub string `json:"one_time_pub"` - TxPubKey string `json:"tx_pub_key"` - AmountCommit string `json:"amount_commit"` - EncAmount string `json:"enc_amount"` - BlockHeight uint64 `json:"block_height"` - Amount uint64 `json:"amount_napr,omitempty"` - BlindHex string `json:"blind_hex,omitempty"` + TxHash string `json:"tx_hash"` + OutIdx uint32 `json:"out_idx"` + TxVersion uint8 `json:"tx_version,omitempty"` + OneTimePub string `json:"one_time_pub"` + TxPubKey string `json:"tx_pub_key"` + AmountCommit string `json:"amount_commit"` + EncAmount string `json:"enc_amount"` + AmountBinding *crypto.AmountBinding `json:"amount_binding,omitempty"` + BlockHeight uint64 `json:"block_height"` + Amount uint64 `json:"amount_napr,omitempty"` + BlindHex string `json:"blind_hex,omitempty"` } // UnmarshalJSON keeps uint64 mint values exact when JavaScript supplies them diff --git a/config/amount_binding_config_test.go b/config/amount_binding_config_test.go new file mode 100644 index 0000000..f4bec59 --- /dev/null +++ b/config/amount_binding_config_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRingCTAmountBindingActivationValidationAndLoad(t *testing.T) { + cfg := DefaultConfig() + cfg.Consensus.RingCTAmountBindingActivationHeight = 100 + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requires ring_ct_clsag_activation_height") { + t.Fatalf("amount-binding activation without CLSAG should fail, got %v", err) + } + + cfg.Consensus.RingCTCLSAGActivationHeight = 200 + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "must be >=") { + t.Fatalf("amount-binding activation before CLSAG should fail, got %v", err) + } + cfg.Consensus.RingCTAmountBindingActivationHeight = 300 + if err := cfg.Validate(); err != nil { + t.Fatalf("coordinated amount-binding activation should validate: %v", err) + } + + path := filepath.Join(t.TempDir(), "node.yaml") + if err := os.WriteFile(path, []byte("consensus:\n ring_ct_clsag_activation_height: 200\n ring_ct_amount_binding_activation_height: 300\n"), 0600); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := loaded.Consensus.RingCTAmountBindingActivationHeight; got != 300 { + t.Fatalf("loaded amount-binding activation height = %d, want 300", got) + } +} diff --git a/config/config.go b/config/config.go index 6e22c8e..c420482 100644 --- a/config/config.go +++ b/config/config.go @@ -69,12 +69,19 @@ type SnapshotConfig struct { // MaintenanceConfig controls the node's scheduled maintenance window. type MaintenanceConfig struct { + // RestartEnabled controls whether the in-process nightly restart scheduler + // is allowed to run. Default: true. Set false together with RestartAt: "" + // to disable the in-process scheduler; an explicit restart_at time while + // disabled is rejected as contradictory configuration. + RestartEnabled bool `yaml:"restart_enabled"` // RestartAt is the UTC time at which the node sends a graceful SIGTERM to // itself for a nightly RAM-reclaim restart (HH:MM, 24-hour clock, UTC). // Go heap fragmentation accumulates ~1.3 GB/h in production; a nightly // restart clears it before memory pressure builds. - // Default: "04:00". Set to "" to disable the in-process nightly restart - // (e.g. when the operator manages scheduling via an external systemd timer). + // Default: "04:00". Set to "" to suppress the in-process scheduler; for an + // explicit node.yaml opt-out, also set restart_enabled: false. The + // scheduler additionally requires SCHED_RESTART_ENABLED=true in the + // process environment for compatibility with existing deployments. RestartAt string `yaml:"restart_at"` } @@ -103,6 +110,18 @@ func ParseRestartAt(s string) (int, int, error) { return h, m, nil } +// NextRestartAt returns the next occurrence of hour:minute UTC. If now is +// exactly at or after that time, the next occurrence is on the following day. +// Callers must pass a validated hour and minute, normally from ParseRestartAt. +func NextRestartAt(now time.Time, hour, minute int) time.Time { + now = now.UTC() + target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, time.UTC) + if !target.After(now) { + target = target.Add(24 * time.Hour) + } + return target +} + // PprofConfig controls the Go pprof HTTP diagnostic endpoint. // The endpoint exposes CPU/heap/goroutine profiles and should only ever // bind to localhost (127.0.0.1). It is disabled by default. @@ -185,11 +204,15 @@ type Config struct { // at connect-time so that node operators can rotate IPs without changing // their configuration file. type P2PConfig struct { - ListenAddr string `yaml:"listen_addr"` // e.g. "/ip4/0.0.0.0/tcp/30303" - Bootnodes []string `yaml:"bootnodes"` // "domain:port" or "ip:port" - MaxPeers int `yaml:"max_peers"` - MinPeers int `yaml:"min_peers"` - MaxPeersPerIP int `yaml:"max_peers_per_ip"` // max inbound connections per source IP (0 = unlimited, recommended: 3) + // HandshakeTimeout bounds inbound TLS handshakes so stalled ClientHello + // connections cannot occupy handshake slots for long. Loaded production + // default: 3s. Explicit 0 uses the p2p host fallback (10s). Restart to apply. + HandshakeTimeout time.Duration `yaml:"handshake_timeout"` + ListenAddr string `yaml:"listen_addr"` // e.g. "/ip4/0.0.0.0/tcp/30303" + Bootnodes []string `yaml:"bootnodes"` // "domain:port" or "ip:port" + MaxPeers int `yaml:"max_peers"` + MinPeers int `yaml:"min_peers"` + MaxPeersPerIP int `yaml:"max_peers_per_ip"` // max inbound connections per source IP (0 = unlimited, recommended: 3) // MinOutbound reserves this many peer slots exclusively for outbound dial-outs. // Inbound connections are capped at (MaxPeers − MinOutbound) so a validator // under an inbound flood can always gossip produced blocks to the network. @@ -316,7 +339,7 @@ type ConsensusConfig struct { ValidatorAuthorityForkFile string `yaml:"validator_authority_fork_file"` ValidatorAuthorityForkSHA256 string `yaml:"validator_authority_fork_sha256"` ValidatorKey string `yaml:"validator_key"` // path to ED25519 key file - ViewKey string `yaml:"view_key"` // hex-encoded Ed25519 view private scalar for automatic UTXO amount decryption (optional) + ViewKey string `yaml:"view_key"` // deprecated; retained for config compatibility, never exposed through unauthenticated REST RewardAddress string `yaml:"reward_address"` // APRO wallet address for block rewards BlockRewardNAPR uint64 `yaml:"block_reward_napro"` // legacy pre-authorization reward in nAPRO; ignored after reward authorization activation. BlockTime time.Duration `yaml:"block_time"` @@ -353,6 +376,10 @@ type ConsensusConfig struct { // RingCTCLSAGActivationHeight is the first height requiring RingCT v5. // Zero deliberately disables v5 rather than activating it immediately. RingCTCLSAGActivationHeight uint64 `yaml:"ring_ct_clsag_activation_height"` + // RingCTAmountBindingActivationHeight is the first height requiring + // verifiable encrypted-amount proofs in transaction versions 13/14. + // It must be coordinated across validators; zero disables this fork. + RingCTAmountBindingActivationHeight uint64 `yaml:"ring_ct_amount_binding_activation_height"` // StakedCollateralRingExclusionActivationHeight is the first block height // whose CLSAG rings must exclude staked collateral. Zero disables this fork. StakedCollateralRingExclusionActivationHeight uint64 `yaml:"staked_collateral_ring_exclusion_activation_height"` @@ -387,6 +414,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"` @@ -467,6 +497,7 @@ func DefaultConfig() *Config { MaxPeersPerIP: 3, // eclipse/partition guard: max 3 connections per source IP MinOutbound: 4, // always keep 4 slots free for outbound dial-outs MaxPendingHandshakes: 20, // goroutine-exhaustion guard: cap in-flight TLS handshakes + HandshakeTimeout: 3 * time.Second, BadBlockHeightLead: 1000, BadBlockBanThreshold: 5, BadBlockBanDuration: 24 * time.Hour, @@ -506,7 +537,8 @@ func DefaultConfig() *Config { MaxInMemoryBlocks: 1_000, MempoolEvictIntervalSec: 300, Maintenance: MaintenanceConfig{ - RestartAt: "04:00", + RestartEnabled: true, + RestartAt: "04:00", }, } } @@ -676,12 +708,16 @@ 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") } rewardActivation := c.Consensus.RewardAuthorizationActivationHeight ringCTActivation := c.Consensus.RingCTV4ActivationHeight clsagActivation := c.Consensus.RingCTCLSAGActivationHeight + amountBindingActivation := c.Consensus.RingCTAmountBindingActivationHeight stakedRingActivation := c.Consensus.StakedCollateralRingExclusionActivationHeight if c.Network == "mainnet" && stakedRingActivation == 0 { return fmt.Errorf("mainnet nodes must configure staked_collateral_ring_exclusion_activation_height") @@ -692,6 +728,18 @@ func (c *Config) Validate() error { clsagActivation, ringCTActivation, ) } + if amountBindingActivation > 0 && clsagActivation == 0 { + return fmt.Errorf("ring_ct_amount_binding_activation_height requires ring_ct_clsag_activation_height") + } + if amountBindingActivation > 0 && amountBindingActivation < clsagActivation { + return fmt.Errorf( + "ring_ct_amount_binding_activation_height (%d) must be >= ring_ct_clsag_activation_height (%d)", + amountBindingActivation, clsagActivation, + ) + } + if amountBindingActivation > uint64(1<<63-1) { + return fmt.Errorf("ring_ct_amount_binding_activation_height exceeds the supported maximum") + } if stakedRingActivation > 0 && clsagActivation == 0 { return fmt.Errorf("staked_collateral_ring_exclusion_activation_height requires ring_ct_clsag_activation_height") } @@ -730,6 +778,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") } @@ -945,6 +996,10 @@ func (c *Config) Validate() error { ) } + if c.P2P.HandshakeTimeout < 0 { + return fmt.Errorf("p2p.handshake_timeout (%v) must not be negative; use a positive duration (e.g. \"3s\") or 0 for the host fallback (10s)", c.P2P.HandshakeTimeout) + } + // KeepaliveInterval validation. Zero means "use the built-in default (10s)" // so it is accepted without complaint. Explicit values must be in [1s, 15s]: // • < 1s would flood slow peers with pings and may saturate a CPU core. @@ -959,6 +1014,13 @@ func (c *Config) Validate() error { c.P2P.KeepaliveInterval, keepaliveMin, keepaliveMax, ) } + if !c.Maintenance.RestartEnabled && c.Maintenance.RestartAt != "" { + return fmt.Errorf( + "maintenance.restart_enabled is false but maintenance.restart_at is %q; "+ + "set maintenance.restart_at to an empty string or set restart_enabled to true", + c.Maintenance.RestartAt, + ) + } if c.Maintenance.RestartAt != "" { if _, _, err := ParseRestartAt(c.Maintenance.RestartAt); err != nil { return fmt.Errorf("maintenance.restart_at %w", err) diff --git a/config/config_test.go b/config/config_test.go index a7e6e73..1de0bd2 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -797,6 +797,70 @@ func TestMemoryLimitDisabledParsesFromYAML(t *testing.T) { } } +func TestMaintenanceRestartConfig(t *testing.T) { + if !DefaultConfig().Maintenance.RestartEnabled { + t.Fatal("DefaultConfig().Maintenance.RestartEnabled = false, want true") + } + + dir := t.TempDir() + tests := []struct { + name string + yaml string + wantEnabled bool + wantRestartAt string + wantValidationError string + }{ + { + name: "omitted field defaults to enabled", + yaml: "network: testnet\n", + wantEnabled: true, + wantRestartAt: "04:00", + }, + { + name: "explicit disable with empty restart time", + yaml: "maintenance:\n restart_enabled: false\n restart_at: \"\"\n", + wantEnabled: false, + wantRestartAt: "", + }, + { + name: "rejects explicit time while disabled", + yaml: "maintenance:\n restart_enabled: false\n restart_at: \"04:00\"\n", + wantEnabled: false, + wantRestartAt: "04:00", + wantValidationError: "maintenance.restart_enabled is false", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".yaml") + if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load(): %v", err) + } + err = cfg.Validate() + if tc.wantValidationError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantValidationError) { + t.Fatalf("Validate() error = %v, want substring %q", err, tc.wantValidationError) + } + return + } + if err != nil { + t.Fatalf("Validate(): %v", err) + } + if cfg.Maintenance.RestartEnabled != tc.wantEnabled { + t.Errorf("restart_enabled = %t, want %t", cfg.Maintenance.RestartEnabled, tc.wantEnabled) + } + if cfg.Maintenance.RestartAt != tc.wantRestartAt { + t.Errorf("restart_at = %q, want %q", cfg.Maintenance.RestartAt, tc.wantRestartAt) + } + }) + } +} + // TestResolveMempoolEvictInterval verifies that the resolved ticker duration // matches the operator-configured value, falls back to 5 minutes when the // field is absent (YAML zero-value), and falls back to 5 minutes when the diff --git a/config/handshake_timeout_yaml_test.go b/config/handshake_timeout_yaml_test.go new file mode 100644 index 0000000..629b35b --- /dev/null +++ b/config/handshake_timeout_yaml_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHandshakeTimeoutProductionDefault(t *testing.T) { + if got := DefaultConfig().P2P.HandshakeTimeout; got != 3*time.Second { + t.Fatalf("default handshake timeout = %v, want 3s", got) + } +} + +func TestHandshakeTimeoutYAML(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + want time.Duration + }{ + {"omitted p2p", "log_level: info\n", 3 * time.Second}, + {"omitted key", "p2p:\n max_peers: 10\n", 3 * time.Second}, + {"custom", "p2p:\n handshake_timeout: 2s\n", 2 * time.Second}, + {"fractional seconds", "p2p:\n handshake_timeout: 1500ms\n", 1500 * time.Millisecond}, + {"explicit host fallback", "p2p:\n handshake_timeout: 0s\n", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.yaml") + if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("valid handshake timeout rejected: %v", err) + } + if got := cfg.P2P.HandshakeTimeout; got != tc.want { + t.Fatalf("handshake_timeout = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHandshakeTimeoutNegativeRejected(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.yaml") + if err := os.WriteFile(path, []byte("p2p:\n handshake_timeout: -1s\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "p2p.handshake_timeout") { + t.Fatalf("expected a named validation error, got %v", err) + } +} + +func TestHandshakeTimeoutMalformedYAMLRejected(t *testing.T) { + for _, value := range []string{"not-a-duration", "3", "999999999999999999999h"} { + t.Run(value, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.yaml") + if err := os.WriteFile(path, []byte("p2p:\n handshake_timeout: "+value+"\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("malformed handshake timeout was silently accepted") + } + }) + } +} + +func TestHandshakeTimeoutExampleYAML(t *testing.T) { + cfg, err := Load("../node.yaml") + if err != nil { + t.Fatal(err) + } + if got := cfg.P2P.HandshakeTimeout; got != 3*time.Second { + t.Fatalf("example handshake timeout = %v, want 3s", got) + } +} diff --git a/consensus/admin_mint_reward_address_test.go b/consensus/admin_mint_reward_address_test.go new file mode 100644 index 0000000..f8adfd5 --- /dev/null +++ b/consensus/admin_mint_reward_address_test.go @@ -0,0 +1,71 @@ +package consensus + +import ( + "io" + "log/slog" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func TestReconcileRewardAddressAdminMintIntents(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatalf("open test store: %v", err) + } + defer db.Close() + + const rewardAddress = "aproRewardAddressForStartupReconciliation" + records := map[string]store.AdminMintRecord{ + "queued-reward": { + State: "intent", Address: rewardAddress, AmountNAPR: 500_000_000, + }, + "queued-other": { + State: "intent", Address: "aproDifferentDestination", AmountNAPR: 250_000_000, + }, + "completed-reward": { + State: "completed", Address: rewardAddress, AmountNAPR: 100_000_000, + }, + "prepared-reward": { + State: "prepared", Address: rewardAddress, AmountNAPR: 125_000_000, + }, + } + for key, record := range records { + if err := db.StoreAdminMintRecord(key, record); err != nil { + t.Fatalf("store %s: %v", key, err) + } + } + + engine := NewEngine( + Config{RewardAddress: rewardAddress, Store: db}, + core.NewChain(), + core.NewMempool(core.DefaultMempoolConfig()), + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + failed, err := engine.ReconcileRewardAddressAdminMintIntents() + if err != nil { + t.Fatalf("reconcile reward-address intents: %v", err) + } + if failed != 1 { + t.Fatalf("reconciled %d intents, want 1", failed) + } + + got, err := db.ListAdminMintRecords() + if err != nil { + t.Fatalf("list reconciled records: %v", err) + } + byKey := make(map[string]store.AdminMintRecord, len(got)) + for _, entry := range got { + byKey[entry.IdempotencyKey] = entry.Record + } + if record := byKey["queued-reward"]; record.State != "failed" || + record.Failure != validatorRewardAddressMintFailure { + t.Fatalf("reward intent was not clearly failed: %+v", record) + } + for _, key := range []string{"queued-other", "completed-reward", "prepared-reward"} { + if byKey[key].State != records[key].State { + t.Fatalf("%s changed state to %q, want %q", key, byKey[key].State, records[key].State) + } + } +} 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/consensus_test.go b/consensus/consensus_test.go index 4269d73..89405e1 100644 --- a/consensus/consensus_test.go +++ b/consensus/consensus_test.go @@ -323,6 +323,55 @@ func TestEngine_AcceptsIncomingBlock(t *testing.T) { } } +func TestEngine_InvalidSignedBlockDoesNotPoisonAcceptedHeight(t *testing.T) { + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + chain := makeChainWithGenesis(t, priv, pub) + eng := newEngine(t, []crypto.ValidatorPubKey{pub}, nil, chain) + utxos := core.NewUTXOSet() + eng.SetTxVerifier(core.NewTxVerifier(utxos), utxos) + + tip := chain.Tip() + invalidTxs := []core.Transaction{{}, {}} + invalidHeader := core.BlockHeader{ + Height: 1, + Round: 1, + PrevHash: tip.Hash(), + Timestamp: time.Now().UnixNano(), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(invalidTxs), + } + if err := invalidHeader.Sign(priv); err != nil { + t.Fatal(err) + } + + validHeader := invalidHeader + validHeader.Timestamp = time.Now().UnixNano() + validHeader.MerkleRoot = core.MerkleRoot(nil) + if err := validHeader.Sign(priv); err != nil { + t.Fatal(err) + } + + stop := make(chan struct{}) + go eng.Run(stop) + defer close(stop) + eng.NewBlockCh() <- &core.Block{Header: invalidHeader, Txs: invalidTxs} + eng.NewBlockCh() <- &core.Block{Header: validHeader} + + deadline := time.Now().Add(time.Second) + for chain.Height() < 1 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if got := chain.Height(); got != 1 { + t.Fatalf("valid block after rejected signed candidate left chain at height %d; want 1", got) + } + if accepted := chain.GetByHeight(1); accepted == nil || len(accepted.Txs) != 0 { + t.Fatal("canonical block is not the valid candidate") + } +} + // TestEngine_RejectsBlock_NoVerifier verifies that handleIncomingBlock is fail-closed // when no TxVerifier has been set: the block must not be added to the chain. // This is a regression guard for the startup ordering bug where engine.Run was diff --git a/consensus/finality_persistence_test.go b/consensus/finality_persistence_test.go index 16288bb..546277c 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) @@ -98,7 +102,11 @@ func TestForgedFinalityCertificateFailsClosed(t *testing.T) { 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) + } + }() badSignature, _ := priv.Sign(crypto.HashBytes([]byte("not-finality"))) if err := db.SaveFinalityCertificate(store.FinalityCertificate{Version: 1, Height: 1, BlockHash: block.Hash(), Votes: []store.FinalityVote{{Validator: attacker, Signature: badSignature}}}); err != nil { 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..d4a1180 100644 --- a/consensus/native_chrome_transfer_test.go +++ b/consensus/native_chrome_transfer_test.go @@ -47,7 +47,6 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { } e, db, deposit, action, keys, priv, _ := positionEngineWithOwner(t, owner.Keys) e.cfg.RingCTCLSAGActivationHeight = 2 - e.utxos.SetCLSAGActivationHeight(2) e.pool.SetVerifier(e.txVerifier) acceptPositionBlock(t, e, priv, 3_000_000_000, *deposit) // These are consensus-authorized payout outputs, not UTXO/decoy mocks. @@ -175,11 +174,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..0783331 100644 --- a/consensus/poa.go +++ b/consensus/poa.go @@ -31,6 +31,10 @@ type AdminMintRecordStore interface { LoadAdminMintRecord(idempotencyKey string) (record store.AdminMintRecord, found bool, err error) } +type AdminMintRecordLister interface { + ListAdminMintRecords() ([]store.AdminMintRecordEntry, error) +} + // Config holds PoA consensus parameters. type Config struct { // ValidatorAuthorityFork separates collateral identity from future signing @@ -42,7 +46,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. @@ -133,6 +140,9 @@ type Config struct { // RingCTCLSAGActivationHeight is the first height requiring v5; zero // disables activation to avoid an accidental uncoordinated fork. RingCTCLSAGActivationHeight uint64 + // RingCTAmountBindingActivationHeight is the first height requiring + // publicly verifiable encrypted-amount proofs in transaction versions 13/14. + RingCTAmountBindingActivationHeight uint64 // StakedCollateralRingExclusionActivationHeight is the first height at which // CLSAG rings must exclude staked collateral. Zero disables this fork. StakedCollateralRingExclusionActivationHeight uint64 @@ -605,6 +615,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) } } @@ -844,6 +855,13 @@ func (e *Engine) tick() error { persistErr := fmt.Errorf("persist canonical block %d: OnCanonicalBlock is required", block.Header.Height) return e.failCanonicalPersistence(block, stakeRollback, persistErr) } + if ev := e.slashing.RecordAcceptedBlock( + block.Header.Height, block.Header.Round, + block.Header.ValidatorPub, block.Hash(), block.Header.Signature, + ); ev != nil { + e.halted.Store(true) + return fmt.Errorf("fatal slashing detector conflict after canonical persistence: %s", ev.String()) + } // Notify local-producer listeners after the canonical durability boundary. // A failure leaves the durable admin-mint records prepared and stops this @@ -1367,6 +1385,45 @@ func (e *Engine) expectedBaseFeeAt(height uint64) uint64 { // ─── Admin mint scheduling ──────────────────────────────────────────────────── +const validatorRewardAddressMintFailure = "cannot admin-mint to the validator reward address: the per-block coinbase reward would collide with the same one-time pub" + +// ReconcileRewardAddressAdminMintIntents permanently fails queued intents that +// target this node's reward address. An intent has not reached transaction +// preparation, so it is safe to retire. Prepared records are deliberately left +// unresolved: a restart must first reconcile their exact transaction against +// canonical storage before deciding whether a mint occurred. +func (e *Engine) ReconcileRewardAddressAdminMintIntents() (int, error) { + if e.cfg.RewardAddress == "" || e.adminMintStore == nil { + return 0, nil + } + lister, ok := e.adminMintStore.(AdminMintRecordLister) + if !ok { + return 0, fmt.Errorf("admin mint record store does not support startup reconciliation") + } + + e.mintMu.Lock() + defer e.mintMu.Unlock() + records, err := lister.ListAdminMintRecords() + if err != nil { + return 0, fmt.Errorf("list admin mint intents for startup reconciliation: %w", err) + } + + failed := 0 + for _, entry := range records { + record := entry.Record + if record.State != "intent" || record.Address != e.cfg.RewardAddress { + continue + } + record.State = "failed" + record.Failure = validatorRewardAddressMintFailure + if err := e.adminMintStore.StoreAdminMintRecord(entry.IdempotencyKey, record); err != nil { + return failed, fmt.Errorf("persist failed validator-reward admin mint intent: %w", err) + } + failed++ + } + return failed, nil +} + // ScheduleAdminMint is retained for API compatibility but fails closed while // the legacy admin-mint transaction has no consensus-authenticated envelope. // @@ -1448,6 +1505,13 @@ func (e *Engine) ScheduleAdminMint(idempotencyKey, addr string, amountNAPR uint6 case "", "completed": e.mintMu.Unlock() return record.TxHash, record.Height, nil + case "failed": + e.mintMu.Unlock() + failure := strings.TrimSpace(record.Failure) + if failure == "" { + failure = "admin mint previously failed and requires operator review" + } + return crypto.Hash32{}, 0, fmt.Errorf("%s", failure) case "prepared": // A crash may happen after a proposal was prepared but before // its result was recorded. Only declare it complete when the @@ -1961,6 +2025,17 @@ func (e *Engine) handleIncomingBlock(block *core.Block) error { return fmt.Errorf("invalid block: %w", err) } + // Detection is read-only here. Only a block that passes every consensus + // check and is durably committed is recorded below. + hash := block.Hash() + if ev := e.slashing.DetectDoubleSign( + block.Header.Height, block.Header.Round, + block.Header.ValidatorPub, hash, block.Header.Signature, + ); ev != nil { + e.log.Error("SLASHING: double-sign detected", "evidence", ev.String()) + return fmt.Errorf("double-sign: %s", ev.String()) + } + // Timejacking guard: only reject blocks whose timestamp is more than // 15 s in the FUTURE. Past-timestamped sync blocks must be accepted // so a restarting relay node can fill historical gaps. A negative @@ -2026,16 +2101,6 @@ func (e *Engine) handleIncomingBlock(block *core.Block) error { } } - // Double-sign detection - hash := block.Hash() - if ev := e.slashing.CheckBlock( - block.Header.Height, block.Header.Round, - block.Header.ValidatorPub, hash, block.Header.Signature, - ); ev != nil { - e.log.Error("SLASHING: double-sign detected", "evidence", ev.String()) - return fmt.Errorf("double-sign: %s", ev.String()) - } - // Oracle price sanity check: reject blocks whose embedded price deviates // too far from our local oracle reading (BFT oracle enforcement). if err := e.checkOraclePriceDeviation(block.Header.OraclePrice); err != nil { @@ -2161,6 +2226,13 @@ func (e *Engine) handleIncomingBlock(block *core.Block) error { persistErr := fmt.Errorf("persist canonical block %d: OnCanonicalBlock is required", block.Header.Height) return e.failCanonicalPersistence(block, stakeRollback, persistErr) } + if ev := e.slashing.RecordAcceptedBlock( + block.Header.Height, block.Header.Round, + block.Header.ValidatorPub, hash, block.Header.Signature, + ); ev != nil { + e.halted.Store(true) + return fmt.Errorf("fatal slashing detector conflict after canonical persistence: %s", ev.String()) + } // Stake txs already applied above — no processStakeTxs call needed. if e.cfg.Registry != nil { e.cfg.Registry.SetSignatureAdmissionHeight(block.Header.Height + 1) diff --git a/consensus/sale.go b/consensus/sale.go index 5e04935..5dddfce 100644 --- a/consensus/sale.go +++ b/consensus/sale.go @@ -15,7 +15,12 @@ func (e *Engine) validateVersionWithSales(tx *core.Transaction, height, activati _, err := tx.SaleAuthorization() return err } - return core.ValidateTxVersionAtHeight(tx, height, activation, e.cfg.RingCTCLSAGActivationHeight, e.cfg.AVMActivationHeight) + return core.ValidateTxVersionAtHeight( + tx, height, activation, + e.cfg.RingCTCLSAGActivationHeight, + e.cfg.AVMActivationHeight, + e.cfg.RingCTAmountBindingActivationHeight, + ) } func (e *Engine) prepareSale(b *core.Block) (*store.SaleSettlement, error) { 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/consensus/slashing.go b/consensus/slashing.go index 01ccf81..64d0363 100644 --- a/consensus/slashing.go +++ b/consensus/slashing.go @@ -43,42 +43,59 @@ func newSlashingDetector() *slashingDetector { return &slashingDetector{seen: make(map[evidenceKey]signedSlot)} } -// CheckBlock records a signed block header and returns DoubleSignEvidence -// if this validator has already signed a different block at the same height. -// Returns nil if no double-sign is detected. -func (sd *slashingDetector) CheckBlock(height uint64, round uint32, pub crypto.ValidatorPubKey, hash crypto.Hash32, sig []byte) *DoubleSignEvidence { +// DetectDoubleSign checks a candidate against accepted blocks without +// recording it. Call this before expensive stateful validation so a known +// equivocation is rejected, but an invalid candidate cannot poison the slot. +func (sd *slashingDetector) DetectDoubleSign(height uint64, round uint32, pub crypto.ValidatorPubKey, hash crypto.Hash32, sig []byte) *DoubleSignEvidence { key := evidenceKey{height: height, pubHex: pub.ID()} sd.mu.Lock() defer sd.mu.Unlock() prev, exists := sd.seen[key] - if !exists { - sd.seen[key] = signedSlot{hash: hash, sig: sig, round: round} - // Opportunistically prune entries for heights far below this one to - // prevent the seen map from growing unboundedly. Keep the last 2048 - // heights of evidence so any delayed double-sign is still caught. - if height > 2048 { - pruneBelow := height - 2048 - for k := range sd.seen { - if k.height < pruneBelow { - delete(sd.seen, k) - } - } - } + if !exists || prev.hash == hash { return nil } - if prev.hash == hash { - return nil // same block, not a double-sign + return doubleSignEvidence(height, round, pub, hash, sig, prev) +} + +// RecordAcceptedBlock stores a block only after all consensus checks and the +// canonical durable commit succeed. It returns evidence if an unexpected +// conflicting accepted record already exists; callers must fail closed. +func (sd *slashingDetector) RecordAcceptedBlock(height uint64, round uint32, pub crypto.ValidatorPubKey, hash crypto.Hash32, sig []byte) *DoubleSignEvidence { + key := evidenceKey{height: height, pubHex: pub.ID()} + sd.mu.Lock() + defer sd.mu.Unlock() + + prev, exists := sd.seen[key] + if exists { + if prev.hash == hash { + return nil + } + return doubleSignEvidence(height, round, pub, hash, sig, prev) } + sd.seen[key] = signedSlot{hash: hash, sig: append([]byte(nil), sig...), round: round} + // Keep a bounded history of accepted slots for delayed equivocation checks. + if height > 2048 { + pruneBelow := height - 2048 + for k := range sd.seen { + if k.height < pruneBelow { + delete(sd.seen, k) + } + } + } + return nil +} + +func doubleSignEvidence(height uint64, round uint32, pub crypto.ValidatorPubKey, hash crypto.Hash32, sig []byte, prev signedSlot) *DoubleSignEvidence { return &DoubleSignEvidence{ Height: height, Round1: prev.round, Validator: pub, Hash1: prev.hash, - Sig1: prev.sig, + Sig1: append([]byte(nil), prev.sig...), Round2: round, Hash2: hash, - Sig2: sig, + Sig2: append([]byte(nil), sig...), } } diff --git a/consensus/slashing_test.go b/consensus/slashing_test.go index 00fd04a..b77c46e 100644 --- a/consensus/slashing_test.go +++ b/consensus/slashing_test.go @@ -12,10 +12,13 @@ func TestSlashing_NoDoubleSign(t *testing.T) { var h1 crypto.Hash32 h1[0] = 1 - ev := sd.CheckBlock(1, 0, pub, h1, []byte("sig1")) + ev := sd.DetectDoubleSign(1, 0, pub, h1, []byte("sig1")) if ev != nil { t.Errorf("expected no evidence for first block, got: %s", ev.String()) } + if ev := sd.RecordAcceptedBlock(1, 0, pub, h1, []byte("sig1")); ev != nil { + t.Fatalf("record accepted block: %s", ev.String()) + } } func TestSlashing_SameBlockTwice(t *testing.T) { @@ -24,8 +27,8 @@ func TestSlashing_SameBlockTwice(t *testing.T) { var h1 crypto.Hash32 h1[0] = 1 - sd.CheckBlock(1, 0, pub, h1, []byte("sig1")) - ev := sd.CheckBlock(1, 0, pub, h1, []byte("sig1")) + sd.RecordAcceptedBlock(1, 0, pub, h1, []byte("sig1")) + ev := sd.DetectDoubleSign(1, 0, pub, h1, []byte("sig1")) if ev != nil { t.Error("same block twice should not produce evidence") } @@ -38,8 +41,8 @@ func TestSlashing_DoubleSign(t *testing.T) { h1[0] = 0xAA h2[0] = 0xBB - sd.CheckBlock(2, 0, pub, h1, []byte("sig1")) - ev := sd.CheckBlock(2, 0, pub, h2, []byte("sig2")) + sd.RecordAcceptedBlock(2, 0, pub, h1, []byte("sig1")) + ev := sd.DetectDoubleSign(2, 0, pub, h2, []byte("sig2")) if ev == nil { t.Fatal("expected double-sign evidence") } @@ -63,8 +66,8 @@ func TestSlashing_DifferentSlots(t *testing.T) { // Same height, different rounds is also a double-sign: validators must // not make competing proposals for one height. - sd.CheckBlock(3, 0, pub, h1, []byte("sig1")) - ev := sd.CheckBlock(3, 1, pub, h2, []byte("sig2")) + sd.RecordAcceptedBlock(3, 0, pub, h1, []byte("sig1")) + ev := sd.DetectDoubleSign(3, 1, pub, h2, []byte("sig2")) if ev == nil { t.Fatal("different rounds at one height should trigger double-sign evidence") } @@ -81,9 +84,31 @@ func TestSlashing_DifferentValidators(t *testing.T) { h1[0] = 1 h2[0] = 2 - sd.CheckBlock(1, 0, pub1, h1, []byte("sig1")) - ev := sd.CheckBlock(1, 0, pub2, h2, []byte("sig2")) + sd.RecordAcceptedBlock(1, 0, pub1, h1, []byte("sig1")) + ev := sd.DetectDoubleSign(1, 0, pub2, h2, []byte("sig2")) if ev != nil { t.Error("different validators in same slot should not trigger evidence") } } + +func TestSlashing_InvalidCandidateDoesNotPoisonAcceptedSlot(t *testing.T) { + sd := newSlashingDetector() + _, pub, _ := crypto.GenerateValidatorKey() + var invalid, valid crypto.Hash32 + invalid[0], valid[0] = 1, 2 + + // A signed candidate that later fails transaction or state validation is + // checked but must not be committed to the accepted-slot detector. + if ev := sd.DetectDoubleSign(4, 0, pub, invalid, []byte("invalid-sig")); ev != nil { + t.Fatalf("unexpected evidence before first accepted block: %v", ev) + } + if ev := sd.DetectDoubleSign(4, 0, pub, valid, []byte("valid-sig")); ev != nil { + t.Fatalf("invalid candidate poisoned the slot: %s", ev.String()) + } + if ev := sd.RecordAcceptedBlock(4, 0, pub, valid, []byte("valid-sig")); ev != nil { + t.Fatalf("valid candidate could not be recorded: %s", ev.String()) + } + if ev := sd.DetectDoubleSign(4, 0, pub, invalid, []byte("invalid-sig")); ev == nil { + t.Fatal("conflict with an accepted block was not detected") + } +} 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/clsag_ring_store_test.go b/core/clsag_ring_store_test.go index 1aba52e..7c7b544 100644 --- a/core/clsag_ring_store_test.go +++ b/core/clsag_ring_store_test.go @@ -24,14 +24,9 @@ func (s *testRingStore) SampleRingMembers(count int, _ map[crypto.Point32]bool) return s.sampled[:count], nil } -func TestCLSAGOutputCacheIsBoundedAndFallsBackToPersistentIndex(t *testing.T) { - oldLimit := maxCLSAGRecentOutputs - maxCLSAGRecentOutputs = 2 - t.Cleanup(func() { maxCLSAGRecentOutputs = oldLimit }) - +func TestCLSAGActiveOutputsRemainSpendableAndIndexed(t *testing.T) { backend := &testRingStore{members: make(map[crypto.Point32]*UTXO)} set := NewUTXOSetWithDB(backend) - set.SetCLSAGActivationHeight(10) var first *UTXO for i := byte(1); i <= 4; i++ { @@ -46,8 +41,11 @@ func TestCLSAGOutputCacheIsBoundedAndFallsBackToPersistentIndex(t *testing.T) { } } - if got := set.Get(first.TxHash, 0); got != nil { - t.Fatal("old v5 output remained in bounded in-memory cache") + if got := set.Get(first.TxHash, 0); got == nil { + t.Fatal("old unspent output was evicted from the active UTXO set") + } + if got := set.GetByPubKey(first.OneTimePub); got == nil { + t.Fatal("old unspent output was evicted from the active public-key index") } if got := set.GetRingMember(first.OneTimePub); got == nil || got.TxHash != first.TxHash || got.OneTimePub != first.OneTimePub { 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/mempool.go b/core/mempool.go index acf9817..16946de 100644 --- a/core/mempool.go +++ b/core/mempool.go @@ -36,10 +36,11 @@ type MempoolConfig struct { // transactions that cannot be included in the next block. Keeping this gate // at admission prevents callers from receiving a successful hash for a tx // that the block producer will immediately evict. - RingCTV4ActivationHeight uint64 - RingCTCLSAGActivationHeight uint64 - AVMActivationHeight uint64 - CurrentHeight func() uint64 + RingCTV4ActivationHeight uint64 + RingCTCLSAGActivationHeight uint64 + RingCTAmountBindingActivationHeight uint64 + AVMActivationHeight uint64 + CurrentHeight func() uint64 // Verifier performs full RingCT/ring-sig/range-proof verification in Add(). // When nil the mempool only runs structural Validate() (dev/test mode). // Production nodes MUST set this to prevent C-0/C-1 inflation attacks. @@ -170,6 +171,10 @@ func (m *Mempool) NextSpendVersion() TxVersion { if nextHeight < ^uint64(0) { nextHeight++ } + if m.cfg.RingCTAmountBindingActivationHeight > 0 && + nextHeight >= m.cfg.RingCTAmountBindingActivationHeight { + return TxVersionAmountBinding + } if m.cfg.RingCTCLSAGActivationHeight > 0 && nextHeight >= m.cfg.RingCTCLSAGActivationHeight { return TxVersionCLSAG } @@ -179,6 +184,20 @@ func (m *Mempool) NextSpendVersion() TxVersion { return TxVersionCommitmentBinding } +// NextAVMVersion returns the AVM transaction format valid in the next block. +func (m *Mempool) NextAVMVersion() TxVersion { + if m.cfg.CurrentHeight != nil && m.cfg.RingCTAmountBindingActivationHeight > 0 { + nextHeight := m.cfg.CurrentHeight() + if nextHeight < ^uint64(0) { + nextHeight++ + } + if nextHeight >= m.cfg.RingCTAmountBindingActivationHeight { + return TxVersionAVMAmountBinding + } + } + return TxVersionAVM +} + // Add attempts to add a transaction to the mempool. // Returns an error if the tx is invalid, duplicate, too large, or a double-spend. func (m *Mempool) Add(tx Transaction) error { @@ -206,6 +225,7 @@ func (m *Mempool) Add(tx Transaction) error { m.cfg.RingCTV4ActivationHeight, m.cfg.RingCTCLSAGActivationHeight, m.cfg.AVMActivationHeight, + m.cfg.RingCTAmountBindingActivationHeight, ); err != nil { return fmt.Errorf("mempool: transaction activation policy: %w", 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_amount_binding_test.go b/core/ringct_amount_binding_test.go new file mode 100644 index 0000000..537a0fd --- /dev/null +++ b/core/ringct_amount_binding_test.go @@ -0,0 +1,109 @@ +package core_test + +import ( + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestRingCTAmountBindingBuilderVerifierAndWalletRecovery(t *testing.T) { + alice, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + bob, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + const supply = uint64(10_000_000) + chain, _, blind := makeGenesisWithCoinbase(t, alice, supply) + scanner := core.NewWalletScanner(alice.View.Private, alice.Spend.Public, alice.View.Public, crypto.TestnetByte) + owned := scanner.ScanChain(chain, 0, chain.Height()) + if len(owned) != 1 { + t.Fatalf("genesis owned outputs=%d, want 1", len(owned)) + } + owned[0].Blind = blind + + set := core.NewUTXOSet() + set.Add(&owned[0].UTXO) + decoys := make([]core.DecoyUTXO, crypto.RingSize-1) + for i := range decoys { + keys, keyErr := crypto.GenerateWalletKeys() + if keyErr != nil { + t.Fatal(keyErr) + } + decoyBlind, blindErr := crypto.NewBlindFactor() + if blindErr != nil { + t.Fatal(blindErr) + } + commit, commitErr := crypto.Commit(uint64(i+1), decoyBlind) + if commitErr != nil { + t.Fatal(commitErr) + } + decoys[i] = core.DecoyUTXO{OneTimePub: keys.Spend.Public, AmountCommit: commit} + set.Add(&core.UTXO{OneTimePub: keys.Spend.Public, AmountCommit: commit}) + } + + aliceAddr := crypto.AddressFromKeys(crypto.TestnetByte, alice) + bobAddr := crypto.AddressFromKeys(crypto.TestnetByte, bob) + result, err := core.NewTxBuilder(alice.Spend.Private, alice.View.Private, alice.Spend.Public, owned, 1). + WithVersion(core.TxVersionAmountBinding). + WithDecoys(decoys). + Build(1_000_000, bobAddr, aliceAddr) + if err != nil { + t.Fatalf("build amount-bound transfer: %v", err) + } + if err := core.NewTxVerifier(set).VerifyTx(&result.Tx); err != nil { + t.Fatalf("verify amount-bound transfer: %v", err) + } + if result.Tx.Size() < core.AmountBindingExtraBytesPerOutput*len(result.Tx.Outputs) { + t.Fatal("transaction size estimate omitted amount-binding payload") + } + for i := range result.Tx.Outputs { + if result.Tx.Outputs[i].EncAmount != ([8]byte{}) { + t.Fatalf("bound output %d still exposes a legacy EncAmount", i) + } + } + + bobScanner := core.NewWalletScannerWithSpendKey( + bob.Spend.Private, bob.View.Private, bob.Spend.Public, bob.View.Public, crypto.TestnetByte, + ) + block := &core.Block{Header: core.BlockHeader{Height: 1}, Txs: []core.Transaction{result.Tx}} + received, err := bobScanner.ScanBlockVerified(block) + if err != nil { + t.Fatalf("recipient scan: %v", err) + } + if len(received) != 1 || received[0].Amount != 1_000_000 { + t.Fatalf("recipient recovered %d outputs, amount=%d; want one output of 1000000", + len(received), func() uint64 { + if len(received) == 0 { + return 0 + } + return received[0].Amount + }()) + } + + // New-format wallets never trust the legacy XOR field for value recovery. + tampered := result.Tx + tampered.Outputs = append([]core.Output(nil), result.Tx.Outputs...) + tampered.Outputs[0].EncAmount[0] ^= 0x80 + received, err = bobScanner.ScanBlockVerified(&core.Block{ + Header: core.BlockHeader{Height: 1}, + Txs: []core.Transaction{tampered}, + }) + if err != nil { + t.Fatalf("scan with altered legacy EncAmount: %v", err) + } + if len(received) != 1 || received[0].Amount != 1_000_000 { + t.Fatal("legacy EncAmount affected bound amount recovery") + } + if err := core.NewTxVerifier(set).VerifyTx(&tampered); err == nil { + t.Fatal("tampering the signed legacy EncAmount field did not invalidate the transaction") + } + + viewOnly := core.NewWalletScanner(bob.View.Private, bob.Spend.Public, bob.View.Public, crypto.TestnetByte) + if _, err := viewOnly.ScanBlockVerified(block); err == nil { + t.Fatal("view-only scanner claimed a bound amount without the spend key") + } +} diff --git a/core/ringct_v5_test.go b/core/ringct_v5_test.go index 65e46fc..ea71839 100644 --- a/core/ringct_v5_test.go +++ b/core/ringct_v5_test.go @@ -139,7 +139,6 @@ func TestRingCTV5RejectsStakedCollateralAcrossRestartAndRollback(t *testing.T) { t.Fatal(err) } set := core.NewUTXOSetWithDB(db) - set.SetCLSAGActivationHeight(1) set.Add(&owned[0].UTXO) const stakedCollateralActivationHeight = uint64(2_950_000) verifier := core.NewTxVerifier(set) @@ -280,13 +279,16 @@ 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 // for historical compatibility; IsStaked is the consensus exclusion. restarted := core.NewUTXOSetWithDB(db) - restarted.SetCLSAGActivationHeight(1) restarted.MarkStakedKnown(owned[0].TxHash, owned[0].OutputIndex, &core.UTXO{ TxHash: owned[0].TxHash, OutputIndex: owned[0].OutputIndex, AmountCommit: owned[0].AmountCommit, 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..1422f9f 100644 --- a/core/transaction.go +++ b/core/transaction.go @@ -69,6 +69,11 @@ const ( // TxVersionGuardianFund is the one-off, consensus-synthesized Guardian // allocation. It is not a coinbase and can never be admitted to the pool. TxVersionGuardianFund TxVersion = 7 + // TxVersionAmountBinding (wire version 13) adds privacy-preserving, publicly + // verifiable encrypted-amount proofs to CLSAG transfers. + TxVersionAmountBinding TxVersion = 13 + // TxVersionAVMAmountBinding (wire version 14) carries AVM in the same format. + TxVersionAVMAmountBinding TxVersion = 14 ) // Transaction is the core unit of value transfer in Aperod. @@ -95,7 +100,7 @@ type Transaction struct { // Extra holds optional data: game asset metadata, OP_RETURN-style notes. // Max 255 bytes. Extra []byte - // AVM is present only for TxVersionAVM. + // AVM is present only for TxVersionAVM or TxVersionAVMAmountBinding. AVM *AVMPayload `json:"avm,omitempty"` } @@ -127,9 +132,13 @@ type Output struct { TxPubKey crypto.Point32 // AmountCommit is the Pedersen commitment C = r·G + v·H hiding the amount. AmountCommit crypto.Commitment - // EncAmount is the amount encrypted with the recipient's view key (for scanning). - // 8 bytes XOR-encrypted with the ECDH shared secret. + // EncAmount is the legacy view-key-readable amount field. Versions 13/14 + // require it to be zero and derive the amount only from AmountBinding. EncAmount [8]byte + // AmountBinding is present only in versions 13 and 14. It proves that the + // encrypted amount matches AmountCommit without revealing the amount or + // the recipient's static view key. + AmountBinding *crypto.AmountBinding `json:"amount_binding,omitempty"` } // Hash returns the SHA3-256 hash of the transaction (used as transaction ID). @@ -171,6 +180,12 @@ func (tx *Transaction) Hash() crypto.Hash32 { parts = append(parts, out.AmountCommit[:]) parts = append(parts, out.TxPubKey[:]) // F-4: was missing parts = append(parts, out.EncAmount[:]) // F-4: was missing + if out.AmountBinding != nil { + for i := range out.AmountBinding.Chunks { + chunk := &out.AmountBinding.Chunks[i] + parts = append(parts, chunk.Commitment[:], chunk.R[:], chunk.E[:]) + } + } } return crypto.HashBytes(parts...) } @@ -189,7 +204,11 @@ func (tx *Transaction) IsStake() bool { } func (tx *Transaction) IsAVM() bool { - return tx.Version == TxVersionAVM + return tx != nil && (tx.Version == TxVersionAVM || tx.Version == TxVersionAVMAmountBinding) +} + +func (tx *Transaction) UsesAmountBinding() bool { + return tx != nil && (tx.Version == TxVersionAmountBinding || tx.Version == TxVersionAVMAmountBinding) } // IsGuardianFund identifies the dedicated, protocol-locked nominal-supply @@ -197,7 +216,8 @@ func (tx *Transaction) IsAVM() bool { func (tx *Transaction) IsGuardianFund() bool { return tx != nil && tx.Version == TxVersionGuardianFund } func (tx *Transaction) UsesCLSAG() bool { - return tx.Version == TxVersionCLSAG || tx.Version == TxVersionAVM + return tx != nil && (tx.Version == TxVersionCLSAG || tx.Version == TxVersionAVM || + tx.Version == TxVersionAmountBinding || tx.Version == TxVersionAVMAmountBinding) } // Validate checks structural validity of the transaction. @@ -207,7 +227,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, TxVersionAmountBinding, TxVersionAVMAmountBinding, TxVersionLPoD, TxVersionLPoDPosition, TxVersionLPoDPayout, TxVersionSale, TxVersionLPoDTopUp: default: return fmt.Errorf("unsupported tx version %d", tx.Version) } @@ -286,7 +306,9 @@ func (tx *Transaction) Validate() error { return burnErr } if burnMarked { - if tx.Version != TxVersionCommitmentBinding && tx.Version != TxVersionCLSAG { + if tx.Version != TxVersionCommitmentBinding && + tx.Version != TxVersionCLSAG && + tx.Version != TxVersionAmountBinding { return fmt.Errorf("intentional burn requires transaction version %d", TxVersionCommitmentBinding) } if tx.IsCoinbase() { @@ -309,6 +331,17 @@ func (tx *Transaction) Validate() error { if len(tx.Extra) > 255 { return fmt.Errorf("tx extra too large: %d bytes (max 255)", len(tx.Extra)) } + for i := range tx.Outputs { + if tx.UsesAmountBinding() && tx.Outputs[i].AmountBinding == nil { + return fmt.Errorf("tx: amount-bound output %d is missing amount binding", i) + } + if tx.UsesAmountBinding() && tx.Outputs[i].EncAmount != ([8]byte{}) { + return fmt.Errorf("tx: amount-bound output %d must not carry legacy encrypted amount", i) + } + if !tx.UsesAmountBinding() && tx.Outputs[i].AmountBinding != nil { + return fmt.Errorf("tx: legacy output %d must not carry amount binding", i) + } + } // Coinbase: no inputs required, no signatures or ring proofs. Only the // legacy mint wire version is a valid coinbase; otherwise a malformed v4 @@ -421,6 +454,29 @@ func ValidateTxVersionAtHeight(tx *Transaction, height, ringCTV4ActivationHeight if len(activations) > 1 { avmHeight = activations[1] } + amountBindingHeight := uint64(0) + if len(activations) > 2 { + amountBindingHeight = activations[2] + } + if amountBindingHeight > 0 { + if height >= amountBindingHeight { + if tx.IsAVM() { + if tx.Version != TxVersionAVMAmountBinding { + return fmt.Errorf("amount-bound AVM transaction version %d is required at height %d", + TxVersionAVMAmountBinding, height) + } + } else if tx.Version != TxVersionAmountBinding { + return fmt.Errorf("amount-bound transaction version %d is required at height %d", + TxVersionAmountBinding, height) + } + } else if tx.UsesAmountBinding() { + return fmt.Errorf("transaction version %d is not active until height %d", + tx.Version, amountBindingHeight) + } + } + if tx.UsesAmountBinding() && (clsagHeight == 0 || height < clsagHeight) { + return fmt.Errorf("amount-binding transaction requires active CLSAG at height %d", height) + } if tx.IsAVM() { if avmHeight == 0 || height < avmHeight { return fmt.Errorf("AVM transaction version %d is not active", tx.Version) @@ -428,8 +484,18 @@ func ValidateTxVersionAtHeight(tx *Transaction, height, ringCTV4ActivationHeight if clsagHeight == 0 || height < clsagHeight { return fmt.Errorf("AVM requires active CLSAG at height %d", height) } + if tx.Version == TxVersionAVM && amountBindingHeight > 0 && height >= amountBindingHeight { + return fmt.Errorf("AVM transaction version %d is disabled at amount-binding activation", tx.Version) + } return nil } + if tx.Version == TxVersionAmountBinding && (amountBindingHeight == 0 || height < amountBindingHeight) { + return fmt.Errorf("transaction version %d is not active", tx.Version) + } + if tx.Version == TxVersionAVMAmountBinding && + (amountBindingHeight == 0 || height < amountBindingHeight) { + return fmt.Errorf("transaction version %d is not active", tx.Version) + } if tx.Version == TxVersionCLSAG && (clsagHeight == 0 || height < clsagHeight) { return fmt.Errorf("transaction version %d is not active", tx.Version) } @@ -477,6 +543,11 @@ func (tx *Transaction) Size() int { } else { size += len(tx.Signatures) * sigBytes } + if tx.UsesAmountBinding() { + // The linked ciphertext proofs are substantial; the estimate accounts + // for four 16-bit range proofs and four equality proofs per output. + size += len(tx.Outputs) * AmountBindingExtraBytesPerOutput + } if tx.AVM != nil { size += len(tx.AVM.canonicalBytes(true)) } @@ -496,6 +567,10 @@ const InitialBaseFeePerByte uint64 = 200 // A 2 000-byte tx therefore costs at least 100 000 nAPRO = 0.001 APRO. const MinBaseFeePerByte uint64 = 50 +// AmountBindingExtraBytesPerOutput covers four 16-bit Bulletproofs, four +// equality proofs, four chunk commitments, and four ElGamal ciphertexts. +const AmountBindingExtraBytesPerOutput = 4 * (675 + 3*32 + 3*32) + // MinFeeAt returns the minimum acceptable fee for this transaction at the given // base fee rate. Any excess paid above this minimum is a priority tip to the validator. func (tx *Transaction) MinFeeAt(baseFeePerByte uint64) uint64 { diff --git a/core/transaction_version_test.go b/core/transaction_version_test.go index 434e479..261935e 100644 --- a/core/transaction_version_test.go +++ b/core/transaction_version_test.go @@ -44,6 +44,42 @@ func TestRingCTV4ActivationBoundary(t *testing.T) { } } +func TestRingCTAmountBindingActivationBoundary(t *testing.T) { + const activation = uint64(200) + legacy := &core.Transaction{Version: core.TxVersionCLSAG, Inputs: []core.RingInput{{}}} + bound := &core.Transaction{Version: core.TxVersionAmountBinding, Inputs: []core.RingInput{{}}} + avm := &core.Transaction{Version: core.TxVersionAVM, Inputs: []core.RingInput{{}}} + boundAVM := &core.Transaction{Version: core.TxVersionAVMAmountBinding, Inputs: []core.RingInput{{}}} + + args := func(tx *core.Transaction, height uint64) error { + return core.ValidateTxVersionAtHeight(tx, height, 1, 1, 100, activation) + } + if err := args(legacy, activation-1); err != nil { + t.Fatalf("legacy CLSAG rejected before amount-binding activation: %v", err) + } + if err := args(bound, activation-1); err == nil { + t.Fatal("amount-binding version accepted before activation") + } + if err := args(boundAVM, activation-1); err == nil { + t.Fatal("amount-binding AVM version accepted before activation") + } + if err := args(legacy, activation); err == nil { + t.Fatal("legacy CLSAG accepted at amount-binding activation") + } + if err := args(avm, activation); err == nil { + t.Fatal("legacy AVM accepted at amount-binding activation") + } + if err := args(bound, activation); err != nil { + t.Fatalf("amount-binding version rejected at activation: %v", err) + } + if err := args(boundAVM, activation); err != nil { + t.Fatalf("amount-binding AVM version rejected at activation: %v", err) + } + if err := core.ValidateTxVersionAtHeight(bound, activation, 1, 0, 100, activation); err == nil { + t.Fatal("amount-binding version accepted without an active CLSAG height") + } +} + func TestStakeTransactionCannotCreateOutputs(t *testing.T) { extra := make([]byte, core.StakePayloadSize) extra[0] = byte(core.StakeWithdraw) diff --git a/core/tx_builder.go b/core/tx_builder.go index d1653c7..64f1e49 100644 --- a/core/tx_builder.go +++ b/core/tx_builder.go @@ -104,7 +104,11 @@ func (b *TxBuilder) WithAVM(payload AVMPayload) *TxBuilder { } } b.avmPayload = &cloned - b.txVersion = TxVersionAVM + if b.txVersion == TxVersionAmountBinding || b.txVersion == TxVersionAVMAmountBinding { + b.txVersion = TxVersionAVMAmountBinding + } else { + b.txVersion = TxVersionAVM + } return b } @@ -209,7 +213,7 @@ func (b *TxBuilder) Build(amount uint64, recipient, changeAddr crypto.Address) ( if err := crypto.Validate(changeAddr); err != nil { return nil, fmt.Errorf("invalid change address: %w", err) } - if b.txVersion == TxVersionAVM { + if txVersionIsAVM(b.txVersion) { if b.avmPayload == nil { return nil, fmt.Errorf("AVM transaction requires WithAVM") } @@ -527,6 +531,21 @@ func (b *TxBuilder) Build(amount uint64, recipient, changeAddr crypto.Address) ( rangeProofs := make([]*crypto.RangeProof, len(outEntries)) for i, e := range outEntries { outputs[i] = e.output + if txVersionUsesAmountBinding(b.txVersion) { + // The old EncAmount is readable by a static view key and is not + // authenticated by RingCT. Keep it zero for the new format. + outputs[i].EncAmount = [8]byte{} + binding, aggregateCommit, bindingErr := crypto.CreateAmountBinding( + e.amount, e.blind, e.output.OneTimePub, + ) + if bindingErr != nil { + return nil, fmt.Errorf("amount binding [%d]: %w", i, bindingErr) + } + if aggregateCommit != e.output.AmountCommit { + return nil, fmt.Errorf("amount binding [%d]: aggregate commitment mismatch", i) + } + outputs[i].AmountBinding = binding + } proof, err := crypto.ProveRange(e.amount, e.blind) if err != nil { return nil, fmt.Errorf("range proof [%d]: %w", i, err) @@ -1018,6 +1037,12 @@ func ExportedEstimateFee(nInputs, nOutputs int, feePerByte uint64) uint64 { return size * feePerByte } +// ExportedEstimateFeeForVersion uses the wire format's serialized size, +// including amount-binding proofs when version 13 or 14 is selected. +func ExportedEstimateFeeForVersion(version TxVersion, nInputs, nOutputs int, feePerByte uint64) uint64 { + return txEstimateFeeVersion(version, nInputs, nOutputs, feePerByte) +} + // txEstimateFee is the internal variant used during transaction construction. func txEstimateFee(nInputs, nOutputs int, feePerByte uint64) uint64 { return ExportedEstimateFee(nInputs, nOutputs, feePerByte) @@ -1031,11 +1056,24 @@ func txEstimatedSize(version TxVersion, nInputs, nOutputs int) int { // (32), compact CLSAG (608). inputBytes = 576 + crypto.RingSize*32 + 32 + 608 } - return txOverheadBytes + nInputs*inputBytes + nOutputs*txBytesPerOutput + outputBytes := txBytesPerOutput + if txVersionUsesAmountBinding(version) { + outputBytes += AmountBindingExtraBytesPerOutput + } + return txOverheadBytes + nInputs*inputBytes + nOutputs*outputBytes } func txVersionUsesCLSAG(version TxVersion) bool { - return version == TxVersionCLSAG || version == TxVersionAVM + return version == TxVersionCLSAG || version == TxVersionAVM || + version == TxVersionAmountBinding || version == TxVersionAVMAmountBinding +} + +func txVersionUsesAmountBinding(version TxVersion) bool { + return version == TxVersionAmountBinding || version == TxVersionAVMAmountBinding +} + +func txVersionIsAVM(version TxVersion) bool { + return version == TxVersionAVM || version == TxVersionAVMAmountBinding } func txEstimateFeeVersion(version TxVersion, nInputs, nOutputs int, feePerByte uint64) uint64 { diff --git a/core/tx_builder_multi.go b/core/tx_builder_multi.go index 924a331..58bec01 100644 --- a/core/tx_builder_multi.go +++ b/core/tx_builder_multi.go @@ -229,7 +229,7 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad } need := len(selected) * (crypto.RingSize - 1) var err error - allDecoys, err = b.sampleChainDecoys(need, excludePubs, b.txVersion == TxVersionCLSAG) + allDecoys, err = b.sampleChainDecoys(need, excludePubs, txVersionUsesCLSAG(b.txVersion)) if err != nil { return nil, fmt.Errorf("sample chain decoys: %w", err) } @@ -258,7 +258,7 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad ringDecoys = allDecoys[start:end] } - if b.txVersion == TxVersionCLSAG && len(ringDecoys) != crypto.RingSize-1 { + if txVersionUsesCLSAG(b.txVersion) && len(ringDecoys) != crypto.RingSize-1 { return nil, fmt.Errorf("v5 requires %d real on-chain decoys per input", crypto.RingSize-1) } ring, realIdx, fallbacks, err := txBuildRing(u.OneTimePub, ringDecoys) @@ -279,7 +279,7 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad AmountCommit: u.AmountCommit, RealIndex: uint8(realIdx), } - if b.txVersion == TxVersionCLSAG { + if txVersionUsesCLSAG(b.txVersion) { commits := make([]crypto.Commitment, crypto.RingSize) di := 0 for j := range commits { @@ -300,6 +300,21 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad rangeProofs := make([]*crypto.RangeProof, len(outEntries)) for i, e := range outEntries { outputs[i] = e.output + if txVersionUsesAmountBinding(b.txVersion) { + // The old EncAmount is readable by a static view key and is not + // authenticated by RingCT. Keep it zero for the new format. + outputs[i].EncAmount = [8]byte{} + binding, aggregateCommit, bindingErr := crypto.CreateAmountBinding( + e.amount, e.blind, e.output.OneTimePub, + ) + if bindingErr != nil { + return nil, fmt.Errorf("amount binding[%d]: %w", i, bindingErr) + } + if aggregateCommit != e.output.AmountCommit { + return nil, fmt.Errorf("amount binding[%d]: aggregate commitment mismatch", i) + } + outputs[i].AmountBinding = binding + } proof, err := crypto.ProveRange(e.amount, e.blind) if err != nil { return nil, fmt.Errorf("range proof[%d]: %w", i, err) @@ -317,7 +332,7 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad FeeCommit: feeCommit, RangeProofs: rangeProofs, } - if tx.Version == TxVersionCLSAG { + if tx.UsesCLSAG() { pseudoBlinds = make([]crypto.BlindFactor, len(inputs)) for i := 0; i < len(inputs)-1; i++ { pseudoBlinds[i], err = crypto.NewBlindFactor() @@ -348,7 +363,7 @@ func (b *TxBuilder) BuildMulti(recipients []BatchRecipient, changeAddr crypto.Ad msg := ringSignMessage(txHashVal, uint32(i)) var sig *crypto.MLSAGSignature var err error - if tx.Version == TxVersionCLSAG { + if tx.UsesCLSAG() { cs, signErr := crypto.CLSAGSign(msg, inputs[i].Ring, inputs[i].RingCommitments, tx.Inputs[i].PseudoOut, inputRealIdxs[i], inputPrivKeys[i], selected[i].Blind, pseudoBlinds[i]) if signErr != nil { return nil, fmt.Errorf("clsag sign[%d]: %w", i, signErr) diff --git a/core/tx_verifier.go b/core/tx_verifier.go index 59f8dfa..e6a9cb5 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) } @@ -407,6 +418,18 @@ func (v *TxVerifier) verifyTx(tx *Transaction, blockStakeBurns map[UTXOKey]struc return fmt.Errorf("tx %x: range proof commitment mismatch at output %d", txHashPrefix[:8], i) } } + if tx.UsesAmountBinding() { + for i := range tx.Outputs { + output := &tx.Outputs[i] + ok, err := crypto.VerifyAmountBinding(output.AmountCommit, output.OneTimePub, output.AmountBinding) + if err != nil { + return fmt.Errorf("tx %x: amount binding proof error at output %d: %w", txHashPrefix[:8], i, err) + } + if !ok { + return fmt.Errorf("tx %x: amount binding mismatch at output %d", txHashPrefix[:8], i) + } + } + } // 5b. Fee commitment binding. // diff --git a/core/utxo.go b/core/utxo.go index d80bbdb..b7a6d98 100644 --- a/core/utxo.go +++ b/core/utxo.go @@ -225,10 +225,6 @@ type UTXOKey struct { // the cap without needing to pre-fill 10 000 entries. var maxSpentDecoys = 10_000 -// maxCLSAGRecentOutputs bounds post-activation output material retained in -// memory. The complete canonical anonymity set is stored in RingMemberStore. -var maxCLSAGRecentOutputs = 100_000 - // maxRollbackDepth is the number of recent block heights kept in the rollback // journal. Any chain reorganization deeper than this cannot be reversed in // memory. On a PoA chain such deep reorgs are operationally impossible. @@ -246,16 +242,14 @@ type rollbackEntry struct { // UTXOSet is an in-memory UTXO set backed by the persistent store. // In production, reads/writes go through store.UTXOStore (LevelDB). type UTXOSet struct { - mu sync.RWMutex - utxos map[UTXOKey]*UTXO - keyImages compactKeyImageSet // spent key images — compact sorted slice (32 B/entry vs ~150 B map) - byPubKey map[crypto.Point32]*UTXO // ACTIVE (unspent) UTXOs by OneTimePub for C-0 check - stakedUTXOs map[UTXOKey]*UTXO // UTXOs burned for staking (C-1 fix) — stores data for rollback - spentPubKeys map[crypto.Point32]*UTXO // Phase 2: spent UTXOs removed from byPubKey; used as safe ring decoys - rollbackJournal map[uint64][]rollbackEntry // height → UTXOs spent at that height (for RollbackBlock) - ringMembers RingMemberStore - clsagActivationHeight uint64 - clsagRecentOutputs []UTXOKey + mu sync.RWMutex + utxos map[UTXOKey]*UTXO + keyImages compactKeyImageSet // spent key images — compact sorted slice (32 B/entry vs ~150 B map) + byPubKey map[crypto.Point32]*UTXO // ACTIVE (unspent) UTXOs by OneTimePub for C-0 check + stakedUTXOs map[UTXOKey]*UTXO // UTXOs burned for staking (C-1 fix) — stores data for rollback + spentPubKeys map[crypto.Point32]*UTXO // Phase 2: spent UTXOs removed from byPubKey; used as safe ring decoys + rollbackJournal map[uint64][]rollbackEntry // height → UTXOs spent at that height (for RollbackBlock) + ringMembers RingMemberStore // OnUTXOSpent, when non-nil, is called from ApplyBlock each time the // real spending input for a ring transaction is identified and removed @@ -301,13 +295,6 @@ func NewUTXOSetWithDB(kiDB KeyImageStore) *UTXOSet { return s } -// SetCLSAGActivationHeight enables bounded post-v5 output caching. -func (s *UTXOSet) SetCLSAGActivationHeight(height uint64) { - s.mu.Lock() - defer s.mu.Unlock() - s.clsagActivationHeight = height -} - // Add inserts a new UTXO into the set. func (s *UTXOSet) Add(u *UTXO) { s.mu.Lock() @@ -323,7 +310,6 @@ func (s *UTXOSet) Add(u *UTXO) { if !isProtocolLockedUTXO(u) { s.byPubKey[u.OneTimePub] = u } - s.trackCLSAGRecentLocked(key, u.BlockHeight) } func cloneUTXO(u *UTXO) *UTXO { @@ -373,25 +359,6 @@ func (s *UTXOSet) replaceUTXOReferenceLocked(old, replacement *UTXO) { } } -func (s *UTXOSet) trackCLSAGRecentLocked(key UTXOKey, height uint64) { - if isProtocolLockedUTXO(s.utxos[key]) { - return - } - if s.clsagActivationHeight == 0 || height < s.clsagActivationHeight { - return - } - s.clsagRecentOutputs = append(s.clsagRecentOutputs, key) - if len(s.clsagRecentOutputs) <= maxCLSAGRecentOutputs { - return - } - oldest := s.clsagRecentOutputs[0] - s.clsagRecentOutputs = s.clsagRecentOutputs[1:] - if old := s.utxos[oldest]; old != nil { - delete(s.byPubKey, old.OneTimePub) - delete(s.utxos, oldest) - } -} - // Get retrieves a copy of a UTXO by its key. Returns nil if not found. func (s *UTXOSet) Get(txHash crypto.Hash32, outIdx uint32) *UTXO { s.mu.RLock() @@ -824,7 +791,6 @@ func (s *UTXOSet) applyTxsLocked(block *Block) { if !u.ProtocolLocked { s.byPubKey[out.OneTimePub] = u } - s.trackCLSAGRecentLocked(key, block.Header.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..c4668a2 100644 --- a/core/wallet_scanner.go +++ b/core/wallet_scanner.go @@ -1,6 +1,9 @@ package core import ( + "encoding/binary" + "fmt" + "github.com/aperod/aperod/crypto" ) @@ -17,6 +20,8 @@ type OwnedUTXO struct { // WalletScanner scans blocks for outputs belonging to a wallet. type WalletScanner struct { viewPriv crypto.Scalar32 + spendPriv crypto.Scalar32 + hasSpendKey bool spendPub crypto.Point32 viewPub crypto.Point32 networkByte crypto.NetworkByte @@ -32,9 +37,26 @@ func NewWalletScanner(viewPriv crypto.Scalar32, spendPub, viewPub crypto.Point32 } } +// NewWalletScannerWithSpendKey creates a scanner that can recover version-13+ +// encrypted amounts, which are encrypted to the output's one-time spend key. +func NewWalletScannerWithSpendKey(spendPriv, viewPriv crypto.Scalar32, spendPub, viewPub crypto.Point32, net crypto.NetworkByte) *WalletScanner { + s := NewWalletScanner(viewPriv, spendPub, viewPub, net) + s.spendPriv = spendPriv + s.hasSpendKey = true + return s +} + // ScanBlock scans a block for outputs belonging to this wallet. // Returns a list of owned UTXOs (may be empty if none belong to us). func (s *WalletScanner) ScanBlock(block *Block) []OwnedUTXO { + owned, _ := s.ScanBlockVerified(block) + return owned +} + +// ScanBlockVerified scans outputs and returns an error if the scanner cannot +// authenticate a version-13 amount. View-only scanners intentionally cannot +// claim an amount for new-format outputs. +func (s *WalletScanner) ScanBlockVerified(block *Block) ([]OwnedUTXO, error) { var owned []OwnedUTXO txHash := func(tx Transaction) crypto.Hash32 { return tx.Hash() } @@ -51,7 +73,35 @@ func (s *WalletScanner) ScanBlock(block *Block) []OwnedUTXO { continue // not ours } - amount := decryptAmount(out.EncAmount, hs) + var amount uint64 + if tx.UsesAmountBinding() { + if !s.hasSpendKey { + return nil, fmt.Errorf("transaction %x output %d requires a spend key to read its bound amount", + hash[:8], i) + } + valid, verifyErr := crypto.VerifyAmountBinding(out.AmountCommit, out.OneTimePub, out.AmountBinding) + if verifyErr != nil { + return nil, fmt.Errorf("transaction %x output %d amount-binding proof: %w", + hash[:8], i, verifyErr) + } + if !valid { + return nil, fmt.Errorf("transaction %x output %d has an invalid amount-binding proof", + hash[:8], i) + } + oneTimePriv, keyErr := crypto.AddScalars(*hs, s.spendPriv) + if keyErr != nil { + return nil, fmt.Errorf("transaction %x output %d one-time private key: %w", + hash[:8], i, keyErr) + } + amount, verifyErr = crypto.DecryptAmountBinding(out.AmountBinding, oneTimePriv) + crypto.ZeroBytes(oneTimePriv[:]) + if verifyErr != nil { + return nil, fmt.Errorf("transaction %x output %d bound amount: %w", + hash[:8], i, verifyErr) + } + } else { + amount = decryptAmount(out.EncAmount, hs) + } owned = append(owned, OwnedUTXO{ UTXO: UTXO{ @@ -68,21 +118,31 @@ func (s *WalletScanner) ScanBlock(block *Block) []OwnedUTXO { }) } } - return owned + return owned, nil } // ScanChain scans all blocks in a range for owned UTXOs. // chain.GetByHeight is called for each height from startHeight to endHeight inclusive. func (s *WalletScanner) ScanChain(chain *Chain, startHeight, endHeight uint64) []OwnedUTXO { + all, _ := s.ScanChainVerified(chain, startHeight, endHeight) + return all +} + +// ScanChainVerified scans a range while preserving amount-proof failures. +func (s *WalletScanner) ScanChainVerified(chain *Chain, startHeight, endHeight uint64) ([]OwnedUTXO, error) { var all []OwnedUTXO for h := startHeight; h <= endHeight; h++ { block := chain.GetByHeight(h) if block == nil { break } - all = append(all, s.ScanBlock(block)...) + owned, err := s.ScanBlockVerified(block) + if err != nil { + return nil, err + } + all = append(all, owned...) } - return all + return all, nil } // Balance sums the amounts of all owned UTXOs. @@ -120,9 +180,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/amount_binding.go b/crypto/amount_binding.go new file mode 100644 index 0000000..3137a78 --- /dev/null +++ b/crypto/amount_binding.go @@ -0,0 +1,336 @@ +package crypto + +import ( + "crypto/sha512" + "fmt" + "sync" + + "filippo.io/edwards25519" +) + +const AmountCipherChunkCount = 4 +const amountCipherChunkBits = 16 + +// AmountCipherChunk encrypts one 16-bit digit under the output's one-time +// public key. Its range proof and equality proof bind the ciphertext to the +// corresponding Pedersen commitment. +type AmountCipherChunk struct { + Commitment Commitment + R Point32 + E Point32 + RangeProof *RangeProof + LinkProof AmountLinkProof +} + +// AmountBinding is the version-13/14 output amount encoding. The weighted chunk +// commitments must sum to the enclosing output commitment. +type AmountBinding struct { + Chunks [AmountCipherChunkCount]AmountCipherChunk +} + +// AmountLinkProof is a Fiat-Shamir proof that one value opens Commitment and +// is also encrypted in (R,E) under the output one-time public key. +type AmountLinkProof struct { + TCommit Point32 + TCipher Point32 + TRandom Point32 + ZBlind [32]byte + ZValue [32]byte + ZRandom [32]byte +} + +// CreateAmountBinding creates four independently blinded 16-bit commitments, +// range proofs, and ElGamal ciphertexts. The weighted chunk blind sum matches +// outputBlind, so the aggregate commitment is identical to the legacy output +// commitment and remains compatible with RingCT balance verification. +func CreateAmountBinding(value uint64, outputBlind BlindFactor, outputPub Point32) (*AmountBinding, Commitment, error) { + pub, err := PointFromBytes(outputPub[:]) + if err != nil || pub.Equal(edwards25519.NewIdentityPoint()) == 1 { + return nil, Commitment{}, fmt.Errorf("amount binding: invalid output public key") + } + targetBlind, err := ScalarFromBytes(outputBlind[:]) + if err != nil { + return nil, Commitment{}, fmt.Errorf("amount binding: invalid output blind: %w", err) + } + + var blinds [AmountCipherChunkCount]BlindFactor + weightedBlind := edwards25519.NewScalar() + for i := 0; i < AmountCipherChunkCount-1; i++ { + blinds[i], err = NewBlindFactor() + if err != nil { + return nil, Commitment{}, fmt.Errorf("amount binding blind %d: %w", i, err) + } + bi, parseErr := ScalarFromBytes(blinds[i][:]) + if parseErr != nil { + return nil, Commitment{}, parseErr + } + weightedBlind.MultiplyAdd(amountChunkWeight(i), bi, weightedBlind) + } + lastWeight := amountChunkWeight(AmountCipherChunkCount - 1) + lastBlind := edwards25519.NewScalar().Multiply( + edwards25519.NewScalar().Subtract(targetBlind, weightedBlind), + edwards25519.NewScalar().Invert(lastWeight), + ) + copy(blinds[AmountCipherChunkCount-1][:], lastBlind.Bytes()) + + binding := &AmountBinding{} + aggregate := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) + for i := range binding.Chunks { + digit := (value >> (amountCipherChunkBits * i)) & ((1 << amountCipherChunkBits) - 1) + commit, commitErr := Commit(digit, blinds[i]) + if commitErr != nil { + return nil, Commitment{}, fmt.Errorf("amount binding commitment %d: %w", i, commitErr) + } + rangeProof, proofErr := ProveRangeBits(digit, blinds[i], amountCipherChunkBits) + if proofErr != nil { + return nil, Commitment{}, fmt.Errorf("amount binding range proof %d: %w", i, proofErr) + } + + nonce, nonceErr := randomScalar() + if nonceErr != nil { + return nil, Commitment{}, fmt.Errorf("amount binding encryption nonce %d: %w", i, nonceErr) + } + randomPoint := (&edwards25519.Point{}).ScalarBaseMult(nonce) + valuePoint := (&edwards25519.Point{}).ScalarBaseMult(scalarFromUint64(digit)) + cipherPoint := new(edwards25519.Point).Add(valuePoint, new(edwards25519.Point).ScalarMult(nonce, pub)) + + chunk := AmountCipherChunk{Commitment: commit, RangeProof: rangeProof} + copy(chunk.R[:], randomPoint.Bytes()) + copy(chunk.E[:], cipherPoint.Bytes()) + link, linkErr := proveAmountLink(outputPub, commit, chunk.R, chunk.E, blinds[i], digit, nonce) + nonce.Set(edwards25519.NewScalar()) + if linkErr != nil { + return nil, Commitment{}, fmt.Errorf("amount binding link proof %d: %w", i, linkErr) + } + chunk.LinkProof = link + binding.Chunks[i] = chunk + + commitPoint, parseErr := PointFromBytes(commit[:]) + if parseErr != nil { + return nil, Commitment{}, fmt.Errorf("amount binding commitment point %d: %w", i, parseErr) + } + aggregate.Add(aggregate, new(edwards25519.Point).ScalarMult(amountChunkWeight(i), commitPoint)) + } + var aggregateCommitment Commitment + copy(aggregateCommitment[:], aggregate.Bytes()) + return binding, aggregateCommitment, nil +} + +// VerifyAmountBinding verifies chunk ranges, ciphertext/commitment equality, +// and the weighted sum against the output's canonical Pedersen commitment. +func VerifyAmountBinding(outputCommit Commitment, outputPub Point32, binding *AmountBinding) (bool, error) { + if binding == nil { + return false, fmt.Errorf("nil amount binding") + } + pub, err := PointFromBytes(outputPub[:]) + if err != nil || pub.Equal(edwards25519.NewIdentityPoint()) == 1 { + return false, fmt.Errorf("invalid amount-binding output public key") + } + aggregate := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) + for i := range binding.Chunks { + chunk := &binding.Chunks[i] + ok, proofErr := VerifyRange(chunk.RangeProof) + if proofErr != nil { + return false, fmt.Errorf("amount-binding chunk %d range proof: %w", i, proofErr) + } + if !ok || chunk.RangeProof.Bits != amountCipherChunkBits || + chunk.RangeProof.ValueCommit != chunk.Commitment { + return false, nil + } + ok, proofErr = verifyAmountLink(outputPub, chunk.Commitment, chunk.R, chunk.E, chunk.LinkProof) + if proofErr != nil { + return false, fmt.Errorf("amount-binding chunk %d link proof: %w", i, proofErr) + } + if !ok { + return false, nil + } + commitPoint, parseErr := PointFromBytes(chunk.Commitment[:]) + if parseErr != nil { + return false, fmt.Errorf("amount-binding chunk %d commitment: %w", i, parseErr) + } + aggregate.Add(aggregate, new(edwards25519.Point).ScalarMult(amountChunkWeight(i), commitPoint)) + } + target, err := PointFromBytes(outputCommit[:]) + if err != nil { + return false, fmt.Errorf("invalid output commitment: %w", err) + } + return aggregate.Equal(target) == 1, nil +} + +// DecryptAmountBinding decrypts the bound amount using the one-time private +// spend scalar. A bounded 16-bit discrete-log table recovers each digit. +func DecryptAmountBinding(binding *AmountBinding, oneTimePriv Scalar32) (uint64, error) { + if binding == nil { + return 0, fmt.Errorf("nil amount binding") + } + x, err := ScalarFromBytes(oneTimePriv[:]) + if err != nil { + return 0, fmt.Errorf("invalid one-time private key: %w", err) + } + table := amountDigitTable() + var value uint64 + for i := range binding.Chunks { + chunk := &binding.Chunks[i] + r, parseErr := PointFromBytes(chunk.R[:]) + if parseErr != nil || r.Equal(edwards25519.NewIdentityPoint()) == 1 { + return 0, fmt.Errorf("invalid amount ciphertext R at chunk %d", i) + } + e, parseErr := PointFromBytes(chunk.E[:]) + if parseErr != nil { + return 0, fmt.Errorf("invalid amount ciphertext E at chunk %d: %w", i, parseErr) + } + plain := new(edwards25519.Point).Subtract(e, new(edwards25519.Point).ScalarMult(x, r)) + digit, found := table[[32]byte(plain.Bytes())] + if !found { + return 0, fmt.Errorf("amount ciphertext chunk %d is outside 16-bit range", i) + } + value |= uint64(digit) << (amountCipherChunkBits * i) + } + return value, nil +} + +func proveAmountLink(pub Point32, commitment Commitment, rPoint, ePoint Point32, + blind BlindFactor, value uint64, nonce *edwards25519.Scalar, +) (AmountLinkProof, error) { + b, err := ScalarFromBytes(blind[:]) + if err != nil { + return AmountLinkProof{}, err + } + alphaBlind, err := randomScalar() + if err != nil { + return AmountLinkProof{}, err + } + alphaValue, err := randomScalar() + if err != nil { + return AmountLinkProof{}, err + } + alphaRandom, err := randomScalar() + if err != nil { + return AmountLinkProof{}, err + } + encryptionKey, err := PointFromBytes(pub[:]) + if err != nil { + return AmountLinkProof{}, fmt.Errorf("invalid output public key: %w", err) + } + t1 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(alphaBlind), + new(edwards25519.Point).ScalarMult(alphaValue, hGenerator), + ) + t2 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(alphaValue), + new(edwards25519.Point).ScalarMult(alphaRandom, encryptionKey), + ) + t3 := new(edwards25519.Point).ScalarBaseMult(alphaRandom) + var proof AmountLinkProof + copy(proof.TCommit[:], t1.Bytes()) + copy(proof.TCipher[:], t2.Bytes()) + copy(proof.TRandom[:], t3.Bytes()) + challenge := amountLinkChallenge(pub, commitment, rPoint, ePoint, proof.TCommit, proof.TCipher, proof.TRandom) + zBlind := edwards25519.NewScalar().MultiplyAdd(challenge, b, alphaBlind) + zValue := edwards25519.NewScalar().MultiplyAdd(challenge, scalarFromUint64(value), alphaValue) + zRandom := edwards25519.NewScalar().MultiplyAdd(challenge, nonce, alphaRandom) + copy(proof.ZBlind[:], zBlind.Bytes()) + copy(proof.ZValue[:], zValue.Bytes()) + copy(proof.ZRandom[:], zRandom.Bytes()) + alphaBlind.Set(edwards25519.NewScalar()) + alphaValue.Set(edwards25519.NewScalar()) + alphaRandom.Set(edwards25519.NewScalar()) + return proof, nil +} + +func verifyAmountLink(pub Point32, commitment Commitment, rPoint, ePoint Point32, proof AmountLinkProof) (bool, error) { + p, err := PointFromBytes(pub[:]) + if err != nil || p.Equal(edwards25519.NewIdentityPoint()) == 1 { + return false, fmt.Errorf("invalid encryption key") + } + c, err := PointFromBytes(commitment[:]) + if err != nil { + return false, fmt.Errorf("invalid chunk commitment: %w", err) + } + r, err := PointFromBytes(rPoint[:]) + if err != nil || r.Equal(edwards25519.NewIdentityPoint()) == 1 { + return false, fmt.Errorf("invalid ciphertext R") + } + e, err := PointFromBytes(ePoint[:]) + if err != nil { + return false, fmt.Errorf("invalid ciphertext E: %w", err) + } + t1, err := PointFromBytes(proof.TCommit[:]) + if err != nil { + return false, fmt.Errorf("invalid link commitment: %w", err) + } + t2, err := PointFromBytes(proof.TCipher[:]) + if err != nil { + return false, fmt.Errorf("invalid link ciphertext commitment: %w", err) + } + t3, err := PointFromBytes(proof.TRandom[:]) + if err != nil { + return false, fmt.Errorf("invalid link randomness commitment: %w", err) + } + zb, err := ScalarFromBytes(proof.ZBlind[:]) + if err != nil { + return false, fmt.Errorf("invalid blind response: %w", err) + } + zv, err := ScalarFromBytes(proof.ZValue[:]) + if err != nil { + return false, fmt.Errorf("invalid value response: %w", err) + } + zr, err := ScalarFromBytes(proof.ZRandom[:]) + if err != nil { + return false, fmt.Errorf("invalid encryption response: %w", err) + } + challenge := amountLinkChallenge(pub, commitment, rPoint, ePoint, proof.TCommit, proof.TCipher, proof.TRandom) + check1 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(zb), + new(edwards25519.Point).ScalarMult(zv, hGenerator), + ) + expected1 := new(edwards25519.Point).Add(t1, new(edwards25519.Point).ScalarMult(challenge, c)) + check2 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(zv), + new(edwards25519.Point).ScalarMult(zr, p), + ) + expected2 := new(edwards25519.Point).Add(t2, new(edwards25519.Point).ScalarMult(challenge, e)) + check3 := new(edwards25519.Point).ScalarBaseMult(zr) + expected3 := new(edwards25519.Point).Add(t3, new(edwards25519.Point).ScalarMult(challenge, r)) + return check1.Equal(expected1) == 1 && check2.Equal(expected2) == 1 && check3.Equal(expected3) == 1, nil +} + +func amountLinkChallenge(pub Point32, commitment Commitment, rPoint, ePoint, t1, t2, t3 Point32) *edwards25519.Scalar { + h := sha512.New() + h.Write([]byte("Aperod/RingCT/amount-binding/v1")) + h.Write(pub[:]) + h.Write(commitment[:]) + h.Write(rPoint[:]) + h.Write(ePoint[:]) + h.Write(t1[:]) + h.Write(t2[:]) + h.Write(t3[:]) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) + return s +} + +func amountChunkWeight(i int) *edwards25519.Scalar { + return scalarFromUint64(uint64(1) << (amountCipherChunkBits * i)) +} + +func amountDigitTable() map[[32]byte]uint16 { + amountDigitTableOnce.Do(func() { + amountDigitTableValue = make(map[[32]byte]uint16, 1<= 1<<16 { + return nil, fmt.Errorf("bulletproof: value %d exceeds %d-bit range", value, bits) + } + n := int(bits) + rounds := 0 + for x := n; x > 1; x >>= 1 { + rounds++ + } // 1. Value commitment V = γ·G + v·BpH ──────────────────────────────── v := scalarFromUint64(value) // H-2 fix: clampScalar fallback removed — Ed25519 clamping has no meaning @@ -256,40 +281,64 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { ) // 2. Bit decomposition: aL[i] = bit i of v, aR[i] = aL[i] - 1 ──────── - aL := make([]*edwards25519.Scalar, bpBits) - aR := make([]*edwards25519.Scalar, bpBits) + aL := make([]*edwards25519.Scalar, n) + aR := make([]*edwards25519.Scalar, n) one := scalarOne() - for i := 0; i < bpBits; i++ { + for i := 0; i < n; i++ { aL[i] = scalarFromUint64((value >> uint(i)) & 1) aR[i] = edwards25519.NewScalar().Subtract(aL[i], one) } // 3. Commit to aL, aR: A = ⟨aL,Gvec⟩ + ⟨aR,Hvec⟩ + α·G ───────────── - alphaBF, _ := NewBlindFactor() - alpha, _ := ScalarFromBytes(alphaBF[:]) - gv := pointSlice(bpGvec) - hv := pointSlice(bpHvec) + alphaBF, err := NewBlindFactor() + if err != nil { + return nil, fmt.Errorf("bulletproof alpha: %w", err) + } + alpha, err := ScalarFromBytes(alphaBF[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof alpha scalar: %w", err) + } + gv := pointSliceN(bpGvec, n) + hv := pointSliceN(bpHvec, n) A := new(edwards25519.Point).ScalarBaseMult(alpha) A.Add(A, msm(aL, gv)) A.Add(A, msm(aR, hv)) // 4. Blinding vectors sL, sR; S = ⟨sL,Gvec⟩ + ⟨sR,Hvec⟩ + ρ·G ────── - sL := make([]*edwards25519.Scalar, bpBits) - sR := make([]*edwards25519.Scalar, bpBits) - for i := 0; i < bpBits; i++ { - bf, _ := NewBlindFactor() - sL[i], _ = ScalarFromBytes(bf[:]) - bf, _ = NewBlindFactor() - sR[i], _ = ScalarFromBytes(bf[:]) + sL := make([]*edwards25519.Scalar, n) + sR := make([]*edwards25519.Scalar, n) + for i := 0; i < n; i++ { + bf, randomErr := NewBlindFactor() + if randomErr != nil { + return nil, fmt.Errorf("bulletproof sL[%d]: %w", i, randomErr) + } + sL[i], err = ScalarFromBytes(bf[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof sL[%d] scalar: %w", i, err) + } + bf, randomErr = NewBlindFactor() + if randomErr != nil { + return nil, fmt.Errorf("bulletproof sR[%d]: %w", i, randomErr) + } + sR[i], err = ScalarFromBytes(bf[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof sR[%d] scalar: %w", i, err) + } + } + rhoBF, err := NewBlindFactor() + if err != nil { + return nil, fmt.Errorf("bulletproof rho: %w", err) + } + rho, err := ScalarFromBytes(rhoBF[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof rho scalar: %w", err) } - rhoBF, _ := NewBlindFactor() - rho, _ := ScalarFromBytes(rhoBF[:]) S := new(edwards25519.Point).ScalarBaseMult(rho) S.Add(S, msm(sL, gv)) S.Add(S, msm(sR, hv)) // 5. Challenges y, z ─────────────────────────────────────────────────── - tr := newBPTranscript("range-proof/v2") + tr := newBPTranscript(rangeProofTranscriptLabel(bits)) tr.absorbPoint(V) tr.absorbPoint(A) tr.absorbPoint(S) @@ -300,8 +349,8 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { // l(x) = (aL - z) + sL·x l₀ = aL-z, l₁ = sL // r(x) = yn⊙(aR + z) + z²·2ⁿ + yn⊙sR·x // r₀ = yn⊙(aR+z)+z²·2ⁿ, r₁ = yn⊙sR - yn := powerVec(y, bpBits) - town := pow2Vec(bpBits) + yn := powerVec(y, n) + town := pow2Vec(n) z2 := edwards25519.NewScalar().Multiply(z, z) l0 := vecSubS(aL, z) @@ -315,10 +364,22 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { t2v := innerProduct(l1, r1) // 7. Commit to t₁, t₂: Tᵢ = τᵢ·G + tᵢ·BpH ──────────────────────────── - tau1BF, _ := NewBlindFactor() - tau1, _ := ScalarFromBytes(tau1BF[:]) - tau2BF, _ := NewBlindFactor() - tau2, _ := ScalarFromBytes(tau2BF[:]) + tau1BF, err := NewBlindFactor() + if err != nil { + return nil, fmt.Errorf("bulletproof tau1: %w", err) + } + tau1, err := ScalarFromBytes(tau1BF[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof tau1 scalar: %w", err) + } + tau2BF, err := NewBlindFactor() + if err != nil { + return nil, fmt.Errorf("bulletproof tau2: %w", err) + } + tau2, err := ScalarFromBytes(tau2BF[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof tau2 scalar: %w", err) + } T1 := new(edwards25519.Point).Add( new(edwards25519.Point).ScalarBaseMult(tau1), new(edwards25519.Point).ScalarMult(t1v, BpH), @@ -356,13 +417,13 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { Uw := new(edwards25519.Point).ScalarMult(w, bpU) // H' = y⁻ⁱ · Hvec[i] (modified generators for IPA) - yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) - hPrime := make([]*edwards25519.Point, bpBits) - for i := 0; i < bpBits; i++ { + yInvPow := powerVec(edwards25519.NewScalar().Invert(y), n) + hPrime := make([]*edwards25519.Point, n) + for i := 0; i < n; i++ { hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) } - Gipa := pointSlice(bpGvec) + Gipa := pointSliceN(bpGvec, n) Ls, Rs, aFin, bFin, err := ipaProve(Gipa, hPrime, l, r, Uw, tr) if err != nil { return nil, fmt.Errorf("bulletproof IPA: %w", err) @@ -370,6 +431,9 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { // 11. Assemble proof ─────────────────────────────────────────────────── proof := &RangeProof{} + if bits != 64 { + proof.Bits = bits + } copy(proof.V[:], V.Bytes()) copy(proof.A[:], A.Bytes()) copy(proof.S[:], S.Bytes()) @@ -378,7 +442,7 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { copy(proof.Taux[:], taux.Bytes()) copy(proof.Mu[:], mu.Bytes()) copy(proof.Tx[:], txHat.Bytes()) - for k := 0; k < bpLog; k++ { + for k := 0; k < rounds; k++ { copy(proof.Ls[k][:], Ls[k].Bytes()) copy(proof.Rs[k][:], Rs[k].Bytes()) } @@ -402,6 +466,23 @@ func VerifyRange(proof *RangeProof) (bool, error) { if [32]byte(proof.ValueCommit) != proof.V { return false, nil } + bits := proof.Bits + if bits == 0 { + bits = 64 + } + if bits != 16 && bits != 64 { + return false, fmt.Errorf("unsupported range proof width %d", bits) + } + n := int(bits) + rounds := 0 + for x := n; x > 1; x >>= 1 { + rounds++ + } + for i := rounds; i < bpLog; i++ { + if !isZero32(proof.Ls[i]) || !isZero32(proof.Rs[i]) { + return false, fmt.Errorf("non-canonical unused IPA round %d", i) + } + } // Decode proof elements (all must be valid encodings) decPt := func(b [32]byte, name string) (*edwards25519.Point, error) { @@ -460,9 +541,9 @@ func VerifyRange(proof *RangeProof) (bool, error) { return false, err } - Ls := make([]*edwards25519.Point, bpLog) - Rs := make([]*edwards25519.Point, bpLog) - for k := 0; k < bpLog; k++ { + Ls := make([]*edwards25519.Point, rounds) + Rs := make([]*edwards25519.Point, rounds) + for k := 0; k < rounds; k++ { Ls[k], err = decPt(proof.Ls[k], fmt.Sprintf("L%d", k)) if err != nil { return false, err @@ -474,7 +555,7 @@ func VerifyRange(proof *RangeProof) (bool, error) { } // ── Replay transcript to get challenges ─────────────────────────────── - tr := newBPTranscript("range-proof/v2") + tr := newBPTranscript(rangeProofTranscriptLabel(bits)) tr.absorbPoint(V) tr.absorbPoint(A) tr.absorbPoint(S) @@ -491,8 +572,8 @@ func VerifyRange(proof *RangeProof) (bool, error) { z2 := edwards25519.NewScalar().Multiply(z, z) z3 := edwards25519.NewScalar().Multiply(z2, z) - yn := powerVec(y, bpBits) - town := pow2Vec(bpBits) + yn := powerVec(y, n) + town := pow2Vec(n) // ── Check 1: polynomial commitment ──────────────────────────────────── // t̂·BpH + τₓ·G == z²·V + δ(y,z)·BpH + x·T₁ + x²·T₂ @@ -528,9 +609,9 @@ func VerifyRange(proof *RangeProof) (bool, error) { // (derived by expanding commitments A and S; the μ·G terms cancel) Uw := new(edwards25519.Point).ScalarMult(w, bpU) - yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) - hPrime := make([]*edwards25519.Point, bpBits) - for i := 0; i < bpBits; i++ { + yInvPow := powerVec(edwards25519.NewScalar().Invert(y), n) + hPrime := make([]*edwards25519.Point, n) + for i := 0; i < n; i++ { hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) } @@ -544,7 +625,7 @@ func VerifyRange(proof *RangeProof) (bool, error) { P.Add(P, new(edwards25519.Point).ScalarBaseMult(edwards25519.NewScalar().Negate(mu))) P.Add(P, new(edwards25519.Point).ScalarMult(txHat, Uw)) negZ := edwards25519.NewScalar().Negate(z) - for i := 0; i < bpBits; i++ { + for i := 0; i < n; i++ { P.Add(P, new(edwards25519.Point).ScalarMult(negZ, bpGvec[i])) P.Add(P, new(edwards25519.Point).ScalarMult(z, bpHvec[i])) coeff := edwards25519.NewScalar().Multiply(z2, town[i]) @@ -555,7 +636,7 @@ func VerifyRange(proof *RangeProof) (bool, error) { // Replay IPA challenges and fold P: P_{k+1} = P_k + u_k·L_k + u_k⁻¹·R_k ipaU := [bpLog]*edwards25519.Scalar{} ipaUInv := [bpLog]*edwards25519.Scalar{} - for k := 0; k < bpLog; k++ { + for k := 0; k < rounds; k++ { tr.absorbPoint(Ls[k]) tr.absorbPoint(Rs[k]) ipaU[k] = tr.challenge() @@ -567,10 +648,10 @@ func VerifyRange(proof *RangeProof) (bool, error) { // P is now P_final // Fold generators using the same challenges - Gfin := pointSlice(bpGvec) - Hfin := make([]*edwards25519.Point, bpBits) + Gfin := pointSliceN(bpGvec, n) + Hfin := make([]*edwards25519.Point, n) copy(Hfin, hPrime) - for k := 0; k < bpLog; k++ { + for k := 0; k < rounds; k++ { half := len(Gfin) / 2 uInv := ipaUInv[k] uk := ipaU[k] @@ -602,6 +683,13 @@ func VerifyRange(proof *RangeProof) (bool, error) { return check.Equal(P) == 1, nil } +func rangeProofTranscriptLabel(bits uint8) string { + if bits == 64 { + return "range-proof/v2" + } + return fmt.Sprintf("range-proof/v3/bits-%d", bits) +} + // ─── Inner Product Argument (IPA) ───────────────────────────────────────────── // ipaProve recursively proves ⟨a,b⟩ is consistent with generators G and H. 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/crypto/value_authorization_test.go b/crypto/value_authorization_test.go index fbedd20..ae33960 100644 --- a/crypto/value_authorization_test.go +++ b/crypto/value_authorization_test.go @@ -34,3 +34,29 @@ func TestValueAuthorizationBindsActualDebitWithoutRevealingBlind(t *testing.T) { t.Fatal("noncanonical commitment accepted") } } + +func TestValidateAuthorizedOutputPoints(t *testing.T) { + var spendScalar, ephemeralScalar Scalar32 + spendScalar[0] = 1 + ephemeralScalar[0] = 2 + + publicKey, err := PublicKeyFromPrivate(spendScalar) + if err != nil { + t.Fatal(err) + } + ephemeralKey, err := PublicKeyFromPrivate(ephemeralScalar) + if err != nil { + t.Fatal(err) + } + if err := ValidateAuthorizedOutputPoints(publicKey, ephemeralKey); err != nil { + t.Fatalf("valid output points rejected: %v", err) + } + + var smallOrder Point32 + if err := ValidateAuthorizedOutputPoints(smallOrder, ephemeralKey); err == nil { + t.Fatal("small-order recipient output accepted") + } + if err := ValidateAuthorizedOutputPoints(publicKey, smallOrder); err == nil { + t.Fatal("small-order ephemeral output accepted") + } +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index bdf476a..fb35240 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: Build ──────────────────────────────────────────────────────────── -FROM golang:1.25-alpine AS builder +FROM golang:1.26.11-alpine AS builder RUN apk add --no-cache git make gcc musl-dev diff --git a/deploy/INVESTORS.md b/deploy/INVESTORS.md index 62464d5..b3b12a2 100644 --- a/deploy/INVESTORS.md +++ b/deploy/INVESTORS.md @@ -150,14 +150,9 @@ forecasts, return projections or guarantees. ### Quick-start (Linux / Ubuntu 22.04+) -```bash -# Install and configure the validator service interactively. -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash - -# Verify the service and local REST API. -systemctl status aperod-node -curl -s http://127.0.0.1:8545/api/v1/status -``` +Use the pinned, reviewed installation procedure in +[VALIDATORS.md — Setup Guide](VALIDATORS.md#setup-guide). Do not run an installer +from the mutable `main` branch or pipe a downloaded script to `sudo bash`. See [install-validator.sh](install-validator.sh) and [install-node.sh](install-node.sh) for full automation scripts. diff --git a/deploy/SECURITY.md b/deploy/SECURITY.md index 54ebc26..1c1914e 100644 --- a/deploy/SECURITY.md +++ b/deploy/SECURITY.md @@ -7,11 +7,10 @@ | Latest `main` | ✅ Active | | Older tags | ❌ No patches | -Always run the latest release: - -```bash -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-node.sh | sudo bash -``` +Use only an installer from a signed, immutable release after independently +verifying its source revision and checksum. Do not fetch scripts from mutable +`main` and pipe them to a root shell. No signed installer release is currently +published. --- 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/VALIDATORS.md b/deploy/VALIDATORS.md index b0f5c86..06e5ca6 100644 --- a/deploy/VALIDATORS.md +++ b/deploy/VALIDATORS.md @@ -168,14 +168,36 @@ Withdrawals cannot be cancelled once initiated. Complete installation instructions are in the main [README](README.md). -```bash -# Install validator node (interactive) -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash +Use only a full commit SHA published for a reviewed release. Do not run an +installer fetched from the mutable `main` branch, and never pipe a downloaded +script directly to `sudo bash`. -# Uninstall -curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/uninstall-validator.sh | sudo bash +```bash +# Replace this with the full SHA from the reviewed release record. +APEROD_NODE_SOURCE_COMMIT="" +if [[ ! "$APEROD_NODE_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "A reviewed, full 40-character commit SHA is required." >&2 + exit 1 +fi + +git clone --no-checkout https://github.com/aperod-network/aperod-node.git aperod-node +cd aperod-node +git fetch --depth=1 origin "$APEROD_NODE_SOURCE_COMMIT" +git cat-file -e "${APEROD_NODE_SOURCE_COMMIT}^{commit}" +git checkout --detach "$APEROD_NODE_SOURCE_COMMIT" +test "$(git rev-parse HEAD)" = "$APEROD_NODE_SOURCE_COMMIT" +test -z "$(git status --porcelain)" + +# Review the checked-out installer before running it as root. +sudo env APEROD_NODE_SOURCE_COMMIT="$APEROD_NODE_SOURCE_COMMIT" bash deploy/install-validator.sh + +# Review the checked-out uninstaller before this destructive operation. +sudo bash deploy/uninstall-validator.sh ``` +If no reviewed release record supplies the exact commit SHA, do not install or +uninstall from this guide. + **Key file locations after install:** | File | Path | diff --git a/deploy/aperod-join.sh b/deploy/aperod-join.sh index 85d83c4..9493ff5 100755 --- a/deploy/aperod-join.sh +++ b/deploy/aperod-join.sh @@ -7,13 +7,7 @@ # sudo bash aperod-join.sh : [OPTIONS] # # Примеры: -# # Простой вариант (dev/тест, без API-ключа): -# sudo bash aperod-join.sh 89.169.53.128:8545 -# -# # С API-ключом (передаётся в открытом виде по HTTP — используйте --tunnel!): -# sudo bash aperod-join.sh 89.169.53.128:8545 --api-key mySecret --tunnel user@primary -# -# # Через SSH-туннель (рекомендуется для prod): +# # Remote sync is only supported through an SSH tunnel with a pinned host key: # sudo bash aperod-join.sh 89.169.53.128:8545 --tunnel aperod@89.169.53.128 # # # Нестандартная директория данных: @@ -28,15 +22,10 @@ # --skip-start Не запускать aperod-node после загрузки # --no-chaindb Пропустить загрузку chain.db (только snapshot) # -# ⚠️ ВАЖНО — безопасность передачи данных: -# Этот скрипт использует plain HTTP для загрузки chain.db и snapshot. -# Если задан --api-key, ключ передаётся в открытом виде. -# В производственной среде настоятельно рекомендуется один из вариантов: -# 1. --tunnel user@primary-host (SSH-туннель, автоматически) -# 2. Настроить HTTPS на основном узле (nginx + TLS) -# 3. Использовать VPN (WireGuard/OpenVPN) -# Без одного из этих вариантов API-ключ и данные цепочки могут быть -# перехвачены на промежуточных узлах сети. +# Безопасность передачи: +# Remote HTTP sync is rejected. Use --tunnel with the primary server's +# SSH host key already pinned in root's known_hosts. Loopback HTTP is +# allowed for local test fixtures only. Never accept a first-use host key. # # Что делает скрипт: # 1. Останавливает aperod-node на ТЕКУЩЕМ сервере @@ -104,6 +93,16 @@ PRIMARY_HOST="${PRIMARY%%:*}" PRIMARY_PORT="${PRIMARY##*:}" [[ "${PRIMARY_PORT}" =~ ^[0-9]+$ ]] || PRIMARY_PORT="8545" +# Chain DB and snapshots are consensus-critical bootstrap data. Do not accept +# them over remote plaintext HTTP, even when no API key is configured. +if [[ -z "${TUNNEL_HOST}" && "${PRIMARY_HOST}" != "127.0.0.1" ]]; then + die "Удалённая загрузка по HTTP запрещена. Используйте --tunnel с заранее закреплённым SSH host key." +fi + +# Joining without a config cannot install the validator's whitelist protection. +# Fail before stopping a service or replacing any existing chain data. +[[ -f "${NODE_YAML}" ]] || die "${NODE_YAML} не найден. Сначала установите ноду через install-node.sh; без node.yaml нельзя настроить peer_whitelist." + # ── Ранняя проверка: --no-chaindb несовместим с safe-mode guard ─────────────── # install-node.sh sets consensus.non_validator: true when no --primary-ip is # given to prevent the node from mining an incompatible genesis block. @@ -174,16 +173,6 @@ if [[ -n "${TUNNEL_HOST}" ]]; then command -v ssh >/dev/null 2>&1 || die "Требуется 'ssh' для --tunnel, но он не установлен" fi -# ── Предупреждение: HTTP + API-ключ ────────────────────── -if [[ -n "${API_KEY}" && -z "${TUNNEL_HOST}" ]]; then - echo -e "${YELLOW}${BOLD}⚠️ ВНИМАНИЕ: API-ключ будет передан по нешифрованному HTTP.${NC}" - echo -e "${YELLOW} Рекомендуем использовать SSH-туннель:${NC}" - echo -e "${YELLOW} ${BOLD}bash aperod-join.sh ${PRIMARY} --api-key ... --tunnel ${DATA_USER}@${PRIMARY_HOST}${NC}" - echo - read -rp "Продолжить без туннеля? [y/N] " CONFIRM - [[ "${CONFIRM,,}" == "y" ]] || die "Прервано. Запустите с --tunnel для безопасной передачи." -fi - # ── Вспомогательная функция: cleanup ───────────────────── cleanup() { if [[ -n "${TUNNEL_PID}" ]]; then @@ -209,7 +198,8 @@ info "Шаг 1/7: Проверяем доступность основного if [[ -n "${TUNNEL_HOST}" ]]; then info " Поднимаем SSH-туннель: ${TUNNEL_HOST}:${PRIMARY_PORT} → localhost:${TUNNEL_LOCAL_PORT}…" - ssh -o StrictHostKeyChecking=accept-new \ + ssh -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile=/root/.ssh/known_hosts \ -o ExitOnForwardFailure=yes \ -o ServerAliveInterval=30 \ -fN \ @@ -345,9 +335,7 @@ BOOTNODE_ADDR="/ip4/${PRIMARY_HOST}/tcp/${PRIMARY_P2P_PORT}" info "Шаг 7/8: Прописываем bootnode ${BOOTNODE_ADDR} в ${NODE_YAML}…" if [[ ! -f "${NODE_YAML}" ]]; then - warn "${NODE_YAML} не найден — пропускаем настройку bootnode" - warn "Прописать вручную после установки:" - warn " bash /opt/aperod/blockchain/deploy/node-config.sh add-bootnode ${BOOTNODE_ADDR}" + die "${NODE_YAML} не найден — bootnode и peer_whitelist не настроены." else # Preferred path: node-config.sh (YAML-safe, idempotent, handles legacy migration). if [[ -x "${NODE_CONFIG_SH}" ]]; then @@ -394,13 +382,47 @@ PY # догоняет цепь и не может принять gossip-блоки основного узла. # peer_whitelist исключает PRIMARY_HOST из счётчика страйков. info "Шаг 7.5/8: Прописываем ${PRIMARY_HOST} в p2p.peer_whitelist…" - python3 - "${NODE_YAML}" "${PRIMARY_HOST}" <<'PY' -import sys, yaml, os + python3 - "${NODE_YAML}" "${PRIMARY_HOST}" "${DATA_DIR}" <<'PY' +import sys, yaml, os, json, ipaddress, stat, subprocess, tempfile cfg_path = sys.argv[1] primary_ip = sys.argv[2] with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} p2p = cfg.setdefault("p2p", {}) +validator_ip = primary_ip +ipaddress.IPv4Address(validator_ip) +# A nonempty runtime sidecar overrides YAML. Merge the joining validator into +# the target's policy too; do not delete existing operator entries or import +# the source host's policy. "-" explicitly disables sidecar persistence. +sidecar = p2p.get("whitelist_file") or os.path.join(cfg.get("data_dir") or sys.argv[3], "p2p_whitelist.json") +if sidecar != "-": + if not os.path.isabs(sidecar): + cwd = subprocess.check_output(["systemctl", "show", "aperod-node", "--property=WorkingDirectory", "--value"], text=True).strip() or "/" + if not os.path.isabs(cwd): + raise ValueError("aperod-node WorkingDirectory must be absolute") + sidecar = os.path.join(cwd, sidecar) + if os.path.lexists(sidecar): + with open(sidecar) as f: + entries = json.load(f) + if not isinstance(entries, list) or any(not isinstance(e, str) for e in entries): + raise ValueError("Invalid whitelist sidecar: expected IP/CIDR array") + for entry in entries: + ipaddress.ip_network(entry, strict=False) + if validator_ip not in entries: + entries.append(validator_ip) + meta = os.stat(sidecar) + fd, tmp_sidecar = tempfile.mkstemp(prefix=".join-whitelist-", dir=os.path.dirname(sidecar)) + try: + with os.fdopen(fd, "w") as f: + os.fchown(f.fileno(), meta.st_uid, meta.st_gid) + os.fchmod(f.fileno(), stat.S_IMODE(meta.st_mode)) + json.dump(entries, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_sidecar, sidecar) + finally: + if os.path.exists(tmp_sidecar): + os.unlink(tmp_sidecar) wl = list(p2p.get("peer_whitelist") or []) if primary_ip not in wl: wl.append(primary_ip) diff --git a/deploy/aperod-morning-report.sh b/deploy/aperod-morning-report.sh index 50f2357..5554746 100644 --- a/deploy/aperod-morning-report.sh +++ b/deploy/aperod-morning-report.sh @@ -3,10 +3,10 @@ # aperod-morning-report.sh — утренний отчёт о работе систем за ночь # # УСТАНОВКА: -# sudo curl -fsSL \ -# "https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/aperod-morning-report.sh" \ -# -o /usr/local/bin/aperod-morning-report.sh \ -# && sudo chmod +x /usr/local/bin/aperod-morning-report.sh +# Сначала получите проверенный checkout по инструкции VALIDATORS.md, затем +# установите скрипт непосредственно из этого checkout: +# sudo install -o root -g root -m 0755 deploy/aperod-morning-report.sh \ +# /usr/local/bin/aperod-morning-report.sh # # CRON (07:00 ежедневно): # echo "0 7 * * * root /usr/local/bin/aperod-morning-report.sh" \ diff --git a/deploy/aperod_backup.sh b/deploy/aperod_backup.sh index 9cbbcaf..f51799e 100644 --- a/deploy/aperod_backup.sh +++ b/deploy/aperod_backup.sh @@ -482,6 +482,17 @@ fi # Telegram alert and exit 0 ("skipped") so Prometheus records skipped_low_disk=1 # rather than a failure, giving admins time to free space before data is lost. MIN_AVAIL_KB=$(( 5 * 1024 * 1024 )) # 5 GiB in kibibytes +DEFAULT_CHECKPOINT_RESERVE_BYTES=$(( MIN_AVAIL_KB * 1024 )) +CHECKPOINT_RESERVE_BYTES="${APEROD_BACKUP_CHECKPOINT_RESERVE_BYTES:-${DEFAULT_CHECKPOINT_RESERVE_BYTES}}" +if [[ ! "$CHECKPOINT_RESERVE_BYTES" =~ ^[0-9]{1,15}$ ]]; then + echo "ОШИБКА: APEROD_BACKUP_CHECKPOINT_RESERVE_BYTES должен быть целым числом байт." + exit 1 +fi +CHECKPOINT_RESERVE_BYTES=$((10#$CHECKPOINT_RESERVE_BYTES)) +if [ "$CHECKPOINT_RESERVE_BYTES" -lt "$DEFAULT_CHECKPOINT_RESERVE_BYTES" ]; then + echo "ОШИБКА: резерв checkpoint не может быть меньше ${DEFAULT_CHECKPOINT_RESERVE_BYTES} байт (5 GiB)." + exit 1 +fi _disk_preflight() { local path="$1" @@ -545,9 +556,11 @@ if [ ! -S "$BACKUP_SOCKET" ]; then fi # The Go exporter copies the active LevelDB into .backup-staging on this same -# filesystem. Require room for the active DB's apparent size plus a 5 GiB -# reserve before asking it to begin; otherwise it could fill the node volume. +# filesystem. Before requesting it, budget the larger of the active DB and the +# largest retained checkpoint, plus the configured reserve. Existing staging +# bytes are already reflected in df availability and are reported separately. CHAIN_DB_DIR="${CHAIN_DATA_DIR}/chain.db" +STAGING_ROOT="${CHAIN_DATA_DIR}/.backup-staging" if [ ! -d "$CHAIN_DB_DIR" ] || [ -L "$CHAIN_DB_DIR" ]; then echo "ОШИБКА: активная LevelDB chain.db отсутствует или является символической ссылкой: ${CHAIN_DB_DIR}" exit 1 @@ -562,12 +575,69 @@ if [[ ! "$CHAIN_DB_KB" =~ ^[0-9]+$ ]] || [[ ! "$CHAIN_AVAIL_KB" =~ ^[0-9]+$ ]]; echo "ОШИБКА: не удалось измерить размер chain.db или свободное место до checkpoint; checkpoint не запрашивался." exit 1 fi -CHAIN_REQUIRED_KB=$(( CHAIN_DB_KB + MIN_AVAIL_KB )) -if [ "$CHAIN_AVAIL_KB" -lt "$CHAIN_REQUIRED_KB" ]; then + +# Existing staging copies are already reflected in df's available-space value. +# Measure them separately for the report, and use the largest retained +# checkpoint as a conservative estimate if it is larger than the live chain.db. +STAGING_USED_KB=0 +RECENT_CHECKPOINT_KB=0 +if [ -L "$STAGING_ROOT" ]; then + echo "ОШИБКА: каталог checkpoint staging является символической ссылкой; checkpoint не запрашивался." + exit 1 +elif [ -e "$STAGING_ROOT" ]; then + if [ ! -d "$STAGING_ROOT" ]; then + echo "ОШИБКА: путь checkpoint staging не является каталогом; checkpoint не запрашивался." + exit 1 + fi + if ! STAGING_USED_KB=$(du -sk --apparent-size -- "$STAGING_ROOT" 2>/dev/null | awk 'NR==1{print $1}'); then + echo "ОШИБКА: не удалось измерить текущий checkpoint staging; checkpoint не запрашивался." + exit 1 + fi + if [[ ! "$STAGING_USED_KB" =~ ^[0-9]+$ ]]; then + echo "ОШИБКА: не удалось измерить текущий checkpoint staging; checkpoint не запрашивался." + exit 1 + fi + CHECKPOINT_STAGE_LIST="${BACKUP_DIR}/checkpoint-stage-list.nul" + if ! find "$STAGING_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'checkpoint-*' \ + -print0 >"$CHECKPOINT_STAGE_LIST" 2>/dev/null; then + echo "ОШИБКА: не удалось перечислить сохраненные checkpoint; новый checkpoint не запрашивался." + exit 1 + fi + mapfile -d '' -t EXISTING_CHECKPOINTS <"$CHECKPOINT_STAGE_LIST" + rm -f -- "$CHECKPOINT_STAGE_LIST" + for existing_checkpoint in "${EXISTING_CHECKPOINTS[@]}"; do + if ! existing_checkpoint_kb=$(du -sk --apparent-size -- "$existing_checkpoint" 2>/dev/null | awk 'NR==1{print $1}'); then + echo "ОШИБКА: не удалось измерить сохраненный checkpoint ${existing_checkpoint}; новый checkpoint не запрашивался." + exit 1 + fi + if [[ ! "$existing_checkpoint_kb" =~ ^[0-9]+$ ]]; then + echo "ОШИБКА: не удалось измерить сохраненный checkpoint ${existing_checkpoint}; новый checkpoint не запрашивался." + exit 1 + fi + if [ "$existing_checkpoint_kb" -gt "$RECENT_CHECKPOINT_KB" ]; then + RECENT_CHECKPOINT_KB="$existing_checkpoint_kb" + fi + done +fi + +ESTIMATED_CHECKPOINT_KB="$CHAIN_DB_KB" +if [ "$RECENT_CHECKPOINT_KB" -gt "$ESTIMATED_CHECKPOINT_KB" ]; then + ESTIMATED_CHECKPOINT_KB="$RECENT_CHECKPOINT_KB" +fi +STAGING_USED_BYTES=$(( STAGING_USED_KB * 1024 )) +ESTIMATED_CHECKPOINT_BYTES=$(( ESTIMATED_CHECKPOINT_KB * 1024 )) +PROJECTED_STAGING_BYTES=$(( STAGING_USED_BYTES + ESTIMATED_CHECKPOINT_BYTES )) +CHAIN_AVAIL_BYTES=$(( CHAIN_AVAIL_KB * 1024 )) +CHAIN_REQUIRED_BYTES=$(( ESTIMATED_CHECKPOINT_BYTES + CHECKPOINT_RESERVE_BYTES )) +if [ "$CHAIN_AVAIL_BYTES" -lt "$CHAIN_REQUIRED_BYTES" ]; then CHAIN_FREE_GB=$(awk "BEGIN{printf \"%.1f\", ${CHAIN_AVAIL_KB}/1024/1024}") - CHAIN_REQUIRED_GB=$(awk "BEGIN{printf \"%.1f\", ${CHAIN_REQUIRED_KB}/1024/1024}") + CHAIN_REQUIRED_GB=$(awk "BEGIN{printf \"%.1f\", ${CHAIN_REQUIRED_BYTES}/1024/1024/1024}") + CHECKPOINT_ESTIMATE_GB=$(awk "BEGIN{printf \"%.1f\", ${ESTIMATED_CHECKPOINT_BYTES}/1024/1024/1024}") + STAGING_USED_GB=$(awk "BEGIN{printf \"%.1f\", ${STAGING_USED_BYTES}/1024/1024/1024}") echo "=== ВНИМАНИЕ: checkpoint пропущен из-за нехватки места на файловой системе ноды ===" - echo " chain.db занимает ${CHAIN_DB_KB} KiB; свободно ${CHAIN_FREE_GB} GiB; требуется ${CHAIN_REQUIRED_GB} GiB (DB + 5 GiB reserve)." + echo " Staging сейчас: ${STAGING_USED_BYTES} байт (${STAGING_USED_GB} GiB); следующий checkpoint: ${ESTIMATED_CHECKPOINT_BYTES} байт (${CHECKPOINT_ESTIMATE_GB} GiB)." + echo " Свободно: ${CHAIN_AVAIL_BYTES} байт (${CHAIN_FREE_GB} GiB); требуется: ${CHAIN_REQUIRED_BYTES} байт, включая резерв ${CHECKPOINT_RESERVE_BYTES} байт." + echo " Прогноз staging после копии: ${PROJECTED_STAGING_BYTES} байт." _BACKUP_FINAL_STATUS="skipped" _BACKUP_SKIP_REASON="low_checkpoint_space" _BACKUP_DISK_PATH="active chain filesystem (${CHAIN_DATA_DIR})" @@ -576,9 +646,11 @@ if [ "$CHAIN_AVAIL_KB" -lt "$CHAIN_REQUIRED_KB" ]; then _checkpoint_ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") _checkpoint_text="⚠️ Бэкап Aperod пропущен — недостаточно места для checkpoint%0A%0A" _checkpoint_text+="Файловая система: ${CHAIN_DATA_DIR}%0A" - _checkpoint_text+="Размер chain.db: ${CHAIN_DB_KB} KiB%0A" - _checkpoint_text+="Свободно: ${CHAIN_FREE_GB} GiB%0A" - _checkpoint_text+="Требуется: ${CHAIN_REQUIRED_GB} GiB (chain.db + 5 GiB reserve)%0A" + _checkpoint_text+="Текущий staging: ${STAGING_USED_BYTES} байт%0A" + _checkpoint_text+="Оценка следующего checkpoint: ${ESTIMATED_CHECKPOINT_BYTES} байт%0A" + _checkpoint_text+="Прогноз staging после копии: ${PROJECTED_STAGING_BYTES} байт%0A" + _checkpoint_text+="Свободно сейчас: ${CHAIN_AVAIL_BYTES} байт (${CHAIN_FREE_GB} GiB)%0A" + _checkpoint_text+="Требуется: ${CHAIN_REQUIRED_BYTES} байт (checkpoint + резерв ${CHECKPOINT_RESERVE_BYTES} байт)%0A" _checkpoint_text+="Время: ${_checkpoint_ts}%0A" curl -s --max-time 15 -X POST \ "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ @@ -591,6 +663,7 @@ if [ "$CHAIN_AVAIL_KB" -lt "$CHAIN_REQUIRED_KB" ]; then write_metrics 0 1 exit 0 fi +echo " Staging сейчас: ${STAGING_USED_BYTES} байт; оценка следующего checkpoint: ${ESTIMATED_CHECKPOINT_BYTES} байт; резерв: ${CHECKPOINT_RESERVE_BYTES} байт; свободно: ${CHAIN_AVAIL_BYTES} байт — достаточно." BACKUP_VERIFY_BIN="${APEROD_BACKUP_VERIFY_BIN:-/usr/local/bin/aperod-backup-verify}" if [ -L "$BACKUP_VERIFY_BIN" ] || [ ! -f "$BACKUP_VERIFY_BIN" ] || [ ! -x "$BACKUP_VERIFY_BIN" ]; then echo "ОШИБКА: обязательный verifier должен быть исполняемым обычным файлом: ${BACKUP_VERIFY_BIN}; checkpoint не запрашивался." @@ -669,13 +742,15 @@ fi echo "=== [1/5] Запрашиваем закрытый логический checkpoint ноды ===" CHECKPOINT_RESPONSE="${BACKUP_DIR}/checkpoint-response.json" -curl --fail --silent --show-error --max-time 900 \ +# A logical export reserializes the entire LevelDB snapshot. Keep it single-flight +# in the node, but allow a large disk-backed snapshot enough time to finish rather +# than canceling it at 15 minutes and discarding a nearly complete copy. +curl --fail --silent --show-error --max-time 3600 \ --unix-socket "$BACKUP_SOCKET" \ --request POST \ --output "$CHECKPOINT_RESPONSE" \ "http://localhost/checkpoint" -STAGING_ROOT="${CHAIN_DATA_DIR}/.backup-staging" SNAPSHOT_DIR=$(python3 - "$CHECKPOINT_RESPONSE" "$STAGING_ROOT" <<'PY' import json import os @@ -764,6 +839,39 @@ fi CHECKPOINT_TIP_HEIGHT="${CHECKPOINT_TIP[0]}" CHECKPOINT_TIP_HASH="${CHECKPOINT_TIP[1]}" +# Validate the exact closed checkpoint before it becomes the archive source. +# Remote readback is later compared byte-for-byte with the encrypted archive, +# so this read-only LevelDB check covers the payload without unpacking a second +# copy of the entire database on the node's root filesystem. +echo "=== Проверяем закрытый checkpoint до архивации ===" +LOCAL_VERIFY_OUTPUT=$("$BACKUP_VERIFY_BIN" --stage "$SNAPSHOT_DIR") +python3 - "$LOCAL_VERIFY_OUTPUT" "$SNAPSHOT_MANIFEST" \ + "$CHECKPOINT_TIP_HEIGHT" "$CHECKPOINT_TIP_HASH" <<'PY' +import json, sys +try: + result = json.loads(sys.argv[1]) +except json.JSONDecodeError as error: + raise SystemExit(f"backup verifier returned invalid JSON: {error}") +with open(sys.argv[2], encoding="utf-8") as source: + manifest = json.load(source) +if (not isinstance(result, dict) or result.get("success") is not True + or result.get("tip_hash") != manifest.get("tip_hash") + or result.get("tip_height") != manifest.get("tip_height") + or str(manifest.get("tip_height")) != sys.argv[3] + or manifest.get("tip_hash") != sys.argv[4]): + raise SystemExit("verified checkpoint tip does not match its manifest and socket response") +PY +if [ "$AUTH_ENABLED" -eq 1 ]; then + rm -f -- "$AUTH_PROOF_FILE" + if ! "$BACKUP_VERIFY_BIN" --stage "$SNAPSHOT_DIR" \ + --anchors-file "$AUTH_ANCHORS_FILE" --proof-output "$AUTH_PROOF_FILE" >/dev/null \ + || ! _auth_output_is_safe "$AUTH_PROOF_FILE"; then + echo "ПРЕДУПРЕЖДЕНИЕ: checkpoint прошел проверку, но anchor proof не подтвержден; auto-cleanup authorization отключена." >&2 + rm -f -- "$AUTH_PROOF_FILE" + _disable_backup_authorization "backup-proof-failed" + fi +fi + # Both source and destination filesystems get a fixed minimum-space check. _disk_preflight "$BACKUP_DIR" "BACKUP_DIR (/opt/aperod/backup-tmp)" _disk_preflight "$SNAPSHOT_DB" "closed checkpoint database (${SNAPSHOT_DB})" @@ -771,6 +879,7 @@ _disk_preflight "$SNAPSHOT_DB" "closed checkpoint database (${SNAPSHOT_DB})" echo "=== [2/5] Дамп PostgreSQL и проверка места ===" echo " Дамп PostgreSQL: ${DB_NAME} (user=${DB_USER}) ..." sudo -u "$DB_USER" pg_dump -F c -b "$DB_NAME" > "${BACKUP_DIR}/explorer_db.dump" +pg_restore --file /dev/null "${BACKUP_DIR}/explorer_db.dump" # Conservatively require enough room for the uncompressed archive (therefore # its encrypted output), which includes the immutable snapshot DB, manifest, @@ -1080,32 +1189,34 @@ PY _verify_remote_archive() { local remote="$1" - local download="$2" + local download="${2:-}" local expected_size="${3:-$ARCHIVE_SIZE}" local expected_sha="${4:-$ARCHIVE_SHA256}" - local expected_height="${5:-}" expected_hash="${6:-}" - local payload_mode="${7:-new}" - local proof_anchors="${8:-}" proof_output="${9:-}" - local remote_size remote_sha - _require_download_space "$expected_size" || return 1 - _copy_remote_fresh "$remote" "$download" || return 1 - remote_size=$(rclone size "$remote" --s3-no-check-bucket --json \ - | python3 -c 'import json,sys; d=json.load(sys.stdin); n=d.get("bytes"); assert isinstance(n,int) and n >= 0; print(n)') || return 1 - [ "$remote_size" = "$expected_size" ] || { - echo "ОШИБКА: размер загруженного объекта не совпадает с локальным архивом." + local remote_result remote_size remote_sha + # The local checkpoint and PostgreSQL dump are verified before archiving. + # A fresh remote stream then proves exact byte-for-byte equality without + # downloading a second ciphertext copy or unpacking another 11+ GiB LevelDB. + # Keep the old download argument for existing recovery/rollback call sites. + : "$download" + remote_result=$(rclone cat "$remote" --s3-no-check-bucket \ + | python3 -c 'import hashlib,sys; h=hashlib.sha256(); n=0 +for block in iter(lambda: sys.stdin.buffer.read(1024 * 1024), b""): + n += len(block) + h.update(block) +print(n, h.hexdigest())') || return 1 + read -r remote_size remote_sha <<<"$remote_result" || return 1 + [[ "$remote_size" =~ ^[0-9]+$ && "$remote_sha" =~ ^[0-9a-f]{64}$ ]] || { + echo "ОШИБКА: потоковая проверка удаленного архива вернула неверные данные." return 1 } - [ "$(stat -c '%s' -- "$download")" = "$expected_size" ] || { - echo "ОШИБКА: размер скачанного архива не совпадает с локальным." + [ "$remote_size" = "$expected_size" ] || { + echo "ОШИБКА: размер удаленного потока не совпадает с локальным архивом." return 1 } - remote_sha=$(sha256sum -- "$download" | awk '{print $1}') || return 1 [ "$remote_sha" = "$expected_sha" ] || { - echo "ОШИБКА: SHA256 удаленного архива не совпадает." + echo "ОШИБКА: SHA256 удаленного потока не совпадает." return 1 } - _verify_archive_payload "$download" "$expected_height" "$expected_hash" "$payload_mode" \ - "$proof_anchors" "$proof_output" } # A successful bucket listing is required to distinguish an absent fixed object @@ -1351,11 +1462,12 @@ if ! _verify_remote_archive "$CANDIDATE_REMOTE" "$DOWNLOAD_FILE" \ "$ARCHIVE_SIZE" "$ARCHIVE_SHA256" "$CHECKPOINT_TIP_HEIGHT" "$CHECKPOINT_TIP_HASH"; then exit 1 fi -echo " Уникальный удаленный объект проверен: размер, SHA256 и содержимое архива совпадают." +echo " Уникальный удаленный объект проверен: размер и SHA256 совпадают с локально проверенными данными." -# Promote only a candidate that passed a full download/decrypt/list validation. -# If replacement verification fails, restore the preexisting fixed object (or -# remove the new name if there was no predecessor) before returning failure. +# Promote only a candidate whose local checkpoint and dump were verified and +# whose remote ciphertext passed exact size/SHA256 readback. If replacement +# verification fails, restore the preexisting fixed object (or remove the new +# name if there was no predecessor) before returning failure. _restore_previous_fixed() { if [ "$FIXED_EXISTS" = "yes" ]; then echo "ОШИБКА: восстанавливаем предыдущую фиксированную копию." diff --git a/deploy/avm-stress.md b/deploy/avm-stress.md index 6c0677c..6d1e206 100644 --- a/deploy/avm-stress.md +++ b/deploy/avm-stress.md @@ -27,7 +27,7 @@ For a five-minute mixed-HTTP soak alone: cd blockchain APEROD_RUN_AVM_STRESS=1 APEROD_AVM_STRESS_DURATION=5m \ APEROD_AVM_STRESS_WORKERS=12 GOMAXPROCS=2 GOMEMLIMIT=512MiB \ - GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.10+auto \ + GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.11+auto \ go test -p 1 ./consensus -run '^TestAVMMixedHTTPStress$' -v -count=1 -timeout=8m ``` diff --git a/deploy/check-genesis-supply.sh b/deploy/check-genesis-supply.sh index c013261..a0e28dd 100644 --- a/deploy/check-genesis-supply.sh +++ b/deploy/check-genesis-supply.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash # check-genesis-supply.sh — Full genesis supply audit. # -# Shows per-allocation locked/unlocked status based on vesting schedules, -# plus circulating supply breakdown (unlocked + admin minted − burned). +# Checks genesis allocation totals; display-only team policy comes from the +# same distribution JSON as the website. Not a proof of circulating UTXOs. # # Usage: # ./check-genesis-supply.sh # node.yaml only # ./check-genesis-supply.sh --config /etc/aperod/node.yaml # ./check-genesis-supply.sh --api-url http://localhost:3001 # pull mints/burned from API +# ./check-genesis-supply.sh --distribution /path/to/genesis-distribution.json +# ./check-genesis-supply.sh --at 2026-08-14 # offline UTC snapshot +# Installed copy: set APEROD_DIR to the source checkout, or pass --distribution. +# Current policy cancels team entitlements; retired annual schedules belong in +# explicit historical JSON fixtures only. This report never modifies genesis. # # Exit codes: # 0 — genesis sums match @@ -17,6 +22,9 @@ set -euo pipefail CONFIG="/etc/aperod/node.yaml" API_URL="" +DISTRIBUTION="" +AS_OF="" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" while (($#)); do case "$1" in @@ -24,21 +32,78 @@ while (($#)); do --config) shift; CONFIG="${1:-}" ;; --api-url=*) API_URL="${1#--api-url=}" ;; --api-url) shift; API_URL="${1:-}" ;; + --distribution=*) DISTRIBUTION="${1#--distribution=}"; : "${DISTRIBUTION:?Missing --distribution path}" ;; + --distribution) shift; DISTRIBUTION="${1:?Missing --distribution path}" ;; + --at=*) AS_OF="${1#--at=}"; : "${AS_OF:?Missing --at date}" ;; + --at) shift; AS_OF="${1:?Missing --at date}" ;; + *) echo "ERROR: unknown option: $1" >&2; exit 1 ;; esac shift done +if [[ -z "$DISTRIBUTION" ]]; then + # Both checkout execution and the installed /usr/local/bin copy are supported. + if [[ -f "${SCRIPT_DIR}/../../lib/db/genesis-distribution.json" ]]; then + DISTRIBUTION="${SCRIPT_DIR}/../../lib/db/genesis-distribution.json" + else + DISTRIBUTION="${APEROD_DIR:-/opt/aperod}/lib/db/genesis-distribution.json" + fi +fi +if [[ ! -f "$DISTRIBUTION" ]]; then + echo "ERROR: distribution JSON not found; use --distribution: $DISTRIBUTION" >&2 + exit 1 +fi +if [[ -n "$AS_OF" && -n "$API_URL" ]]; then + echo "ERROR: --at is offline-only; live API counters cannot prove a historical snapshot" >&2 + exit 1 +fi + if [[ ! -f "$CONFIG" ]]; then echo "ERROR: config file not found: $CONFIG" >&2 exit 1 fi -python3 - "$CONFIG" "$API_URL" <<'PYEOF' +python3 - "$CONFIG" "$API_URL" "$DISTRIBUTION" "$AS_OF" <<'PYEOF' import sys, re, os, json, urllib.request, urllib.error from datetime import datetime, timezone +from decimal import Decimal config_path = sys.argv[1] api_url = sys.argv[2] if len(sys.argv) > 2 else "" +distribution_path = sys.argv[3] +as_of = sys.argv[4] + +def fail(message): + print("ERROR: " + message, file=sys.stderr) + sys.exit(1) + +def utc_day(value): + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise ValueError("expected YYYY-MM-DD") + return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc) + +try: + snapshot = utc_day(as_of) if as_of else datetime.now(timezone.utc) + with open(distribution_path) as source: + distribution = json.load(source) + if not isinstance(distribution, list) or not distribution: + raise ValueError("expected a non-empty allocation array") + for entry in distribution: + if not isinstance(entry, dict): + raise ValueError("invalid allocation entry") + amount = entry.get("amount_apro") + if type(amount) is not int or amount < 0: + raise ValueError("amount_apro must be a non-negative integer") + if "is_schedule_entry" in entry and type(entry["is_schedule_entry"]) is not bool: + raise ValueError("is_schedule_entry must be boolean") + if entry.get("unlock_date_utc"): + utc_day(entry["unlock_date_utc"]) + schedules = [e for e in distribution if e.get("is_schedule_entry") is True or e.get("unlock_date_utc")] + primary = [e for e in distribution if not e.get("is_schedule_entry") and not e.get("unlock_date_utc")] + if sum(e["amount_apro"] for e in primary) != 10_000_000_000: + raise ValueError("primary allocation total must be exactly 10B; schedules are suballocations") +except (ValueError, OSError, TypeError): + fail("invalid distribution JSON or UTC snapshot; refusing a continuous-vesting fallback") # ── Load node.yaml ──────────────────────────────────────────────────────────── with open(config_path) as f: @@ -104,12 +169,16 @@ for line in alloc_lines: if re.match(r'\s+-\s+(address|label)\s*:', line): if cur: allocations.append(cur) - cur = {"amount": 0, "label": "", "vesting_type": "immediate", + cur = {"amount": 0, "label": "", "address": "", "category": "", "vesting_type": "immediate", "cliff_seconds": 0, "vest_seconds": 0} m = re.match(r'\s+amount\s*:\s*([0-9_]+)', line) if m: cur["amount"] = parse_int(m.group(1)) m = re.match(r'\s+label\s*:\s*"?([^"#\n]+)"?', line) if m: cur["label"] = m.group(1).strip().strip('"') + m = re.match(r'\s+(?:-\s+)?address\s*:\s*"?([^"#\n]*)', line) + if m: cur["address"] = m.group(1).strip().strip('"') + m = re.match(r'\s+category\s*:\s*"?([^"#\n]+)', line) + if m: cur["category"] = m.group(1).strip().strip('"') # type: may appear directly or nested under vesting: m = re.match(r'\s+type\s*:\s*(\w+)', line) if m: cur["vesting_type"] = m.group(1) @@ -144,13 +213,14 @@ if api_url: api_error = str(e) # ── Compute vesting per allocation ──────────────────────────────────────────── -now_sec = int(datetime.now(timezone.utc).timestamp()) +now_sec = int(snapshot.timestamp()) genesis_known = genesis_ts > 0 total_locked_napr = 0 total_unlocked_napr = 0 rows = [] +policy_notes = [] for a in allocations: amt = a["amount"] vtype = a["vesting_type"] @@ -158,27 +228,72 @@ for a in allocations: cliff = a["cliff_seconds"] vest = a["vest_seconds"] - if vtype == "immediate": - pct = 100.0 + # Recognize legacy team allocations, but never rewrite genesis or claim + # that changing nominal policy removes a node-enforced reservation. + team = a["category"] == "team" or bool(re.search(r"\b(dev(?:elopment)?\s+fund|team)\b", label, re.I)) + if team: + candidates = [e for e in primary if e.get("category") == "team" + and e["amount_apro"] * 100_000_000 == amt] + if not candidates: + # Cancelled fund: the current JSON reclassifies the same nominal + # allocation into immediate Community & Ecosystem availability. + candidates = [e for e in primary if e.get("category") == "community" + and e.get("vesting_type") == "immediate" + and e["amount_apro"] * 100_000_000 == amt] + if a["address"]: + candidates = [e for e in candidates if e.get("address") == a["address"]] + if len(candidates) != 1: + fail("legacy team allocation cannot be uniquely reconciled with distribution JSON") + policy = candidates[0] + policy_label = policy.get("label", label) + if policy.get("category") != "team": + unlocked = amt + vtype = "policy-immediate" + policy_notes.append(f"YAML '{label}' -> '{policy_label}': team entitlement cancelled; " + "nominal policy only, not proof of canonical release.") + label = policy_label + else: + matching = [e for e in schedules if e.get("category") == "team"] + if not matching or any(not e.get("unlock_date_utc") for e in matching): + fail("team tranche dates are missing; refusing continuous interpolation") + total_scheduled = sum(e["amount_apro"] for e in matching) * 100_000_000 + if total_scheduled > amt: + fail("team tranches exceed the primary allocation") + policy_cliff = policy.get("cliff_seconds", 0) + if type(policy_cliff) is not int or policy_cliff < 0: + fail("invalid team cliff_seconds") + if not genesis_known: + unlocked = None + elif now_sec < genesis_ts + policy_cliff: + unlocked = 0 + else: + unlocked = amt - total_scheduled + sum( + e["amount_apro"] * 100_000_000 for e in matching + if now_sec >= int(utc_day(e["unlock_date_utc"]).timestamp()) + ) + vtype = "dated-tranches" + policy_notes.append(f"YAML '{label}': using dated JSON tranches, not its legacy continuous schedule.") + elif vtype == "immediate": + unlocked = amt elif not genesis_known: - pct = None # unknown + unlocked = None else: - elapsed = now_sec - genesis_ts + elapsed = max(0, now_sec - genesis_ts) if vtype == "linear": - pct = min(100.0, max(0.0, elapsed / vest * 100)) if vest > 0 else 100.0 + unlocked = amt * min(elapsed, vest) // vest if vest > 0 else amt elif vtype == "cliff_linear": if elapsed < cliff: - pct = 0.0 + unlocked = 0 else: - pct = min(100.0, max(0.0, (elapsed - cliff) / vest * 100)) if vest > 0 else 100.0 + unlocked = amt * min(elapsed - cliff, vest) // vest if vest > 0 else amt else: - pct = 100.0 # unknown type → assume unlocked + fail("unknown configured vesting type; refusing to assume unlocked") - if pct is None: - unlocked = None + if unlocked is None: + pct = None locked = None else: - unlocked = int(amt * pct / 100) + pct = unlocked * 100 / amt if amt else 100 locked = amt - unlocked total_locked_napr += locked total_unlocked_napr += unlocked @@ -191,7 +306,7 @@ for a in allocations: # ── Helpers ─────────────────────────────────────────────────────────────────── def fmt(napr): if napr is None: return " unknown" - apro = napr / 1e8 + apro = Decimal(napr) / 100_000_000 return f"{apro:>18,.4f}" def bar(pct, width=20): @@ -209,7 +324,7 @@ CYAN = "\033[36m" print() print(f"{BOLD}══════════════════════════════════════════════════════════════{RESET}") -print(f"{BOLD} APEROD — Genesis Supply Audit{RESET}") +print(f"{BOLD} APEROD — Genesis Allocation Audit{RESET}") print(f"{BOLD}══════════════════════════════════════════════════════════════{RESET}") print(f" Config : {config_path}") if genesis_ts > 0: @@ -222,7 +337,12 @@ if api_url: print(f" API : {RED}unreachable ({api_error}){RESET}") else: print(f" API : {api_url} (live data)") -print(f" Snapshot : {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") +print(f" Snapshot : {snapshot.strftime('%Y-%m-%d %H:%M UTC')}") +print(f" Policy JSON : {distribution_path}") +print(" Basis : nominal allocation / calendar estimates, NOT verified circulating or spendable UTXOs") +for note in policy_notes: + print(f" Policy note : {note}") +print(" Genesis YAML is unchanged; node-enforced locks/reserves require independent canonical reconciliation.") print() # Per-allocation table @@ -246,26 +366,29 @@ for r in rows: print(sep) # Totals from genesis -print(f" {'GENESIS TOTAL':<{col1}} {fmt(total_napr)} {fmt(total_unlocked_napr)} {fmt(total_locked_napr)}") +all_vesting_known = all(row["unlocked"] is not None for row in rows) +print(f" {'GENESIS TOTAL':<{col1}} {fmt(total_napr)} " + f"{fmt(total_unlocked_napr if all_vesting_known else None)} " + f"{fmt(total_locked_napr if all_vesting_known else None)}") print() # Supply identity -print(f"{BOLD}── Supply Identity ──────────────────────────────────────────{RESET}") +print(f"{BOLD}── Nominal accounting (not a circulating-supply proof) ─────────{RESET}") print(f" Genesis total : {fmt(total_napr)} APRO") if minted_napr is not None: - print(f" Admin minted :{CYAN}{fmt(minted_napr)}{RESET} APRO (confirmed admin mints)") + print(f" Admin minted :{CYAN}{fmt(minted_napr)}{RESET} APRO (reported by API, not independently verified)") if burned_napr is not None: print(f" Burned (EIP-1559) :{RED}{fmt(-burned_napr)}{RESET} APRO") if minted_napr is not None and burned_napr is not None: - circulating = total_unlocked_napr + minted_napr - burned_napr if genesis_known else None + circulating = total_unlocked_napr + minted_napr - burned_napr if all_vesting_known else None print() print(f" ┌──────────────────────────────────────────────────────┐") if circulating is not None: - print(f" │ Circulating = unlocked + minted − burned │") + print(f" │ Nominal estimate = unlocked + minted − burned │") print(f" │ = {fmt(total_unlocked_napr)} + {fmt(minted_napr)} − {fmt(burned_napr)} │") print(f" │ = {BOLD}{GREEN}{fmt(circulating)} APRO{RESET} │") - print(f" │ Remaining mintable cap : {fmt(initial_supply_napr - (minted_napr or 0))} APRO │") + print(f" │ This estimate grants no minting authority. │") print(f" └──────────────────────────────────────────────────────┘") print() diff --git a/deploy/check-system.sh b/deploy/check-system.sh index 3ee1ff3..1710a64 100644 --- a/deploy/check-system.sh +++ b/deploy/check-system.sh @@ -229,7 +229,7 @@ hdr "7. rclone + S3/Backblaze" if ! command -v rclone &>/dev/null; then err "rclone НЕ установлен" - echo " → curl https://rclone.org/install.sh | sudo bash" + echo " → Установите rclone из подписанного репозитория пакетов вашего дистрибутива." else RCLONE_VER=$(rclone version 2>/dev/null | head -1) ok "rclone установлен: ${RCLONE_VER}" diff --git a/deploy/explorer-indexer-static-guard.sh b/deploy/explorer-indexer-static-guard.sh new file mode 100644 index 0000000..6853c0a --- /dev/null +++ b/deploy/explorer-indexer-static-guard.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Sourced by deployment entrypoints. Never execute the candidate to inspect it. +# ldd is required here: an unavailable verifier is not permission to deploy. +explorer_indexer_static_guard() { + if [[ $# -ne 1 ]]; then + echo "Usage: explorer_indexer_static_guard BINARY" >&2 + return 1 + fi + local binary="$1" output magic + if [[ ! -f "$binary" || ! -x "$binary" ]]; then + echo "Static-link check FAILED: explorer-indexer candidate is absent or not executable: $binary" >&2 + return 1 + fi + magic=$(head -c 4 -- "$binary") || return 1 + if [[ "$magic" != $'\x7fELF' ]]; then + echo "Static-link check FAILED: explorer-indexer candidate is not an ELF binary: $binary" >&2 + return 1 + fi + if ! command -v ldd >/dev/null 2>&1; then + echo "Static-link check FAILED: ldd is unavailable; cannot verify explorer-indexer." >&2 + return 1 + fi + # glibc ldd normally exits 1 for a static executable; its output is decisive. + output=$(LC_ALL=C ldd "$binary" 2>&1 || true) + if [[ "$output" != *"not a dynamic executable"* ]]; then + echo "Static-link check FAILED: explorer-indexer is dynamically linked or unverifiable: $binary" >&2 + printf '%s\n' "$output" >&2 + echo "Fix: ensure CGO_ENABLED=0 in the Makefile build-explorer-indexer target." >&2 + return 1 + fi + echo "explorer-indexer: ldd confirmed a statically linked binary." +} diff --git a/deploy/fixtures/node-lifecycle/cli.go b/deploy/fixtures/node-lifecycle/cli.go index d46843b..60f76c3 100644 --- a/deploy/fixtures/node-lifecycle/cli.go +++ b/deploy/fixtures/node-lifecycle/cli.go @@ -19,11 +19,19 @@ func main() { fmt.Println(pub) return } + + address := "apro" + strings.Repeat("A", 99) for i, arg := range os.Args { - if arg == "--out" && i+1 < len(os.Args) { - _ = os.WriteFile(os.Args[i+1], []byte(`{"address":"apro`+strings.Repeat("A", 99)+`"}`), 0600) + if arg == "--output" && i+1 < len(os.Args) { + keystore := fmt.Sprintf( + `{"address":%q,"crypto":{"ciphertext":"synthetic-only"}}`, address) + if err := os.WriteFile(os.Args[i+1], []byte(keystore), 0600); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(address) return } } - fmt.Println("apro" + strings.Repeat("A", 99)) + fmt.Println(address) } diff --git a/deploy/fixtures/node-lifecycle/go.mod b/deploy/fixtures/node-lifecycle/go.mod index 4a2850d..8746e41 100644 --- a/deploy/fixtures/node-lifecycle/go.mod +++ b/deploy/fixtures/node-lifecycle/go.mod @@ -1,4 +1,4 @@ // Template assets; the lifecycle generator copies each main into its own command. module fixture.invalid/lifecycle-assets -go 1.26.10 +go 1.26.11 diff --git a/deploy/install-node.sh b/deploy/install-node.sh index 370d3aa..3e100a1 100755 --- a/deploy/install-node.sh +++ b/deploy/install-node.sh @@ -19,7 +19,7 @@ INSTALL_DIR="/opt/aperod" DATA_DIR="/var/lib/aperod" CONFIG_DIR="/etc/aperod" WALLET_DIR="/etc/aperod/wallet" -GO_VERSION="1.26.10" +GO_VERSION="1.26.11" REPO_URL="https://github.com/aperod-network/aperod-node.git" P2P_PORT=30303 RPC_PORT=8545 @@ -120,10 +120,26 @@ fi if ${need_go}; then info "Устанавливаем Go ${GO_VERSION}…" ARCH=$( [[ $(uname -m) == "aarch64" ]] && echo "arm64" || echo "amd64" ) - wget -q "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" -O /tmp/go.tar.gz + case "$ARCH" in + amd64) GO_SHA256="9849f4e04ee35303714e34fbc3ac8ab44ea3e448a95aa182f91e3b9b79c710ec" ;; + arm64) GO_SHA256="6c2f29b458b5add0835f18b776f69c8881a6d88428f1484a6da55755e620757e" ;; + *) die "Неподдерживаемая архитектура Go: ${ARCH}" ;; + esac + GO_ARCHIVE=$(mktemp /tmp/aperod-go.XXXXXX.tar.gz) || die "Не удалось создать временный файл Go" + if ! wget -q "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" -O "$GO_ARCHIVE"; then + rm -f "$GO_ARCHIVE" + die "Не удалось скачать архив Go ${GO_VERSION}" + fi + if ! printf '%s %s\n' "$GO_SHA256" "$GO_ARCHIVE" | sha256sum --check --status; then + rm -f "$GO_ARCHIVE" + die "Контрольная сумма Go ${GO_VERSION} не совпала" + fi rm -rf /usr/local/go - tar -C /usr/local -xzf /tmp/go.tar.gz - rm -f /tmp/go.tar.gz + if ! tar -C /usr/local -xzf "$GO_ARCHIVE"; then + rm -f "$GO_ARCHIVE" + die "Не удалось распаковать проверенный архив Go ${GO_VERSION}" + fi + rm -f "$GO_ARCHIVE" echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh export PATH="$PATH:/usr/local/go/bin" ok "Go ${GO_VERSION} установлен" @@ -250,45 +266,50 @@ if [[ -f "${WALLET_FILE}" ]]; then read -rp "Использовать существующий? [Y/n]: " USE_EXISTING if [[ "${USE_EXISTING,,}" != "n" ]]; then WALLET_ADDR=$(jq -r '.address // empty' "${WALLET_FILE}" 2>/dev/null || "") + [[ -n "${WALLET_ADDR}" ]] || die "Существующий файл кошелька не распознан. Он оставлен без изменений; проверьте его резервную копию." + aperod wallet validate "${WALLET_ADDR}" >/dev/null \ + || die "Адрес существующего кошелька недействителен. Файл оставлен без изменений." ok "Используем существующий кошелёк" + else + die "Существующий файл оставлен без изменений. Переместите его в безопасное место и повторите установку, чтобы создать другой кошелёк." fi fi if [[ -z "${WALLET_ADDR}" ]]; then echo echo " 1) Создать новый кошелёк (рекомендуется)" - echo " 2) Импортировать существующий приватный ключ (hex, 64 символа)" + echo " 2) Восстановить кошелёк по мнемонической фразе" echo read -rp "Выбор [1/2]: " W_CHOICE if [[ "${W_CHOICE}" == "2" ]]; then - echo - read -rsp " Введите приватный ключ (64 hex-символа, ввод скрыт): " PRIVKEY - echo - if [[ ! "${PRIVKEY}" =~ ^[0-9a-fA-F]{64}$ ]]; then - die "Неверный формат: ожидается 64 hex-символа" - fi - aperod wallet import --key "${PRIVKEY}" --out "${WALLET_FILE}" 2>/dev/null || \ - echo -n "${PRIVKEY}" > "${WALLET_FILE}" - ok "Приватный ключ импортирован" + aperod wallet restore --output "${WALLET_FILE}" \ + || die "Не удалось восстановить кошелёк. Ключи не были сохранены." + [[ -s "${WALLET_FILE}" ]] || die "CLI не создал зашифрованный файл кошелька." + WALLET_ADDR=$(jq -r '.address // empty' "${WALLET_FILE}" 2>/dev/null || "") + [[ -n "${WALLET_ADDR}" ]] || die "В файле восстановленного кошелька отсутствует адрес." + aperod wallet validate "${WALLET_ADDR}" >/dev/null \ + || die "CLI создал кошелёк с недействительным адресом." + ok "Зашифрованный кошелёк восстановлен" else info "Генерируем новый кошелёк…" - aperod wallet create --out "${WALLET_FILE}" 2>/dev/null || true + aperod wallet create --output "${WALLET_FILE}" \ + || die "Не удалось создать зашифрованный кошелёк." # Если команда создала файл — читаем адрес из него if [[ -f "${WALLET_FILE}" ]]; then WALLET_ADDR=$(jq -r '.address // empty' "${WALLET_FILE}" 2>/dev/null || "") fi + [[ -s "${WALLET_FILE}" ]] || die "CLI не создал зашифрованный файл кошелька." + [[ -n "${WALLET_ADDR}" ]] || die "В файле нового кошелька отсутствует адрес." + aperod wallet validate "${WALLET_ADDR}" >/dev/null \ + || die "CLI создал кошелёк с недействительным адресом." ok "Кошелёк создан" fi chmod 600 "${WALLET_FILE}" chown root:root "${WALLET_FILE}" - # Получить адрес - if [[ -z "${WALLET_ADDR}" ]]; then - WALLET_ADDR=$(aperod wallet address 2>/dev/null || "см. файл ${WALLET_FILE}") - fi fi # ── 7. Определяем внешний IP ────────────────────────────── @@ -724,10 +745,9 @@ echo echo -e " ${BOLD}Полезные команды:${NC}" echo -e " systemctl status aperod-node — статус ноды" echo -e " journalctl -u aperod-node -f — логи в реальном времени" -echo -e " aperod wallet balance — баланс кошелька" -echo -e " aperod wallet address — ваш APR-адрес" -echo -e " aperod wallet new-address --label X — создать адрес для платежа" -echo -e " aperod wallet send --to --amount — отправить APR" +echo -e " aperod wallet validate — проверить формат адреса" +echo -e " aperod wallet restore --output <файл> — восстановить из мнемоники" +echo -e " aperod --help — список доступных команд" echo echo -e "${YELLOW}${BOLD} ⚠ Не забудьте сделать резервную копию кошелька:${NC}" echo -e " gpg --symmetric --cipher-algo AES256 ${WALLET_FILE}" diff --git a/deploy/install-validator.sh b/deploy/install-validator.sh index 0671d4b..1e230c6 100755 --- a/deploy/install-validator.sh +++ b/deploy/install-validator.sh @@ -19,7 +19,7 @@ APEROD_USER="aperod" INSTALL_DIR="/opt/aperod" DATA_DIR="/var/lib/aperod" CONFIG_DIR="/etc/aperod" -GO_VERSION="1.26.10" +GO_VERSION="1.26.11" P2P_PORT=30303 RPC_PORT=8545 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -126,10 +126,26 @@ fi if ${need_go}; then info "Устанавливаем Go ${GO_VERSION}…" ARCH=$( [[ $(uname -m) == "aarch64" ]] && echo "arm64" || echo "amd64" ) - wget -q "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" -O /tmp/go.tar.gz + case "$ARCH" in + amd64) GO_SHA256="9849f4e04ee35303714e34fbc3ac8ab44ea3e448a95aa182f91e3b9b79c710ec" ;; + arm64) GO_SHA256="6c2f29b458b5add0835f18b776f69c8881a6d88428f1484a6da55755e620757e" ;; + *) die "Неподдерживаемая архитектура Go: ${ARCH}" ;; + esac + GO_ARCHIVE=$(mktemp /tmp/aperod-go.XXXXXX.tar.gz) || die "Не удалось создать временный файл Go" + if ! wget -q "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" -O "$GO_ARCHIVE"; then + rm -f "$GO_ARCHIVE" + die "Не удалось скачать архив Go ${GO_VERSION}" + fi + if ! printf '%s %s\n' "$GO_SHA256" "$GO_ARCHIVE" | sha256sum --check --status; then + rm -f "$GO_ARCHIVE" + die "Контрольная сумма Go ${GO_VERSION} не совпала" + fi rm -rf /usr/local/go - tar -C /usr/local -xzf /tmp/go.tar.gz - rm -f /tmp/go.tar.gz + if ! tar -C /usr/local -xzf "$GO_ARCHIVE"; then + rm -f "$GO_ARCHIVE" + die "Не удалось распаковать проверенный архив Go ${GO_VERSION}" + fi + rm -f "$GO_ARCHIVE" echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh export PATH="$PATH:/usr/local/go/bin" ok "Go ${GO_VERSION} установлен" diff --git a/deploy/join-network.sh b/deploy/join-network.sh index 9fae28d..9ad1d7d 100644 --- a/deploy/join-network.sh +++ b/deploy/join-network.sh @@ -110,6 +110,7 @@ if [[ -n "${BOOTSTRAP_FROM}" ]]; then die "--bootstrap-from='${VALIDATOR_IP}' не является IPv4-адресом." fi + [[ -f "${LOCAL_NODE_YAML}" ]] || die "${LOCAL_NODE_YAML} не найден. Сначала установите ноду через install-node.sh; без node.yaml нельзя настроить peer_whitelist." command -v rsync >/dev/null 2>&1 || die "rsync не установлен" command -v ssh >/dev/null 2>&1 || die "ssh не установлен" @@ -448,13 +449,46 @@ PY # Предотвращает накопление bad-block страйков (→ 24-ч бан) пока реле # не догонит цепь и не начнёт принимать gossip-блоки валидатора. info "Шаг 7.5/9: Прописываем ${VALIDATOR_IP} в p2p.peer_whitelist…" - python3 - "${LOCAL_NODE_YAML}" "${VALIDATOR_IP}" <<'PY' -import sys, yaml, os + python3 - "${LOCAL_NODE_YAML}" "${VALIDATOR_IP}" "${LOCAL_DATA_DIR}" <<'PY' +import sys, yaml, os, json, ipaddress, stat, subprocess, tempfile cfg_path = sys.argv[1] validator_ip = sys.argv[2] +ipaddress.IPv4Address(validator_ip) with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} p2p = cfg.setdefault("p2p", {}) +# A nonempty runtime sidecar overrides YAML. Merge the joining validator into +# the target's policy too; do not delete existing operator entries or import +# the source host's policy. "-" explicitly disables sidecar persistence. +sidecar = p2p.get("whitelist_file") or os.path.join(cfg.get("data_dir") or sys.argv[3], "p2p_whitelist.json") +if sidecar != "-": + if not os.path.isabs(sidecar): + cwd = subprocess.check_output(["systemctl", "show", "aperod-node", "--property=WorkingDirectory", "--value"], text=True).strip() or "/" + if not os.path.isabs(cwd): + raise ValueError("aperod-node WorkingDirectory must be absolute") + sidecar = os.path.join(cwd, sidecar) + if os.path.lexists(sidecar): + with open(sidecar) as f: + entries = json.load(f) + if not isinstance(entries, list) or any(not isinstance(e, str) for e in entries): + raise ValueError("Invalid whitelist sidecar: expected IP/CIDR array") + for entry in entries: + ipaddress.ip_network(entry, strict=False) + if validator_ip not in entries: + entries.append(validator_ip) + meta = os.stat(sidecar) + fd, tmp_sidecar = tempfile.mkstemp(prefix=".join-whitelist-", dir=os.path.dirname(sidecar)) + try: + with os.fdopen(fd, "w") as f: + os.fchown(f.fileno(), meta.st_uid, meta.st_gid) + os.fchmod(f.fileno(), stat.S_IMODE(meta.st_mode)) + json.dump(entries, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_sidecar, sidecar) + finally: + if os.path.exists(tmp_sidecar): + os.unlink(tmp_sidecar) wl = list(p2p.get("peer_whitelist") or []) if validator_ip not in wl: wl.append(validator_ip) @@ -469,11 +503,7 @@ else: PY ok "IP валидатора ${VALIDATOR_IP} добавлен в peer_whitelist" else - warn "${LOCAL_NODE_YAML} не найден — bootnode не прописан." - warn "Добавьте вручную в node.yaml:" - warn " p2p:" - warn " bootnodes:" - warn " - ${VALIDATOR_BOOTNODE}" + die "${LOCAL_NODE_YAML} не найден — bootnode и peer_whitelist не настроены." fi # ── Шаг 7b: Устанавливаем snapshot.utxo_count_tolerance_pct ── @@ -623,21 +653,29 @@ fi [[ -z "${TARGET_IP}" ]] && die "Укажите IP нового сервера: bash join-network.sh Или используйте: bash join-network.sh --bootstrap-from=" -# PRIMARY_IP — IP-адрес ЭТОГО сервера (откуда запускается скрипт). -# Переопределите переменной окружения PRIMARY_IP если auto-detect даёт -# неверный адрес (например, внутренний 10.x вместо внешнего IP). +# PRIMARY_IP — source IPv4 as observed by the relay's authenticated SSH server. +# Local interfaces/routes can show a private address behind NAT. Observing the +# connection at the destination avoids whitelisting that unusable private IP. +# An explicit override remains available for unusual SSH/P2P routing or proxies. +command -v ssh >/dev/null 2>&1 || die "ssh не установлен" if [[ -z "${PRIMARY_IP:-}" ]]; then - # Explicitly pick the first IPv4 address (skip IPv6 addresses). - # hostname -I can return IPv6 first on dual-stack hosts, but /ip4/.../tcp/... - # multiaddrs require a dotted-quad IPv4 address. - PRIMARY_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -1) - if [[ -z "${PRIMARY_IP}" ]]; then - die "Не удалось определить IPv4-адрес этого сервера. + if ! PRIMARY_IP=$(ssh "root@${TARGET_IP}" 'printf "%s\n" "${SSH_CONNECTION%% *}"' bash join-network.sh " fi fi -# Validate that PRIMARY_IP is a dotted-quad to catch accidental IPv6 overrides. -if ! echo "${PRIMARY_IP}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then +# Reject empty/malformed observations and invalid IPv4 overrides before stopping +# either node; never silently fall back to the first local network interface. +_valid_primary_ipv4() { + local part + local -a parts + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r -a parts <<< "$1" + for part in "${parts[@]}"; do + [[ "${part}" =~ ^(0|[1-9][0-9]{0,2})$ ]] && ((10#${part} <= 255)) || return 1 + done +} +if ! _valid_primary_ipv4 "${PRIMARY_IP}"; then die "PRIMARY_IP='${PRIMARY_IP}' не является IPv4-адресом. /ip4/.../tcp/... multiaddr требует dotted-quad IPv4. Укажите IPv4: PRIMARY_IP= bash join-network.sh " fi @@ -871,8 +909,33 @@ _PUSH_RSYNC_STARTED=1 # The source does not have this file, so without the exclusion rsync's # deletion pass would remove the sentinel from the target BEFORE the data # transfer completes — defeating its purpose. +# The source's authoritative whitelist sidecar is also host-local policy: +# copying it would override the validator IP we add to the relay's YAML. +# Preserve an existing target policy; a fresh relay seeds its own from YAML. +_TARGET_WHITELIST_EXCLUDE=$(ssh "root@${TARGET_IP}" python3 - "${SECONDARY_NODE_YAML}" "${SECONDARY_DATA_DIR}" <<'PY' +import os, sys, yaml, subprocess +cfg = {} +if os.path.exists(sys.argv[1]): + with open(sys.argv[1]) as f: + cfg = yaml.safe_load(f) or {} +path = (cfg.get("p2p") or {}).get("whitelist_file") or os.path.join(cfg.get("data_dir") or sys.argv[2], "p2p_whitelist.json") +if path != "-": + if not os.path.isabs(path): + cwd = subprocess.check_output(["systemctl", "show", "aperod-node", "--property=WorkingDirectory", "--value"], text=True).strip() or "/" + if not os.path.isabs(cwd): + raise ValueError("aperod-node WorkingDirectory must be absolute") + path = os.path.join(cwd, path) + # An anchored exclusion protects a custom sidecar inside the copied data. + root = os.path.abspath(sys.argv[2]) + path = os.path.abspath(path) + if os.path.commonpath([root, path]) == root: + print("/" + os.path.relpath(path, root)) +PY +) rsync -az --delete --progress --ignore-errors \ --exclude='p2p_identity.key' \ + --exclude='p2p_whitelist.json' \ + --exclude="${_TARGET_WHITELIST_EXCLUDE}" \ --exclude='.rsync-in-progress' \ "${PRIMARY_DATA_DIR}/" \ "root@${TARGET_IP}:${SECONDARY_DATA_DIR}/" @@ -977,13 +1040,46 @@ ok "Bootnode ${PRIMARY_BOOTNODE} прописан в ${SECONDARY_NODE_YAML}" # забанит валидатор на 24 ч — полностью разрывая P2P-синхронизацию. # Белый список (peer_whitelist) исключает валидатор из счётчика страйков. info "Шаг 5.5/7: Прописываем ${PRIMARY_IP} в p2p.peer_whitelist нового узла…" -ssh "root@${TARGET_IP}" python3 - "${SECONDARY_NODE_YAML}" "${PRIMARY_IP}" <<'PY' -import sys, yaml, os +ssh "root@${TARGET_IP}" python3 - "${SECONDARY_NODE_YAML}" "${PRIMARY_IP}" "${SECONDARY_DATA_DIR}" <<'PY' +import sys, yaml, os, json, ipaddress, stat, subprocess, tempfile cfg_path = sys.argv[1] validator_ip = sys.argv[2] +ipaddress.IPv4Address(validator_ip) with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} p2p = cfg.setdefault("p2p", {}) +# A nonempty runtime sidecar overrides YAML. Merge the joining validator into +# the target's policy too; do not delete existing operator entries or import +# the source host's policy. "-" explicitly disables sidecar persistence. +sidecar = p2p.get("whitelist_file") or os.path.join(cfg.get("data_dir") or sys.argv[3], "p2p_whitelist.json") +if sidecar != "-": + if not os.path.isabs(sidecar): + cwd = subprocess.check_output(["systemctl", "show", "aperod-node", "--property=WorkingDirectory", "--value"], text=True).strip() or "/" + if not os.path.isabs(cwd): + raise ValueError("aperod-node WorkingDirectory must be absolute") + sidecar = os.path.join(cwd, sidecar) + if os.path.lexists(sidecar): + with open(sidecar) as f: + entries = json.load(f) + if not isinstance(entries, list) or any(not isinstance(e, str) for e in entries): + raise ValueError("Invalid whitelist sidecar: expected IP/CIDR array") + for entry in entries: + ipaddress.ip_network(entry, strict=False) + if validator_ip not in entries: + entries.append(validator_ip) + meta = os.stat(sidecar) + fd, tmp_sidecar = tempfile.mkstemp(prefix=".join-whitelist-", dir=os.path.dirname(sidecar)) + try: + with os.fdopen(fd, "w") as f: + os.fchown(f.fileno(), meta.st_uid, meta.st_gid) + os.fchmod(f.fileno(), stat.S_IMODE(meta.st_mode)) + json.dump(entries, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_sidecar, sidecar) + finally: + if os.path.exists(tmp_sidecar): + os.unlink(tmp_sidecar) wl = list(p2p.get("peer_whitelist") or []) if validator_ip not in wl: wl.append(validator_ip) diff --git a/deploy/join_network_bootnode_test.go b/deploy/join_network_bootnode_test.go index b58b602..3de6412 100644 --- a/deploy/join_network_bootnode_test.go +++ b/deploy/join_network_bootnode_test.go @@ -308,6 +308,10 @@ exit 1`, // everything else (disable, daemon-reload) → exit 0 "systemctl": `#!/usr/bin/env bash [[ "${1:-}" == "is-active" ]] && exit 1 +exit 0`, + // ssh: keep the secure tunnel path local to this test; host-key enforcement + // is covered by TestJoinTunnelRequiresPinnedHostKey. + "ssh": `#!/usr/bin/env bash exit 0`, // curl: /api/v1/snapshot → exit 1 (non-fatal warn path) // /api/v1/status → exit 0 (primary reachable) @@ -392,6 +396,7 @@ genesis: // ── Run aperod-join.sh with --p2p-port 30304 ──────────────────────────── cmd := exec.Command("bash", joinSh, fmt.Sprintf("%s:8545", primaryIP), + "--tunnel", fmt.Sprintf("root@%s", primaryIP), "--p2p-port", nonDefaultPort, "--data-dir", dataDir, "--user", "nonexistentuser_gotest", diff --git a/deploy/join_network_mock_ssh_test.go b/deploy/join_network_mock_ssh_test.go index cf26ae6..4cef591 100644 --- a/deploy/join_network_mock_ssh_test.go +++ b/deploy/join_network_mock_ssh_test.go @@ -123,7 +123,7 @@ func TestJoinNetworkMockSSHSkipContract(t *testing.T) { "0 passed", "0 failed", "1 skipped", - "M1-M6: python3 not found in PATH", +"M1-M8: python3 not found in PATH", "SKIP_SUMMARY count=1", } { if !strings.Contains(string(output), marker) { diff --git a/deploy/join_network_whitelist_test.go b/deploy/join_network_whitelist_test.go new file mode 100644 index 0000000..b41e683 --- /dev/null +++ b/deploy/join_network_whitelist_test.go @@ -0,0 +1,149 @@ +package deploy_test + +import ( + "encoding/json" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/aperod/aperod/p2p" +) + +// Test the actual production heredocs in all join paths, not a copy of their +// Python implementation. The complete push flow is covered by MockSSH too. +func TestJoinNetworkWhitelist(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("join scripts require bash") + } + cmd := exec.Command("bash", "test-join-network-whitelist.sh") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("production join whitelist writers: %v\n%s", err, output) + } +} + +// A YAML-only assertion misses the authoritative persisted whitelist. Run the +// production writer, then load its result through a real local Host twice. +func TestJoinNetworkWhitelistEffectiveAfterStartAndRestart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("join scripts require bash") + } + source, err := os.ReadFile("join-network.sh") + if err != nil { + t.Fatal(err) + } + marker := `ssh "root@${TARGET_IP}" python3 - "${SECONDARY_NODE_YAML}" "${PRIMARY_IP}" "${SECONDARY_DATA_DIR}" <<'PY'` + "\n" + _, rest, found := strings.Cut(string(source), marker) + if !found { + t.Fatal("production push whitelist writer not found") + } + writer, _, found := strings.Cut(rest, "\nPY\n") + if !found { + t.Fatal("production whitelist heredoc has no terminator") + } + const validator = "198.51.100.8" + const existing = "203.0.113.20" + for _, variant := range []string{"fresh", "existing-default", "existing-custom"} { + t.Run(variant, func(t *testing.T) { + dir := t.TempDir() + sidecar := filepath.Join(dir, "p2p_whitelist.json") + p2pConfig := map[string]any{"peer_whitelist": []string{validator}} + if variant == "existing-custom" { + sidecar = filepath.Join(dir, "operator-peers.json") + p2pConfig["whitelist_file"] = sidecar + } + want := []string{validator} + if variant != "fresh" { + want = []string{existing, validator} + if err := os.WriteFile(sidecar, []byte(`["`+existing+`"]`), 0600); err != nil { + t.Fatal(err) + } + } + body, err := json.Marshal(map[string]any{"data_dir": dir, "p2p": p2pConfig}) + if err != nil { + t.Fatal(err) + } + config := filepath.Join(dir, "node.yaml") + if err := os.WriteFile(config, body, 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("python3", "-", config, validator, dir) + cmd.Stdin = strings.NewReader(writer) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("production writer failed: %v\n%s", err, out) + } + for boot := 0; boot < 2; boot++ { + func() { + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", MaxPeers: 2, + NodeID: "join-whitelist-test", WhitelistFile: sidecar, + PeerWhitelist: []string{validator}, + }, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := host.Start(); err != nil { + t.Fatalf("boot %d: %v", boot, err) + } + defer host.Stop() + got := host.GetPeerWhitelist() + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("boot %d: effective whitelist %v, want %v", boot, got, want) + } + }() + } + }) + } +} + +func TestJoinWhitelistMissingConfigDoesNotTouchData(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("join scripts require bash") + } + for _, script := range []string{"join-network.sh", "aperod-join.sh"} { + t.Run(script, func(t *testing.T) { + dir := t.TempDir() + data := filepath.Join(dir, "data") + if err := os.MkdirAll(filepath.Join(data, "chain.db"), 0700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(data, "chain.db", "CURRENT") + if err := os.WriteFile(marker, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + missing := filepath.Join(dir, "missing.yaml") + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + calls := filepath.Join(dir, "external-calls") + for _, name := range []string{"ssh", "curl", "rsync", "systemctl"} { + if err := os.WriteFile(filepath.Join(bin, name), + []byte("#!/bin/sh\nprintf called >> \"$JOIN_EXTERNAL_CALLS\"\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + } + args := []string{script, "--bootstrap-from=192.0.2.10"} + if script == "aperod-join.sh" { + args = []string{script, "192.0.2.10:8545", "--tunnel", "root@192.0.2.10", "--data-dir", data, "--skip-start"} + } + cmd := exec.Command("bash", args...) + cmd.Env = append(os.Environ(), "LOCAL_NODE_YAML="+missing, + "APEROD_NODE_YAML="+missing, "SECONDARY_DATA_DIR="+data, + "JOIN_EXTERNAL_CALLS="+calls, "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "peer_whitelist") || + !strings.Contains(string(out), "install-node.sh") { + t.Fatalf("missing config must fail with install guidance: %v\n%s", err, out) + } + body, err := os.ReadFile(marker) + if err != nil || string(body) != "original" { + t.Fatalf("existing chain data changed: %v %q", err, body) + } + if _, err := os.Stat(calls); !os.IsNotExist(err) { + t.Fatalf("external operations attempted before rejecting missing config: %v", err) + } + }) + } +} 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 deleted file mode 100644 index 35b5da8..0000000 --- a/deploy/publish_public_repo.py +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env python3 -"""Scan an immutable source distribution before uploading any public objects.""" -import argparse -import base64 -import json -import os -from pathlib import Path -from importlib.metadata import PackageNotFoundError, distribution -import shutil -import subprocess -import sys -import tempfile -import time - -from publication_security import git, inspect, inspect_history, path_problem -from sync_public_repo import API, AUTHOR, make_request - -EXTENSIONS = {".go", ".sh", ".py", ".mod", ".sum", ".yaml", ".yml", - ".toml", ".md", ".txt", ".json"} -# Build metadata is part of the verified source tree even when the filename -# has no extension. Keep public CI/container builds in sync with the source. -SOURCE_BASENAMES = {"Dockerfile", "Makefile"} -EXCLUDED = {".git", "data", "build", "node_modules", "__pycache__"} -IGNORES = """ -# Runtime state and credentials never belong in a source distribution. -data/testnet/ -data/mainnet/ -data/devnet/ -snapshots/ -*.key -*.keyfile -*.pem -.env -.env.* -!.env.example -!.env.sample -aperod -cli -aperod-node -build/ -__pycache__/ -""" -MESSAGE = "chore: maintain verified source distribution" -MERGE_MESSAGE = "Verified source distribution through protected pull request." -GITHUB_ACTIONS_APP_ID = 15368 -TRUSTED_CHECK_APP_IDS = {"CodeQL": 57789} -REQUIRED_CHECKS = { - "Whole-tree publication security", - "go build & vet & test", - "install-node.sh e2e smoke test (install)", - "install-node.sh e2e smoke test (validator)", - "install-node.sh e2e smoke test (update)", - "install-node.sh e2e smoke test (upgrade)", - "CodeQL", - "Analyze (actions)", - "Analyze (go)", - "Analyze (javascript-typescript)", - "Analyze (python)", -} -CODEQL_NEUTRAL_ALLOWED_TITLE = "1 configuration not found" - - -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. - allowed = {"PATH", "HOME", "TMPDIR", "LANG", "TZ", "GOTOOLCHAIN", - "GOPATH", "GOMAXPROCS", "GOSUMDB", "GOCACHE", "GOPROXY", - "CGO_ENABLED", "GOROOT", "LD_LIBRARY_PATH", "NIX_LD"} - env = {k: v for k, v in os.environ.items() if k in allowed} - if test_python_userbase is not None: - if args[:2] != ("go", "test"): - raise ValueError("isolated Python test dependencies are only for Go tests") - # Do not overwrite PYTHONPATH: Go test fixtures may provide their own - # isolated stubs. Python's user-site path supplies PyYAML as a fallback. - env["PYTHONUSERBASE"] = str(test_python_userbase) - subprocess.run(list(args), cwd=cwd, check=True, env=env) - - -def prepare_test_python_userbase(destination): - """Expose only the trusted PyYAML package to candidate Go tests.""" - try: - dist = distribution("PyYAML") - package = Path(dist.locate_file("yaml")).resolve() - dist_root = Path(dist.locate_file("")).resolve() - except (PackageNotFoundError, OSError) as exc: - raise RuntimeError( - "PyYAML is required for exported deployment tests; " - "refusing to treat incomplete coverage as a pass" - ) from exc - if (not package.is_dir() or not (package / "__init__.py").is_file() - or not package.is_relative_to(dist_root)): - raise RuntimeError("PyYAML resolved outside its installed distribution") - site_packages = ( - Path(destination) / "lib" - / f"python{sys.version_info.major}.{sys.version_info.minor}" - / "site-packages" - ) - site_packages.mkdir(parents=True, exist_ok=True) - shutil.copytree(package, site_packages / "yaml") - return Path(destination) - - -def prepare(root, candidate, gate_only=False): - source = root / "blockchain" if (root / "blockchain/go.mod").exists() else root - # Only runtime artifacts in the disposable clone are removed. This tool - # never opens a production data directory or performs a server checkout. - for raw in git(candidate, "ls-files", "-s", "-z").split(b"\0"): - if not raw: - continue - metadata, name = raw.split(b"\t", 1) - mode = metadata.decode().split()[0] - rel = name.decode() - if path_problem(rel, mode): - (candidate / rel).unlink() - if gate_only: - selected = [source / "deploy" / name for name in ( - "publication_security.py", "test_publication_security.py", - "publish_public_repo.py", "test_publish_public_repo.py", - "sync_public_repo.py", "test_sync_public_repo.py")] - selected += [source / ".github/workflows/publication-security.yml"] - else: - selected = [] - for directory, dirs, files in os.walk(source, followlinks=False): - dirs[:] = [d for d in dirs if d not in EXCLUDED - and not (Path(directory) / d).is_symlink()] - for name in files: - file = Path(directory) / name - rel = file.relative_to(source) - if (file.is_file() and not file.is_symlink() - and (file.suffix in EXTENSIONS or file.name in SOURCE_BASENAMES) - and not path_problem(rel.as_posix()) - and rel.name not in ("README.md", "README-public.md")): - selected.append(file) - for file in selected: - if not file.is_file() or file.is_symlink(): - raise RuntimeError("Missing or unsafe required publication source") - rel = file.relative_to(source) - dest = candidate / rel - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(file, dest) - if not gate_only and (source / "deploy/README-public.md").exists(): - shutil.copyfile(source / "deploy/README-public.md", candidate / "README.md") - if not gate_only: - # Source/test removals must be atomic with additions. Keep public-only - # documentation and branding; remove obsolete Go files bidirectionally. - local_go = {f.relative_to(source).as_posix() for f in selected if f.suffix == ".go"} - for raw in git(candidate, "ls-files", "-z").split(b"\0"): - rel = raw.decode() - if rel.endswith(".go") and rel not in local_go and (candidate / rel).exists(): - (candidate / rel).unlink() - ignore = candidate / ".gitignore" - text = ignore.read_text() if ignore.exists() else "" - if "# Runtime state and credentials" not in text: - ignore.write_text(text.rstrip() + "\n" + IGNORES) - run("git", "add", "-A", cwd=candidate) - return git(candidate, "write-tree").decode().strip() - - -def verify(candidate, tree, go_checks=True, with_tests=False): - checked, failures = inspect(candidate, tree=tree) - if failures: - raise RuntimeError("Publication policy refused paths: " + - ", ".join(path for path, _ in failures)) - if not shutil.which("gitleaks"): - raise RuntimeError("Secret scanner missing; publication refused") - commits, pairs, history_failures = inspect_history(candidate) - if history_failures: - raise RuntimeError("Historical publication policy refused artifacts; " - "approved history remediation is required before upload") - run("gitleaks", "git", str(candidate), "--redact=100", "--no-banner", - "--log-opts=--all") - # Export exactly the frozen index. Ignored/untracked worktree contents - # cannot diverge from the Git objects later uploaded. - with tempfile.TemporaryDirectory(prefix="aperod-source-check-") as temp: - export = Path(temp) - run("git", "checkout-index", "--all", "--prefix=" + temp + "/", cwd=candidate) - run("gitleaks", "dir", temp, "--redact=100", "--no-banner") - for test in ("test_publication_security.py", "test_publish_public_repo.py", - "test_sync_public_repo.py"): - run("python3", str(export / "deploy" / test), cwd=export) - if go_checks: - run("go", "build", "./...", cwd=export) - run("go", "vet", "./...", cwd=export) - run("go", "run", "golang.org/x/vuln/cmd/govulncheck@v1.1.4", "./...", cwd=export) - if with_tests: - with tempfile.TemporaryDirectory(prefix="aperod-test-python-") as python_temp: - python_userbase = prepare_test_python_userbase(python_temp) - run("go", "test", "./...", cwd=export, - test_python_userbase=python_userbase) - if git(candidate, "write-tree").decode().strip() != tree: - raise RuntimeError("Candidate changed during verification") - print(f"Verified immutable candidate: {checked} files.") - - -def publish(candidate, tree, expected_head, request, with_tests=False): - # Independently revalidate everything before the first upload; callers - # cannot bypass verification by invoking this Python API directly. - verify(candidate, tree, with_tests=with_tests) - 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 - frozen = {} - for row in git(candidate, "ls-tree", "-rz", tree).split(b"\0"): - if row: - meta, path = row.split(b"\t", 1) - mode, kind, oid = meta.decode().split() - frozen[path.decode()] = (mode, oid) - entries = [] - for raw in changed: - if not raw: - continue - rel = raw.decode() - if rel not in frozen: - entries.append({"path": rel, "mode": "100644", "type": "blob", "sha": None}) - continue - mode, oid = frozen[rel] - content = git(candidate, "cat-file", "blob", oid) - blob = request("POST", f"{API}/git/blobs", { - "content": base64.b64encode(content).decode(), "encoding": "base64"}) - 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}) - 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", { - "title": "chore: maintain verified source distribution", - "head": branch, "base": "main", - "body": "Complete immutable source tree passed artifact policy, redacted secret scanning, " - "Go build, vet and dependency vulnerability checks before object upload. " - "No production runtime state is changed."}) - pr["_verified_head_sha"] = commit["sha"] - print("Verified publication awaiting required checks:", pr["html_url"]) - return pr - - -def await_merge(pr, request, timeout=900, expected_head_sha=None): - expected_head_sha = expected_head_sha or pr.get("_verified_head_sha") - if not expected_head_sha: - raise RuntimeError("Verified uploaded PR head SHA is required") - deadline = time.monotonic() + timeout - required = REQUIRED_CHECKS - while time.monotonic() < deadline: - pull = request("GET", f"{API}/pulls/{pr['number']}") - if pull.get("merged"): - print("Verified publication merged:", pr["html_url"]) - return - if pull["state"] != "open": - raise RuntimeError("Publication PR closed without merging") - if pull.get("base", {}).get("ref") != "main": - raise RuntimeError("Publication PR base is not main") - if pull.get("head", {}).get("sha") != expected_head_sha: - time.sleep(10) - continue - sha = expected_head_sha - checks = request("GET", f"{API}/commits/{sha}/check-runs?per_page=100")["check_runs"] - trusted = { - c["name"]: c for c in sorted(checks, key=lambda c: c.get("id", 0)) - if c.get("app", {}).get("id") == - TRUSTED_CHECK_APP_IDS.get(c["name"], GITHUB_ACTIONS_APP_ID) - } - for name in required: - check = trusted.get(name) - if check and check["status"] == "completed" and check["conclusion"] != "success": - if name != "CodeQL" or check["conclusion"] != "neutral": - raise RuntimeError("Required publication check failed: " + name) - other_checks_passed = all( - other == "CodeQL" or ( - other in trusted - and trusted[other]["status"] == "completed" - and trusted[other]["conclusion"] == "success" - ) - for other in required - ) - if not other_checks_passed: - continue - output = check.get("output") or {} - summary = output.get("summary") or "" - analyze_go = trusted.get("Analyze (go)") or {} - if (output.get("title") != CODEQL_NEUTRAL_ALLOWED_TITLE - or "Default setup" not in summary - or "/language:go" not in summary - or analyze_go.get("status") != "completed" - or analyze_go.get("conclusion") != "success"): - raise RuntimeError( - "Required publication check failed: CodeQL " - "(neutral result lacks a completed clean Go analysis)" - ) - alerts = request( - "GET", - f"{API}/code-scanning/alerts?pr={pr['number']}" - "&tool_name=CodeQL&state=open&per_page=100", - ) - if not isinstance(alerts, list) or alerts: - raise RuntimeError( - "Required publication check failed: CodeQL " - "(open pull-request alerts or unreadable alert results)" - ) - if all( - name in trusted and trusted[name]["status"] == "completed" - and (trusted[name]["conclusion"] == "success" - or (name == "CodeQL" and trusted[name]["conclusion"] == "neutral")) - for name in required - ): - 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): - raise RuntimeError("Publication PR head or base changed after checks") - result = request("PUT", f"{API}/pulls/{pr['number']}/merge", { - "sha": expected_head_sha, "merge_method": "merge", - "commit_title": "chore: maintain verified source distribution", - "commit_message": MERGE_MESSAGE}) - if not result.get("merged"): - raise RuntimeError("Required branch policy refused the verified merge") - print("Verified publication merged:", pr["html_url"]) - return - time.sleep(10) - raise RuntimeError("Publication checks did not complete; main was not updated") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(".")) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--gate-only", action="store_true") - parser.add_argument("--with-tests", action="store_true") - parser.add_argument("--no-wait", action="store_true") - parser.add_argument("--check-drift", action="store_true") - args = parser.parse_args() - token = os.environ.get("PUBLIC_GITHUB_TOKEN") - if not args.dry_run and not args.check_drift and not token: - raise SystemExit("PUBLIC_GITHUB_TOKEN must be provided through the environment") - with tempfile.TemporaryDirectory(prefix="aperod-publication-") as temp: - candidate = Path(temp) / "repo" - run("git", "clone", "-q", - "https://github.com/aperod-network/aperod-node", str(candidate)) - # GitHub keeps closed pull-request refs outside ordinary clone refs. - # Audit them too; history cleanup must not silently ignore their blobs. - run("git", "fetch", "--quiet", "origin", - "+refs/pull/*/head:refs/public-audit/pull/*/head", cwd=candidate) - head = git(candidate, "rev-parse", "HEAD").decode().strip() - tree = prepare(args.root.resolve(), candidate, args.gate_only) - if args.check_drift: - changed = [x for x in git(candidate, "diff", "--cached", "--name-only", "-z").split(b"\0") if x] - print(f"Complete source-distribution drift: {len(changed)} files.") - if changed: - raise SystemExit(1) - elif args.dry_run: - verify(candidate, tree, with_tests=args.with_tests) - print("Dry-run verified; no objects uploaded.") - else: - request = make_request(token) - pr = publish(candidate, tree, head, request, args.with_tests) - if pr and not args.no_wait: - await_merge(pr, request, expected_head_sha=pr["_verified_head_sha"]) - - -if __name__ == "__main__": - main() \ No newline at end of file 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/security_mitigations_test.go b/deploy/security_mitigations_test.go new file mode 100644 index 0000000..015f732 --- /dev/null +++ b/deploy/security_mitigations_test.go @@ -0,0 +1,148 @@ +package deploy_test + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestJoinRejectsRemotePlainHTTPBeforeTouchingNodeData(t *testing.T) { + cmd := exec.Command("bash", "aperod-join.sh", "203.0.113.8:8545") + cmd.Env = append(os.Environ(), + "APEROD_NODE_YAML=/path/that/does/not/exist/node.yaml", + "APEROD_NODE_CONFIG_SH=/path/that/does/not/exist/node-config.sh", + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("remote plaintext sync unexpectedly succeeded") + } + if !strings.Contains(string(output), "Удалённая загрузка по HTTP запрещена") { + t.Fatalf("remote HTTP rejection was not the first failure:\n%s", output) + } +} + +func TestJoinTunnelRequiresPinnedHostKey(t *testing.T) { + script, err := os.ReadFile("aperod-join.sh") + if err != nil { + t.Fatal(err) + } + text := string(script) + if !strings.Contains(text, "StrictHostKeyChecking=yes") { + t.Fatal("SSH tunnel does not require a pre-pinned host key") + } + if strings.Contains(text, "StrictHostKeyChecking=accept-new") { + t.Fatal("SSH tunnel still trusts an unverified first-use host key") + } +} + +func TestInstallerWalletSetupNeverPersistsRawPrivateKey(t *testing.T) { + script, err := os.ReadFile("install-node.sh") + if err != nil { + t.Fatal(err) + } + text := string(script) + for _, required := range []string{ + `aperod wallet create --output "${WALLET_FILE}"`, + `aperod wallet restore --output "${WALLET_FILE}"`, + `[[ -s "${WALLET_FILE}" ]] || die`, + } { + if !strings.Contains(text, required) { + t.Errorf("secure wallet setup missing %q", required) + } + } + for _, forbidden := range []string{ + "PRIVKEY", + "wallet import", + `echo -n "${PRIVKEY}"`, + "aperod wallet address", + } { + if strings.Contains(text, forbidden) { + t.Errorf("unsafe or unsupported wallet setup remains: %q", forbidden) + } + } +} + +func TestGoToolchainInstallersVerifyOfficialChecksumsBeforeExtraction(t *testing.T) { + for _, path := range []string{"install-node.sh", "install-validator.sh"} { + script, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(script) + for _, required := range []string{ + `GO_VERSION="1.26.11"`, + `9849f4e04ee35303714e34fbc3ac8ab44ea3e448a95aa182f91e3b9b79c710ec`, + `6c2f29b458b5add0835f18b776f69c8881a6d88428f1484a6da55755e620757e`, + `mktemp /tmp/aperod-go.XXXXXX.tar.gz`, + `sha256sum --check --status`, + } { + if !strings.Contains(text, required) { + t.Errorf("%s is missing verified Go installation step %q", path, required) + } + } + if strings.Index(text, "sha256sum --check --status") > strings.Index(text, `tar -C /usr/local -xzf "$GO_ARCHIVE"`) { + t.Errorf("%s extracts the Go archive before checking its checksum", path) + } + } +} + +func TestPublicValidatorGuidesRequirePinnedInstallerSource(t *testing.T) { + validators, err := os.ReadFile("VALIDATORS.md") + if err != nil { + t.Fatal(err) + } + investors, err := os.ReadFile("INVESTORS.md") + if err != nil { + t.Fatal(err) + } + morningReport, err := os.ReadFile("aperod-morning-report.sh") + if err != nil { + t.Fatal(err) + } + systemCheck, err := os.ReadFile("check-system.sh") + if err != nil { + t.Fatal(err) + } + for _, text := range []string{string(validators), string(investors)} { + for _, unsafe := range []string{ + "raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash", + "raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/uninstall-validator.sh | sudo bash", + } { + if strings.Contains(text, unsafe) { + t.Errorf("public validator guide still pipes mutable-main script to root: %q", unsafe) + } + } + } + for _, text := range []string{string(morningReport), string(systemCheck)} { + for _, unsafe := range []string{ + "https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/aperod-morning-report.sh", + "curl https://rclone.org/install.sh | sudo bash", + } { + if strings.Contains(text, unsafe) { + t.Errorf("public operator instructions still run mutable remote code as root: %q", unsafe) + } + } + } + for _, required := range []string{ + "APEROD_NODE_SOURCE_COMMIT", + `[[ ! "$APEROD_NODE_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]`, + "git checkout --detach", + `git cat-file -e "${APEROD_NODE_SOURCE_COMMIT}^{commit}"`, + "test \"$(git rev-parse HEAD)\" = \"$APEROD_NODE_SOURCE_COMMIT\"", + "sudo env APEROD_NODE_SOURCE_COMMIT=", + } { + if !strings.Contains(string(validators), required) { + t.Errorf("pinned installation procedure is missing %q", required) + } + } + if !strings.Contains(string(investors), "VALIDATORS.md#setup-guide") { + t.Fatal("investor quick-start does not link to the pinned validator setup") + } + if !strings.Contains(string(morningReport), "install -o root -g root -m 0755 deploy/aperod-morning-report.sh") { + t.Fatal("morning-report installation does not use the checked-out source") + } + if !strings.Contains(string(systemCheck), "подписанного репозитория") { + t.Fatal("system check recommends no trusted package source for rclone") + } +} diff --git a/deploy/setup-backup.sh b/deploy/setup-backup.sh index 55f7800..4d9ef0d 100755 --- a/deploy/setup-backup.sh +++ b/deploy/setup-backup.sh @@ -57,7 +57,7 @@ trap cleanup_verify_tmp EXIT if ! ( cd "${BLOCKCHAIN_DIR}" - CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.10+auto \ + CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.11+auto \ go build -o "${VERIFY_TMP}" ./cmd/backup-verify ); then die "Не удалось собрать aperod-backup-verify; существующий бинарный файл не изменён" 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/static_binary_test.go b/deploy/static_binary_test.go index f7c49c5..a9e4eb3 100644 --- a/deploy/static_binary_test.go +++ b/deploy/static_binary_test.go @@ -36,6 +36,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -335,6 +336,134 @@ func TestUpdateNodeStaticGuard(t *testing.T) { t.Log("Case 2 ✓: aperod-node passes the static-link guard — update-node.sh would not abort ✓") } +// TestUpdateExplorerIndexerStaticGuard exercises the guard used by the real +// monorepo upgrade entrypoint, with both a genuinely dynamic ELF and a fresh +// Makefile-built indexer. No candidate or deployment entrypoint is executed. +func TestUpdateExplorerIndexerStaticGuard(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("explorer-indexer ELF/ldd guard requires Linux") + } + if _, err := exec.LookPath("ldd"); err != nil { + t.Skip("ldd unavailable; explorer-indexer guard cases cannot run") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash unavailable; cannot invoke the actual guard") + } + deployDir, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + blockchainDir := filepath.Dir(deployDir) + guardPath := filepath.Join(deployDir, "explorer-indexer-static-guard.sh") + if _, err := os.Stat(guardPath); err != nil { + t.Fatalf("actual explorer-indexer guard missing: %v", err) + } + runGuard := func(binary string) ([]byte, error) { + cmd := exec.Command(bash, "-c", + `set -euo pipefail; source "$1"; explorer_indexer_static_guard "$2"`, + "indexer-static-guard-test", guardPath, binary) + return cmd.CombinedOutput() + } + + t.Run("dynamic_binary_is_rejected", func(t *testing.T) { + var dynamicBin string + candidates := []string{"/bin/ls", "/usr/bin/ls", "/bin/cat"} + if found, err := exec.LookPath("ls"); err == nil { + candidates = append(candidates, found) + } + for _, candidate := range candidates { + f, err := elf.Open(candidate) + if err != nil { + continue + } + for _, prog := range f.Progs { + if prog.Type == elf.PT_INTERP { + dynamicBin = candidate + break + } + } + f.Close() + if dynamicBin != "" { + break + } + } + if dynamicBin == "" { + t.Skip("no dynamic ELF with PT_INTERP available for the negative control") + } + out, err := runGuard(dynamicBin) + if err == nil { + t.Fatalf("actual upgrade guard accepted dynamic ELF %s:\n%s", dynamicBin, out) + } + if !strings.Contains(string(out), "Static-link check FAILED") || + !strings.Contains(string(out), "CGO_ENABLED=0") { + t.Fatalf("guard failed for the wrong reason:\n%s", out) + } + t.Logf("dynamic ELF rejected by actual guard: %s", dynamicBin) + }) + + t.Run("fresh_static_indexer_is_accepted", func(t *testing.T) { + makeBin, err := exec.LookPath("make") + if err != nil { + t.Skip("make unavailable; cannot build the static positive control") + } + buildDir := t.TempDir() + cmd := exec.Command(makeBin, "BUILD_DIR="+buildDir, "build-explorer-indexer") + cmd.Dir = blockchainDir + // The Makefile must override this ambient setting itself. Do not make + // the test pass by silently forcing CGO_ENABLED=0 outside the target. + cmd.Env = append(os.Environ(), "CGO_ENABLED=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("make build-explorer-indexer failed: %v\n%s", err, out) + } + indexer := filepath.Join(buildDir, "aperod-explorer-indexer") + assertNoInterp(t, indexer) + out, err := runGuard(indexer) + if err != nil { + t.Fatalf("actual upgrade guard rejected the freshly built indexer: %v\n%s", err, out) + } + if !strings.Contains(string(out), "ldd confirmed a statically linked binary") { + t.Fatalf("guard succeeded without confirming the static positive control:\n%s", out) + } + t.Log("fresh Makefile-built indexer passes the actual upgrade guard") + }) + + t.Run("upgrade_wires_guard_before_service_stop", func(t *testing.T) { + monorepoRoot := filepath.Dir(blockchainDir) + script, err := os.ReadFile(filepath.Join(monorepoRoot, "deploy", "deploy-server.sh")) + if err != nil { + if _, workspaceErr := os.Stat(filepath.Join(monorepoRoot, "pnpm-workspace.yaml")); os.IsNotExist(workspaceErr) && os.IsNotExist(err) { + // Public node-only distributions omit the monorepo installer. + // Both real-binary guard cases above still run there. + t.Skip("monorepo upgrade entrypoint is not part of the standalone node distribution") + } + t.Fatalf("cannot read the monorepo upgrade entrypoint: %v", err) + } + text := string(script) + position := func(pattern string) int { + t.Helper() + match := regexp.MustCompile(pattern).FindStringIndex(text) + if match == nil { + t.Fatalf("upgrade entrypoint lost required command matching %s", pattern) + } + return match[0] + } + source := position(`(?m)^source "\$\{SCRIPT_DIR\}/../blockchain/deploy/explorer-indexer-static-guard\.sh"$`) + build := position(`(?m)^[ \t]*make BUILD_DIR="\$preflight_dir" build-explorer-indexer \|\| exit 1$`) + guard := position(`(?m)^[ \t]*explorer_indexer_static_guard "\$preflight_dir/aperod-explorer-indexer" \|\| exit 1$`) + stopNode := position(`(?m)^systemctl stop aperod-node\b`) + stopIndexer := position(`(?m)^systemctl stop aperod-explorer-indexer\b`) + if !(source < build && build < guard && guard < stopNode && guard < stopIndexer) { + t.Fatal("the built candidate must pass the guard before either running service is stopped") + } + finalGuard := position(`(?m)^explorer_indexer_static_guard "\$APP_DIR/blockchain/build/aperod-explorer-indexer" \|\| error `) + enable := position(`(?m)^systemctl enable --now aperod-explorer-indexer\b`) + if finalGuard <= guard || finalGuard >= enable { + t.Fatal("the final rebuilt indexer must be checked again before installation/start") + } + }) +} + // assertLddStatic runs ldd on path and checks that the output contains // "not a dynamic executable". When ldd is not found the check is skipped // with a log message (ldd absence is not a test failure). diff --git a/deploy/sync_public_repo.py b/deploy/sync_public_repo.py deleted file mode 100644 index 4dc8ce7..0000000 --- a/deploy/sync_public_repo.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""Shared GitHub transport; legacy unverified publication is disabled.""" -import hashlib -import json -import urllib.error -import urllib.request - -API = "https://api.github.com/repos/aperod-network/aperod-node" -AUTHOR = {"name": "aperod-network", "email": "aperod-network@users.noreply.github.com"} - - -def git_blob_sha1(content): - return hashlib.sha1(f"blob {len(content)}\0".encode() + content).hexdigest() - - -def make_request(token): - def request(method, url, data=None): - 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) - try: - with urllib.request.urlopen(req) 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. - raise RuntimeError(f"GitHub request failed with HTTP {error.code}") from None - return request - - -def sync(*args, **kwargs): - raise RuntimeError("Legacy publication disabled; use publish_public_repo.py") - - -def main(): - raise SystemExit("Legacy publication disabled; use publish_public_repo.py (token from environment)") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/deploy/test-avm-http-stress.sh b/deploy/test-avm-http-stress.sh index cd94530..e69a331 100644 --- a/deploy/test-avm-http-stress.sh +++ b/deploy/test-avm-http-stress.sh @@ -29,5 +29,5 @@ GOMEMLIMIT="${GOMEMLIMIT:-512MiB}" \ GOCACHE="${GOCACHE:-${scratch}/go-cache}" \ TMPDIR="${scratch}" \ GOSUMDB=sum.golang.org \ -GOTOOLCHAIN=go1.26.10+auto \ +GOTOOLCHAIN=go1.26.11+auto \ go test -p 1 ./consensus -run '^TestAVMMixedHTTPStress$' -count=1 -v -timeout=8m diff --git a/deploy/test-avm-stress.sh b/deploy/test-avm-stress.sh index 34f5462..858baa8 100755 --- a/deploy/test-avm-stress.sh +++ b/deploy/test-avm-stress.sh @@ -21,5 +21,5 @@ APEROD_RUN_AVM_STRESS=1 \ APEROD_AVM_STRESS_DURATION="${duration}" \ APEROD_AVM_STRESS_WORKERS="${workers}" \ GOSUMDB=sum.golang.org \ -GOTOOLCHAIN=go1.26.10+auto \ +GOTOOLCHAIN=go1.26.11+auto \ go test ./consensus -run '^TestAVMSpamDoesNotDelayBlockProduction$' -count=1 -v -timeout=7m \ No newline at end of file diff --git a/deploy/test-avm-two-validator.sh b/deploy/test-avm-two-validator.sh index 8f3490f..bb8c5c8 100755 --- a/deploy/test-avm-two-validator.sh +++ b/deploy/test-avm-two-validator.sh @@ -13,4 +13,4 @@ fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "${script_dir}/.." -GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.10+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file +GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.11+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file diff --git a/deploy/test-backup-logical.sh b/deploy/test-backup-logical.sh index 43e3cd3..62caf77 100644 --- a/deploy/test-backup-logical.sh +++ b/deploy/test-backup-logical.sh @@ -34,7 +34,8 @@ else pass "live node data directory is not archived directly" fi for required in 'chain.db' 'manifest.json' 'explorer_db.dump' 'sha256sum' \ - 'unix-socket' '_verify_remote_archive' 'CHAIN_DATA_DIR' \ + 'unix-socket' '_verify_remote_archive' 'rclone cat' '--max-time 3600' 'CHAIN_DATA_DIR' \ + 'LOCAL_VERIFY_OUTPUT=$("$BACKUP_VERIFY_BIN" --stage "$SNAPSHOT_DIR")' \ '_cleanup_validated_snapshot' 'realpath -e' 'PREVIOUS_OBJECT' \ 'uploads[:keep_count]' 'du -sk --apparent-size' \ 'APEROD_BACKUP_VERIFY_BIN:-/usr/local/bin/aperod-backup-verify' \ @@ -43,11 +44,19 @@ for required in 'chain.db' 'manifest.json' 'explorer_db.dump' 'sha256sum' \ 'LEGACY_OBJECT_PREFIX="${BACKUP_NAME}_legacy_"' '--legacy-stage' \ 'preserved-legacy-copy.tar.gpg' 'aperod_backup_legacy_migration' \ 'BACKUP_ROOT_REQUESTED="${APEROD_BACKUP_DIR_OVERRIDE}"' \ - 'mktemp -d -- "${BACKUP_ROOT%/}/aperod_backups_XXXXXX"' 'BACKUP_DIR_OWNED=1'; do + 'mktemp -d -- "${BACKUP_ROOT%/}/aperod_backups_XXXXXX"' 'BACKUP_DIR_OWNED=1' \ + 'APEROD_BACKUP_CHECKPOINT_RESERVE_BYTES' 'STAGING_USED_BYTES' 'PROJECTED_STAGING_BYTES'; do if grep -qF -- "$required" "$BACKUP_SH"; then pass "backup implementation contains $required" else fail "backup implementation is missing $required"; fi done -DB_PREFLIGHT_LINE=$(grep -n 'CHAIN_REQUIRED_KB=' "$BACKUP_SH" | head -1 | cut -d: -f1) +LOCAL_VERIFY_LINE=$(grep -n 'LOCAL_VERIFY_OUTPUT=$("$BACKUP_VERIFY_BIN" --stage "$SNAPSHOT_DIR")' "$BACKUP_SH" | head -1 | cut -d: -f1) +ARCHIVE_CREATE_LINE=$(grep -n '^tar -czf -' "$BACKUP_SH" | head -1 | cut -d: -f1) +if [[ -n "$LOCAL_VERIFY_LINE" && -n "$ARCHIVE_CREATE_LINE" && "$LOCAL_VERIFY_LINE" -lt "$ARCHIVE_CREATE_LINE" ]]; then + pass "the closed checkpoint is read-only verified before archive creation" +else + fail "the checkpoint is archived before its read-only LevelDB verification" +fi +DB_PREFLIGHT_LINE=$(grep -n 'CHAIN_REQUIRED_BYTES=' "$BACKUP_SH" | head -1 | cut -d: -f1) CHECKPOINT_POST_LINE=$(grep -n '"http://localhost/checkpoint"' "$BACKUP_SH" | head -1 | cut -d: -f1) if [[ -n "$DB_PREFLIGHT_LINE" && -n "$CHECKPOINT_POST_LINE" && "$DB_PREFLIGHT_LINE" -lt "$CHECKPOINT_POST_LINE" ]]; then pass "active chain.db size plus reserve is checked before checkpoint request" @@ -443,6 +452,12 @@ REMOTE_PREVIOUS_SHA=$(sha256sum "$REMOTE_COPY_ROOT/previous.tar.gpg" | awk '{pri rclone() { local action="$1" source="$2" target="${3:-}" file ignore_times=0 shift 3 || true + if [[ "$action" == cat ]]; then + file="$REMOTE_COPY_ROOT/store/${source##*/}" + printf '%s -> stdout\n' "$source" >> "$REMOTE_COPY_ROOT/cat.log" + cat "$file" + return + fi if [[ "$action" == copyto ]]; then for argument in "$@"; do [[ "$argument" != --ignore-times ]] || ignore_times=1; done printf '%s\n' "$source $target $*" >> "$REMOTE_COPY_ROOT/copy.log" @@ -723,8 +738,6 @@ if not os.path.isdir(os.path.join(root, "chain.db")): raise SystemExit("missing new-format chain.db") with open(os.path.join(root, "manifest.json"), encoding="utf-8") as source: manifest = json.load(source) -if not os.path.isfile(os.path.join(root, "explorer_db.dump")): - raise SystemExit("missing new-format pg dump") print(json.dumps({"success": True, "tip_hash": manifest["tip_hash"], "tip_height": manifest["tip_height"]})) PY @@ -778,6 +791,11 @@ PY fi fi ;; + cat) + source="$2" + printf '%s -> stdout\n' "$source" >> "$MOCK_DOWNLOAD_LOG" + cat "$store/$(remote_path "$source")" + ;; size) file="$store/$(remote_path "$2")" python3 - "$file" <<'PY' diff --git a/deploy/test-backup.sh b/deploy/test-backup.sh index 17fd09f..4e2daac 100644 --- a/deploy/test-backup.sh +++ b/deploy/test-backup.sh @@ -256,6 +256,11 @@ import json, os, sys print(json.dumps({"count": 1, "bytes": os.path.getsize(sys.argv[1])})) PY ;; + cat) + file="$store/$(base_name "$2")" + [[ -f "$file" ]] || { echo "missing remote fixture: $file" >&2; exit 1; } + cat "$file" + ;; deletefile) rm -f "$store/$(base_name "$2")" ;; @@ -361,6 +366,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 +389,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" @@ -1641,17 +1656,18 @@ else fi # ============================================================================= -# Test 19: pre-export checkpoint reserve gates the request and preserves its parent +# Test 19: pre-export checkpoint-size estimate and reserve gate the request # # Scenario A — enough space for checkpoint export: -# df reports more than chain.db size + the 5 GiB reserve. The checkpoint is -# requested and the backup proceeds to the intentionally failing pg_dump. +# The largest retained checkpoint is 3 GiB, the active DB is 2 GiB, and +# 9 GiB is free. The default 5 GiB reserve fits, so checkpoint is requested. # # Scenario B — insufficient reserve: -# df reports 4 GiB for a 2 GiB chain.db, so the run skips before requesting -# the checkpoint. This protects the source filesystem from export exhaustion. +# The retained checkpoint is 3 GiB, current staging is 4 GiB, and the +# configured reserve is 6 GiB. With only 8 GiB free, the run skips before +# requesting the checkpoint and reports exact byte counts. # ============================================================================= -section "Test 19: pre-export checkpoint reserve gates checkpoint request" +section "Test 19: recent checkpoint size and reserve gate checkpoint request" T19_DIR=$(mktemp -d "$TMPDIR_TEST/run-t19-XXXXXXXX") T19_NODE_DATA_DIR=$(mktemp -d "$TMPDIR_TEST/nodedata-t19-XXXXXXXX") @@ -1690,11 +1706,18 @@ fi STUB chmod +x "$T19_FAKE_FIND_DIR/find" -# du stub: return 2 GiB for any path (stale dir size + scaled preflight) +# du stub: active chain.db is 2 GiB, largest retained checkpoint is 3 GiB, +# and total current staging is 4 GiB. T19_FAKE_DU_DIR=$(mktemp -d "$TMPDIR_TEST/fake-du-t19-XXXXXXXX") cat >"$T19_FAKE_DU_DIR/du" <<'STUB' #!/usr/bin/env bash -echo "2097152 ${@: -1}" +path="${@: -1}" +case "$path" in + */.backup-staging/checkpoint-*) echo "3145728 $path" ;; + */.backup-staging) echo "4194304 $path" ;; + */chain.db) echo "2097152 $path" ;; + *) echo "2097152 $path" ;; +esac exit 0 STUB chmod +x "$T19_FAKE_DU_DIR/du" @@ -1710,15 +1733,15 @@ fi STUB chmod +x "$T19_FAKE_RM_DIR/rm" -# df stub: BACKUP_DIR returns 20 GiB; the node filesystem returns 8 GiB, enough -# for the 2 GiB chain.db fixture plus the 5 GiB reserve. +# df stub: BACKUP_DIR returns 20 GiB; the node filesystem returns 9 GiB, +# enough for the 3 GiB retained checkpoint estimate plus the 5 GiB reserve. T19_FAKE_DF_DIR=$(mktemp -d "$TMPDIR_TEST/fake-df-t19-XXXXXXXX") cat >"$T19_FAKE_DF_DIR/df" <"$T19B_FAKE_DF_DIR/df" <"$T19B_FAKE_DU_DIR/du" <<'STUB' #!/usr/bin/env bash -echo "2097152 ${@: -1}" +path="${@: -1}" +case "$path" in + */.backup-staging/checkpoint-*) echo "3145728 $path" ;; + */.backup-staging) echo "4194304 $path" ;; + */chain.db) echo "2097152 $path" ;; + *) echo "2097152 $path" ;; +esac exit 0 STUB chmod +x "$T19B_FAKE_DU_DIR/du" @@ -1862,6 +1892,7 @@ chmod +x "$T19B_FAKE_TAR_DIR/tar" T19B_EXIT=99 APEROD_BACKUP_PASSWORD="test-pass-t19b" \ + APEROD_BACKUP_CHECKPOINT_RESERVE_BYTES="6442450944" \ DATA_DIR="$T19B_DIR/data" \ APEROD_TEXTFILE_DIR="$T19B_DIR/metrics" \ APEROD_HISTORY_LOG="$T19B_DIR/backup.log" \ @@ -1874,7 +1905,7 @@ APEROD_BACKUP_PASSWORD="test-pass-t19b" \ [[ "$T19B_EXIT" -eq 99 ]] && T19B_EXIT=0 # script returned 0 if [[ "$T19B_EXIT" -eq 0 ]]; then - pass "Scenario B: exits 0 when 4 GiB cannot cover DB plus 5 GiB reserve" + pass "Scenario B: exits 0 when 8 GiB cannot cover retained checkpoint plus 6 GiB reserve" else fail "Scenario B: exited $T19B_EXIT — expected pre-export skip" fi @@ -1890,6 +1921,19 @@ if [[ ! -f "$T19B_CURL_LOG" ]] || ! grep -q '^checkpoint ' "$T19B_CURL_LOG"; the else fail "Scenario B: checkpoint export was requested despite insufficient reserve" fi +if grep -q "4294967296 байт" "$T19B_CURL_LOG" \ + && grep -q "3221225472 байт" "$T19B_CURL_LOG" \ + && grep -q "7516192768 байт" "$T19B_CURL_LOG"; then + pass "Scenario B: alert reports current staging, recent checkpoint estimate, and projected staging bytes" +else + fail "Scenario B: alert omitted current/projected staging byte totals (log: $(cat "$T19B_CURL_LOG" 2>/dev/null || echo ''))" +fi +if grep -q "8589934592 байт" "$T19B_CURL_LOG" \ + && grep -q "9663676416 байт" "$T19B_CURL_LOG"; then + pass "Scenario B: alert reports exact available and required bytes" +else + fail "Scenario B: alert omitted exact available/required byte totals (log: $(cat "$T19B_CURL_LOG" 2>/dev/null || echo ''))" +fi # ============================================================================= # Test 20: update scripts source sync-backup-script.sh and call _sync_backup_script 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-install-node-primary-ip.sh b/deploy/test-install-node-primary-ip.sh index f8e6a1a..002f78a 100644 --- a/deploy/test-install-node-primary-ip.sh +++ b/deploy/test-install-node-primary-ip.sh @@ -28,14 +28,16 @@ # systemctl stubs) is identical to test-install-node-e2e.sh so the real # install-node.sh runs end-to-end, not a stripped excerpt. # -# Skip condition: Docker not found or daemon unreachable → exit 0 (skip). +# Skip condition: Docker unavailable or no reviewed source commit is provided. +# Infrastructure skips print SKIP_SUMMARY and exit 77. # # Run from anywhere: # bash blockchain/deploy/test-install-node-primary-ip.sh # # Exit codes: -# 0 — all assertions passed (or Docker unavailable → skipped) -# 1 — one or more assertions failed +# 0 — all assertions passed +# 1 — one or more assertions failed +# 77 — skipped (Docker unavailable or reviewed source commit not provided) # ============================================================================= set -uo pipefail @@ -47,15 +49,21 @@ BOLD='\033[1m'; NC='\033[0m' pass() { echo -e "${GREEN} PASS${NC} $*"; } fail() { echo -e "${RED} FAIL${NC} $*"; } section() { echo -e "\n${BOLD}▶ $*${NC}"; } +skip() { + echo -e "${YELLOW}[SKIP]${NC} $*" + echo "SKIP_SUMMARY: install-node-primary-ip: $*" + exit 77 +} # ── Docker availability check ───────────────────────────────────────────────── if ! command -v docker &>/dev/null; then - echo -e "${YELLOW}[SKIP]${NC} Docker not found in PATH — skipping primary-ip test." - exit 0 + skip "Docker not found in PATH." fi if ! docker info &>/dev/null 2>&1; then - echo -e "${YELLOW}[SKIP]${NC} Docker daemon not reachable — skipping primary-ip test." - exit 0 + skip "Docker daemon is not reachable." +fi +if [[ ! "${APEROD_NODE_SOURCE_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]]; then + skip "Set APEROD_NODE_SOURCE_COMMIT to a reviewed full public commit before running the installer rehearsal." fi IMAGE_TAG="aperod-primary-ip-test:latest" @@ -229,17 +237,16 @@ case "${1:-} ${2:-}" in "wallet create") out="" prev="" - for a in "$@"; do [[ "$prev" == "--out" ]] && out="$a"; prev="$a"; done + for a in "$@"; do [[ "$prev" == "--output" ]] && out="$a"; prev="$a"; done if [[ -n "$out" ]]; then mkdir -p "$(dirname "$out")" printf '{"address":"apr1test00000000000000000000000000000000000","version":1}\n' > "$out" fi ;; - "wallet address") echo "apr1test00000000000000000000000000000000000" ;; - "wallet import") + "wallet restore") out="" prev="" - for a in "$@"; do [[ "$prev" == "--out" ]] && out="$a"; prev="$a"; done + for a in "$@"; do [[ "$prev" == "--output" ]] && out="$a"; prev="$a"; done if [[ -n "$out" ]]; then mkdir -p "$(dirname "$out")" printf '{"address":"apr1test00000000000000000000000000000000000","version":1}\n' > "$out" diff --git a/deploy/test-join-network-bootnode.sh b/deploy/test-join-network-bootnode.sh index 9556697..28d90ed 100644 --- a/deploy/test-join-network-bootnode.sh +++ b/deploy/test-join-network-bootnode.sh @@ -745,6 +745,13 @@ STUB #!/usr/bin/env bash [[ "${1:-}" == "is-active" ]] && exit 1 exit 0 +STUB + + # SSH tunnel stub: the host-key policy is exercised in its dedicated test. + # This test only needs the secure tunneled API URL and must not contact a host. + cat > "${STUB15}/ssh" << 'STUB' +#!/usr/bin/env bash +exit 0 STUB # curl stub: @@ -759,7 +766,7 @@ done exit 0 STUB - chmod +x "${STUB15}/id" "${STUB15}/systemctl" "${STUB15}/curl" +chmod +x "${STUB15}/id" "${STUB15}/systemctl" "${STUB15}/ssh" "${STUB15}/curl" # Temp dirs that replace the real system paths via env-var overrides. CFG15="${TMPDIR_TEST}/node-t15.yaml" @@ -777,6 +784,7 @@ STUB APEROD_DROPIN_DIR="${DROPIN15}" \ bash "${APEROD_JOIN_SH}" \ "${PRIMARY_IP}:8545" \ + --tunnel "root@${PRIMARY_IP}" \ --p2p-port 30304 \ --data-dir "${DATA15}" \ --user nonexistentuser_test15 \ diff --git a/deploy/test-join-network-mock-ssh.sh b/deploy/test-join-network-mock-ssh.sh index c31d066..5413c02 100755 --- a/deploy/test-join-network-mock-ssh.sh +++ b/deploy/test-join-network-mock-ssh.sh @@ -69,12 +69,12 @@ if [[ ! -f "${JOIN_SH}" ]]; then fi if ! command -v python3 &>/dev/null; then - skip "M1-M6: python3 not found in PATH" + skip "M1-M8: python3 not found in PATH" finish fi if ! python3 -c "import yaml" 2>/dev/null; then - skip "M1-M6: python3 pyyaml not available" + skip "M1-M8: python3 pyyaml not available" finish fi @@ -138,9 +138,9 @@ count_lines() { wc -l | tr -d ' '; } # Write a canonical nested-schema node.yaml (install-validator.sh output). make_nested_config() { local path="$1" - cat >"$path" <<'YAML' + cat >"$path" <"$path" <<'YAML' + cat >"$path" <"$path" <<'YAML' + cat >"$path" </dev/null printf 'stopped\nremoved\nstarted\n' @@ -650,4 +671,70 @@ fi # ============================================================================= # ── Summary ─────────────────────────────────────────────────────────────────── # ============================================================================= +section "M7: SSH observation detects the NAT outbound IP, not a private interface" +M7_DATA="${TMPDIR_TEST}/m7-data" +mkdir -p "${M7_DATA}/chain.db" +M7_YAML="${TMPDIR_TEST}/m7-node.yaml" +make_nested_config "${M7_YAML}" +M7_REMOTE="${TMPDIR_TEST}/m7-target" +mkdir -p "${M7_REMOTE}" +python3 - "${M7_YAML}" "${M7_REMOTE}" <<'PY' +import sys, yaml, json, os +cfg = yaml.safe_load(open(sys.argv[1])) +cfg['data_dir'] = sys.argv[2] +cfg['p2p']['whitelist_file'] = os.path.join(sys.argv[2], 'operator-peers.json') +json.dump(['203.0.113.20'], open(cfg['p2p']['whitelist_file'], 'w')) +with open(sys.argv[1], 'w') as f: + yaml.safe_dump(cfg, f) +PY +M7_BIN="${TMPDIR_TEST}/m7-bin" +build_systemctl_stub "${M7_BIN}" +build_ssh_stub "${M7_BIN}" 42000 1 198.51.100.8 +make_stub "${M7_BIN}" "rsync" ' +default=0 +custom=0 +for arg in "$@"; do + [[ "$arg" == "--exclude=p2p_whitelist.json" ]] && default=1 + [[ "$arg" == "--exclude=/operator-peers.json" ]] && custom=1 +done +[[ "$default" == 1 && "$custom" == 1 ]] || { + echo "Source sidecars must not overwrite target policy" >&2 + exit 88 +}' +make_stub "${M7_BIN}" "sleep" 'exit 0' +make_stub "${M7_BIN}" "hostname" 'echo "10.1.0.1 10.2.0.1"' +make_stub "${M7_BIN}" "ip" '[[ "$*" == "-4 route get 192.0.2.55" ]] || exit 1 +echo "192.0.2.55 via 10.1.0.254 dev eth1 src 10.1.0.1 uid 0"' +if OUTPUT=$(env -u PRIMARY_IP PATH="${M7_BIN}:${PATH}" PRIMARY_DATA_DIR="${M7_DATA}" \ + SECONDARY_NODE_YAML="${M7_YAML}" SECONDARY_DATA_DIR="${M7_REMOTE}" SECONDARY_NODE_CONFIG_SH=/nonexistent \ + GOMEMLIMIT_BYTES=5905580032 bash "${JOIN_SH}" "${TARGET_IP}" 2>&1); then + if python3 - "${M7_YAML}" <<'PY' +import sys, yaml, json +with open(sys.argv[1]) as f: + cfg = yaml.safe_load(f) +assert cfg["p2p"]["peer_whitelist"] == ["198.51.100.8"], cfg +assert "/ip4/198.51.100.8/tcp/30303" in cfg["p2p"]["bootnodes"], cfg +assert cfg["consensus"]["reward_address"] == "aproecTest", cfg +assert json.load(open(cfg['p2p']['whitelist_file'])) == ["203.0.113.20", "198.51.100.8"] +PY + then pass "M7: Fresh relay whitelists the SSH-observed outbound source" + else fail "M7: Fresh relay has incorrect whitelist/config"; fi +else + fail "M7: Full join failed: ${OUTPUT}" +fi + +section "M8: Failed source observation aborts before any node/config changes" +make_stub "${M7_BIN}" "ssh" 'exit 1' +make_stub "${M7_BIN}" "systemctl" "touch '${TMPDIR_TEST}/m8-service-touched'; exit 1" +BEFORE=$(cat "${M7_YAML}") +if OUTPUT=$(env -u PRIMARY_IP PATH="${M7_BIN}:${PATH}" PRIMARY_DATA_DIR="${M7_DATA}" \ + SECONDARY_NODE_YAML="${M7_YAML}" GOMEMLIMIT_BYTES=5905580032 \ + bash "${JOIN_SH}" "${TARGET_IP}" 2>&1); then + fail "M8: Failed observation was accepted" +elif [[ "$(cat "${M7_YAML}")" == "${BEFORE}" && "${OUTPUT}" == *"PRIMARY_IP="* && + ! -e "${TMPDIR_TEST}/m8-service-touched" ]]; then + pass "M8: Failed source observation rejected before service/configuration changes" +else + fail "M8: Missing diagnostic or existing config changed: ${OUTPUT}" +fi finish diff --git a/deploy/test-join-network-whitelist.sh b/deploy/test-join-network-whitelist.sh index c221371..12a74e8 100644 --- a/deploy/test-join-network-whitelist.sh +++ b/deploy/test-join-network-whitelist.sh @@ -24,33 +24,29 @@ ok() { echo -e "${GREEN} PASS${NC} $1"; PASS=$(( PASS + 1 )); } fail() { echo -e "${RED} FAIL${NC} $1"; FAIL=$(( FAIL + 1 )); } info() { echo -e "${YELLOW} INFO${NC} $1"; } -# ── Shared Python snippet (same logic as in join-network.sh + aperod-join.sh) ─ -ADD_WHITELIST_PY=' -import sys, yaml, os -cfg_path = sys.argv[1] -validator_ip = sys.argv[2] -with open(cfg_path) as f: - cfg = yaml.safe_load(f) or {} -p2p = cfg.setdefault("p2p", {}) -wl = list(p2p.get("peer_whitelist") or []) -if validator_ip not in wl: - wl.append(validator_ip) - p2p["peer_whitelist"] = wl - tmp = cfg_path + ".tmp" - with open(tmp, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True) - os.replace(tmp, cfg_path) - print(f"[OK] p2p.peer_whitelist updated: {wl}") -else: - print(f"[OK] p2p.peer_whitelist already contains {validator_ip} — skipping") -' +# Read each production heredoc rather than testing a copied implementation. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +extract_whitelist_python() { + python3 - "$1" "$2" <<'PY' +import sys +lines = open(sys.argv[1]).read().splitlines() +starts = [i for i, line in enumerate(lines) + if "python3 - " in line and sys.argv[2] in line and "<<'PY'" in line] +assert len(starts) == 1, f"Expected one production whitelist writer: {sys.argv[2]}" +start = starts[0] + 1 +end = lines.index("PY", start) +body = "\n".join(lines[start:end]) +assert "peer_whitelist" in body, "Production writer does not update peer_whitelist" +print(body) +PY +} # ── Helper: create a minimal node.yaml ──────────────────────────────────────── make_yaml() { local path="$1" - cat > "${path}" <<'YAML' + cat > "${path}" <&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 'exec "${REPO_ROOT}/blockchain/deploy/sync-public-repo.sh" "$@"' "${WRAPPER}" -if grep -Fq '/contents/${remote_path}' "${WRAPPER}"; then - echo "legacy wrapper must not push files individually" >&2 - 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 diff --git a/deploy/test-update-node-static-guard.sh b/deploy/test-update-node-static-guard.sh index 007de04..f1f9788 100644 --- a/deploy/test-update-node-static-guard.sh +++ b/deploy/test-update-node-static-guard.sh @@ -9,6 +9,8 @@ # "not a dynamic executable" — the happy path must pass through. # G3. The guard section is syntactically present in update-node.sh so that # a future rename/refactor cannot silently remove it. +# G4. With ldd hidden, readelf detects PT_INTERP in a dynamic ELF and aborts. +# G5. With ldd hidden, readelf output without INTERP passes the guard. # # Design # ------ @@ -18,6 +20,7 @@ # - BINARY_SRC overridden to a known dynamic/static binary # - send_telegram_alert() stubbed to a no-op (no real HTTP call) # - For G2, ldd injected via PATH to emit "not a dynamic executable" +# - For G4/G5, a minimal PATH exposes readelf but deliberately omits ldd # # Skip conditions # --------------- @@ -95,6 +98,11 @@ if [[ -z "${_DYN_BIN}" ]]; then fi echo " Using dynamic binary: ${_DYN_BIN}" +if [[ "${_has_readelf}" != "true" ]]; then + echo "ERROR: readelf is required to test the musl/Alpine fallback." >&2 + exit 1 +fi + # ── Extract the Step 2b guard block from update-node.sh ───────────────────── # # The block starts at the "# Step 2b:" comment and ends just before the @@ -233,6 +241,108 @@ fi rm -f "${_G2_OUT}" "${_G2_ERR}" rm -rf "${_MOCK_BIN}" +# ── G4/G5: hide ldd and exercise the readelf fallback ──────────────────────── +# +# A private PATH exposes only the helper tools the extracted guard requires. +# In particular, ldd is deliberately absent even if the host has it installed. +echo "" +echo "── G4/G5: readelf fallback when ldd is absent ───────────────────────────" + +_MOCK_NO_LDD_BIN="$(mktemp -d)" +_READ_ELF_BIN="$(command -v readelf)" +for _tool in bash env grep touch; do + _tool_path="$(command -v "${_tool}")" + if [[ -z "${_tool_path}" ]] || ! ln -s "${_tool_path}" "${_MOCK_NO_LDD_BIN}/${_tool}"; then + echo "ERROR: cannot prepare isolated PATH for readelf fallback (${_tool})." >&2 + rm -rf "${_MOCK_NO_LDD_BIN}" + exit 1 + fi +done +if ! ln -s "${_READ_ELF_BIN}" "${_MOCK_NO_LDD_BIN}/readelf"; then + echo "ERROR: cannot expose readelf in the isolated PATH." >&2 + rm -rf "${_MOCK_NO_LDD_BIN}" + exit 1 +fi + +# G4: use the actual readelf tool on the known dynamic ELF. It must find +# PT_INTERP and reject the candidate before the update script stops the service. +_G4_OUT="$(mktemp)" +_G4_ERR="$(mktemp)" +_G4_RC=0 +bash > "${_G4_OUT}" 2>"${_G4_ERR}" < /dev/null 2>&1; then + echo "ldd unexpectedly present in fallback PATH" >&2 + exit 99 +fi +send_telegram_alert() { :; } +BINARY_SRC="${_DYN_BIN}" +${GUARD_SRC} +SUBSHELL + +if [[ "${_G4_RC}" -eq 1 ]]; then + pass_test "G4a: readelf fallback exited 1 with ldd absent" +else + fail_test "G4a: expected exit 1 for dynamic ELF, got rc=${_G4_RC}" +fi +if grep -q "readelf: PT_INTERP segment found" "${_G4_ERR}" \ + && grep -q "Static-link check FAILED" "${_G4_ERR}"; then + pass_test "G4b: stderr confirms readelf found PT_INTERP and the guard failed" +else + fail_test "G4b: expected PT_INTERP and static-link failure — got: $(head -5 "${_G4_ERR}")" +fi +if grep -q "NOT stopped\|not stopped\|still running" "${_G4_ERR}"; then + pass_test "G4c: stderr confirms the service was NOT stopped" +else + fail_test "G4c: expected service-not-stopped message — got: $(head -5 "${_G4_ERR}")" +fi +rm -f "${_G4_OUT}" "${_G4_ERR}" + +# G5: mock readelf with output that contains no INTERP marker. The marker file +# proves the fallback executable (not ldd) actually ran in this isolated PATH. +_G5_MARKER="${_MOCK_NO_LDD_BIN}/readelf-was-called" +rm -f "${_MOCK_NO_LDD_BIN}/readelf" +cat > "${_MOCK_NO_LDD_BIN}/readelf" <<'MOCKREADELF' +#!/usr/bin/env bash +echo "no program interpreter header" +touch "${G5_MARKER}" +MOCKREADELF +chmod +x "${_MOCK_NO_LDD_BIN}/readelf" +_G5_OUT="$(mktemp)" +_G5_ERR="$(mktemp)" +_G5_RC=0 +bash > "${_G5_OUT}" 2>"${_G5_ERR}" < /dev/null 2>&1; then + echo "ldd unexpectedly present in fallback PATH" >&2 + exit 99 +fi +send_telegram_alert() { :; } +BINARY_SRC="${_DYN_BIN}" +${GUARD_SRC} +SUBSHELL + +if [[ "${_G5_RC}" -eq 0 ]]; then + pass_test "G5a: no-INTERP fallback exited 0 with ldd absent" +else + fail_test "G5a: readelf fallback exited ${_G5_RC} for output without INTERP" +fi +if [[ -f "${_G5_MARKER}" ]] && grep -q "no PT_INTERP segment" "${_G5_OUT}"; then + pass_test "G5b: mock readelf was called and the guard accepted no INTERP" +else + fail_test "G5b: mock readelf did not run or guard did not report a static binary" +fi +if ! grep -q "Static-link check FAILED" "${_G5_ERR}"; then + pass_test "G5c: stderr contains no static-link failure" +else + fail_test "G5c: static-link failure was unexpected on the no-INTERP path" +fi +rm -f "${_G5_OUT}" "${_G5_ERR}" +rm -rf "${_MOCK_NO_LDD_BIN}" + # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo "========================================" diff --git a/deploy/test_check_genesis_supply.py b/deploy/test_check_genesis_supply.py new file mode 100644 index 0000000..3075380 --- /dev/null +++ b/deploy/test_check_genesis_supply.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Offline CLI regressions; historical team fixtures never change live policy.""" +import copy +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().with_name("check-genesis-supply.sh") +CURRENT_DISTRIBUTION = SCRIPT.parents[2] / "lib/db/genesis-distribution.json" +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +class GenesisSupplyPolicyTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="genesis-policy-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.config = self.root / "node.yaml" + self.config.write_text('genesis:\n file: "genesis.yaml"\n') + self.genesis = self.root / "genesis.yaml" + self.genesis.write_text("""initial_supply: 10000000000 +timestamp: 1735689600 +allocations: + - address: "" + amount: 900000000000000000 + label: "Public" + vesting: + type: immediate + - address: "" + amount: 100000000000000000 + label: "Development Fund" + vesting: + type: cliff_linear + cliff_seconds: 31536000 + vest_seconds: 126144000 +""") + self.distribution = self.root / "policy.json" + # Representative cancelled model, NOT current distribution data. + primary = { + "address": "HISTORICAL_TEST_ONLY", "label": "Development Fund", + "category": "team", "amount_apro": 1_000_000_000, + "vesting_type": "cliff_linear", "cliff_seconds": 31536000, + "vest_seconds": 315360000, + } + self.historical = [ + {"label": "Public", "category": "community", "amount_apro": 9_000_000_000, + "vesting_type": "immediate"}, + primary, + ] + [ + {**primary, "label": f"Historical tranche {i + 1}", + "amount_apro": 84_910_000 if i == 9 else 84_900_000, + "unlock_date_utc": f"{2027 + i}-02-10"} + for i in range(10) + ] # No is_schedule_entry flags: exercise the same dated fallback as the API. + self.write_policy(self.historical) + + def write_policy(self, policy): + self.distribution.write_text(json.dumps(policy)) + + def run_audit(self, at="2026-08-14", distribution=None, script=SCRIPT, extra=(), environment=None): + args = ["bash", str(script), "--config", str(self.config)] + if distribution is not False: + args += ["--distribution", str(distribution or self.distribution)] + if at: + args += ["--at", at] + args += list(extra) + # No inherited credentials, no service or network calls. + env = {"PATH": os.environ["PATH"], "LC_ALL": "C", **(environment or {})} + result = subprocess.run(args, capture_output=True, text=True, env=env, cwd=self.root, timeout=10) + result.stdout = ANSI.sub("", result.stdout) + return result + + def amounts(self, result, label): + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + line = next(row for row in result.stdout.splitlines() if row.strip().startswith(label + " ")) + return re.findall(r"\d[\d,]*\.\d{4}", line)[:3] + + def test_historical_cliff_is_not_interpolated_from_four_year_yaml(self): + result = self.run_audit() + self.assertEqual(self.amounts(result, "Development Fund"), + ["1,000,000,000.0000", "150,990,000.0000", "849,010,000.0000"]) + self.assertIn("dated-tranches", result.stdout) + self.assertIn("not its legacy continuous schedule", result.stdout) + + def test_first_tranche_unlocks_on_exact_utc_day(self): + for day, unlocked in [ + ("2027-02-09", "150,990,000.0000"), + ("2027-02-10", "235,890,000.0000"), + ("2027-02-11", "235,890,000.0000"), + ]: + with self.subTest(day=day): + self.assertEqual(self.amounts(self.run_audit(at=day), "Development Fund")[1], unlocked) + + def test_cliff_and_last_tranche_boundaries(self): + for day, unlocked in [ + ("2025-12-31", "0.0000"), + ("2026-01-01", "150,990,000.0000"), + ("2036-02-10", "1,000,000,000.0000"), + ]: + with self.subTest(day=day): + self.assertEqual(self.amounts(self.run_audit(at=day), "Development Fund")[1], unlocked) + + def test_changing_json_tranche_amount_changes_the_cliff_not_a_hardcoded_value(self): + self.historical[2]["amount_apro"] -= 1_000_000 + self.write_policy(self.historical) + self.assertEqual(self.amounts(self.run_audit(), "Development Fund")[1], "151,990,000.0000") + + def test_current_cancelled_fund_is_fully_available_without_restoring_team_tranches(self): + before = self.genesis.read_bytes() + for day in ["2026-08-14", "2027-02-10"]: + result = self.run_audit(at=day, distribution=CURRENT_DISTRIBUTION) + self.assertEqual(self.amounts(result, "Community & Ecosystem"), + ["1,000,000,000.0000", "1,000,000,000.0000", "0.0000"]) + self.assertIn("team entitlement cancelled", result.stdout) + self.assertIn("NOT verified circulating or spendable UTXOs", result.stdout) + self.assertIn("PASS", result.stdout) + self.assertEqual(self.genesis.read_bytes(), before) + self.assertFalse(any(e.get("category") == "team" for e in json.loads(CURRENT_DISTRIBUTION.read_text()))) + + def test_removed_tranches_do_not_silently_fall_back_to_continuous_vesting(self): + self.write_policy(self.historical[:2]) + result = self.run_audit() + self.assertNotEqual(result.returncode, 0) + self.assertIn("refusing continuous interpolation", result.stderr) + + def test_invalid_dates_amounts_and_double_counted_schedules_fail_explicitly(self): + for field, value in [("unlock_date_utc", "2027-02-30"), ("amount_apro", -1), + ("amount_apro", 1.5), ("unlock_date_utc", "")]: + with self.subTest(field=field, value=value): + policy = copy.deepcopy(self.historical) + policy[2][field] = value + self.write_policy(policy) + result = self.run_audit() + self.assertNotEqual(result.returncode, 0) + self.assertIn("ERROR:", result.stderr) + + def test_missing_policy_does_not_assume_a_fully_unlocked_team_allocation(self): + result = self.run_audit(distribution=self.root / "missing.json") + self.assertNotEqual(result.returncode, 0) + self.assertIn("distribution JSON not found", result.stderr) + + def test_policy_overlay_cannot_hide_original_genesis_total_mismatch(self): + self.genesis.write_text(self.genesis.read_text().replace("initial_supply: 10000000000", + "initial_supply: 9999999999")) + result = self.run_audit(distribution=CURRENT_DISTRIBUTION) + self.assertEqual(result.returncode, 1) + self.assertIn("DO NOT match", result.stdout) + + def test_installed_script_resolves_policy_from_explicit_checkout_root(self): + installed = self.root / "installed" / "usr/local/bin" / SCRIPT.name + installed.parent.mkdir(parents=True) + shutil.copyfile(SCRIPT, installed) + checkout = self.root / "checkout" + policy = checkout / "lib/db/genesis-distribution.json" + policy.parent.mkdir(parents=True) + shutil.copyfile(CURRENT_DISTRIBUTION, policy) + result = self.run_audit(distribution=False, script=installed, + environment={"APEROD_DIR": str(checkout)}) + self.assertEqual(self.amounts(result, "Community & Ecosystem")[1], "1,000,000,000.0000") + + def test_checkout_default_policy_does_not_depend_on_working_directory(self): + result = self.run_audit(distribution=False) + self.assertEqual(self.amounts(result, "Community & Ecosystem")[1], "1,000,000,000.0000") + + def test_historical_snapshot_cannot_mix_in_live_api_counters(self): + result = self.run_audit(extra=("--api-url", "http://127.0.0.1:1")) + self.assertNotEqual(result.returncode, 0) + self.assertIn("offline-only", result.stderr) + + def test_missing_genesis_time_does_not_present_unknown_totals_as_zero(self): + self.genesis.write_text(self.genesis.read_text().replace("timestamp: 1735689600", "timestamp: 0")) + result = self.run_audit() + self.assertEqual(result.returncode, 0, result.stderr) + total = next(line for line in result.stdout.splitlines() if "GENESIS TOTAL" in line) + self.assertEqual(total.count("unknown"), 2) + + def test_identity_mismatch_is_not_treated_as_cancellation_of_another_wallets_lock(self): + self.genesis.write_text(self.genesis.read_text().replace('address: ""', 'address: "OTHER_WALLET"')) + result = self.run_audit(distribution=CURRENT_DISTRIBUTION) + self.assertNotEqual(result.returncode, 0) + self.assertIn("cannot be uniquely reconciled", result.stderr) + + def test_discrete_utc_boundaries_are_independent_of_process_timezone(self): + result = self.run_audit(at="2027-02-10", environment={"TZ": "America/Los_Angeles"}) + self.assertEqual(self.amounts(result, "Development Fund")[1], "235,890,000.0000") + + def test_empty_snapshot_option_cannot_silently_mean_today(self): + result = self.run_audit(at=None, extra=("--at=",)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Missing --at date", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/test_publication_security.py b/deploy/test_publication_security.py deleted file mode 100644 index 57bc12e..0000000 --- a/deploy/test_publication_security.py +++ /dev/null @@ -1,104 +0,0 @@ -import importlib.util -import subprocess -import tempfile -import unittest -from pathlib import Path - -spec = importlib.util.spec_from_file_location( - "publication_security", Path(__file__).with_name("publication_security.py") -) -policy = importlib.util.module_from_spec(spec) -spec.loader.exec_module(policy) - - -class PublicationSecurityTests(unittest.TestCase): - def test_forbidden_paths(self): - for path in ( - "data/testnet/validator.key", "anything/secret.key", "keys/a.pem", - ".env", ".env.production", "data/testnet/chain.db/CURRENT", - "snapshots/a.gz", "aperod", "node", "cli", "x.sqlite", - "node_modules/a.js", "../key", "/outside", "back\\slash", - ".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", - ): - with self.subTest(path=path): - self.assertIsNotNone(policy.path_problem(path)) - - def test_safe_source_and_documented_environment_templates(self): - for path in ("core/verify.go", "deploy/install-node.sh", ".env.example", - ".github/workflows/security.yml", "data/favicon.png"): - self.assertIsNone(policy.path_problem(path)) - - def test_symlink_and_submodule_are_refused(self): - for mode in ("120000", "160000"): - self.assertIsNotNone(policy.path_problem("source.go", mode)) - - def test_disguised_binaries_and_raw_keys(self): - for content in (b"\x7fELFbinary", b"MZbinary", - b"\xff" * 32, b"\xff" * 64): - self.assertIsNotNone(policy.content_problem("innocent.txt", content)) - - def test_standalone_encoded_key_material(self): - import base64 - for size in (32, 64): - self.assertIsNotNone(policy.content_problem("ordinary.txt", base64.b64encode(b"\xff" * size))) - - def test_history_still_refuses_deleted_key(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 / "retired.key").write_bytes(b"\xff" * 64) - subprocess.run(["git", "-C", str(root), "add", "."], check=True) - subprocess.run(["git", "-C", str(root), "commit", "-qm", "old"], check=True) - (root / "retired.key").unlink() - (root / "source.go").write_text("package source\n") - subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) - subprocess.run(["git", "-C", str(root), "commit", "-qm", "clean tip"], check=True) - self.assertEqual(policy.inspect(root)[1], []) - self.assertTrue(any(x[0] == "retired.key" for x in policy.inspect_history(root)[2])) - - def test_private_pem_is_refused_without_echoing_contents(self): - self.assertIsNotNone(policy.content_problem( - "config.txt", b"-----BEGIN " + b"PRIVATE KEY-----\nTEST_ONLY\n" - )) - - def test_missing_candidate_git_never_falls_back_to_parent(self): - with tempfile.TemporaryDirectory() as temp: - parent = Path(temp) - subprocess.run(["git", "init", "-q", str(parent)], check=True) - candidate = parent / "candidate" - candidate.mkdir() - with self.assertRaisesRegex(RuntimeError, "independent Git"): - policy.git(candidate, "ls-files") - - def test_explicit_bare_repository_boundary_is_supported(self): - with tempfile.TemporaryDirectory() as temp: - bare = Path(temp) / "metadata.git" - subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True) - self.assertEqual(policy.git(bare, "rev-parse", "--is-bare-repository").strip(), - b"true") - - def test_whole_tree_includes_preexisting_tracked_runtime(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 / "data/testnet").mkdir(parents=True) - (root / "data/testnet/validator.key").write_bytes(b"\xff" * 64) - (root / "source.go").write_text("package source\n") - subprocess.run(["git", "-C", str(root), "add", "."], check=True) - subprocess.run(["git", "-C", str(root), "commit", "-qm", "fixture"], check=True) - checked, failures = policy.inspect(root) - self.assertEqual(checked, 2) - self.assertEqual(failures[0][0], "data/testnet/validator.key") - (root / "data/testnet/validator.key").unlink() - checked, failures = policy.inspect(root, worktree=True) - self.assertEqual((checked, failures), (1, [])) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/deploy/test_publish_public_repo.py b/deploy/test_publish_public_repo.py deleted file mode 100644 index e9dfaff..0000000 --- a/deploy/test_publish_public_repo.py +++ /dev/null @@ -1,472 +0,0 @@ -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -import publish_public_repo as publisher -from publication_security import git, inspect - - -def fixture(root): - 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 / "go.mod").write_text("module example.invalid/test\n\ngo 1.25.0\n") - subprocess.run(["git", "-C", str(root), "add", "."], check=True) - subprocess.run(["git", "-C", str(root), "commit", "-qm", "fixture"], check=True) - - -class PublisherTests(unittest.TestCase): - def test_publication_uses_only_aperod_network_as_author(self): - self.assertEqual( - publisher.AUTHOR, - {"name": "aperod-network", "email": "aperod-network@users.noreply.github.com"}, - ) - self.assertEqual(publisher.MESSAGE, "chore: maintain verified source distribution") - self.assertNotIn("Co-authored-by:", publisher.MESSAGE) - self.assertNotIn("Co-authored-by:", publisher.MERGE_MESSAGE) - - def test_candidate_symlink_cannot_overwrite_external_file(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") - (source / "example.py").write_text("print('source')\n") - external = base / "protected.txt" - external.write_text("DO NOT MODIFY") - (candidate / "example.py").symlink_to(external) - subprocess.run(["git", "-C", str(candidate), "add", "."], check=True) - publisher.prepare(source, candidate) - self.assertEqual(external.read_text(), "DO NOT MODIFY") - self.assertFalse((candidate / "example.py").is_symlink()) - - def test_build_processes_do_not_receive_publication_credentials(self): - with patch.dict(publisher.os.environ, {"PUBLIC_GITHUB_TOKEN": "TEST_ONLY", - "SSH_PASSWORD": "TEST_ONLY", - "PYTHONPATH": "/untrusted/module/path"}), \ - patch.object(publisher.subprocess, "run") as child: - publisher.run("go", "vet", "./...") - env = child.call_args.kwargs["env"] - self.assertNotIn("PUBLIC_GITHUB_TOKEN", env) - self.assertNotIn("SSH_PASSWORD", env) - self.assertNotIn("PYTHONPATH", env) - - def test_go_tests_receive_only_the_pyyaml_userbase(self): - python_userbase = Path("/trusted/python-userbase") - with patch.dict(publisher.os.environ, {"PUBLIC_GITHUB_TOKEN": "TEST_ONLY", - "SSH_PASSWORD": "TEST_ONLY", - "PYTHONPATH": "/untrusted/module/path"}), \ - patch.object(publisher.subprocess, "run") as child: - publisher.run("go", "test", "./...", - test_python_userbase=python_userbase) - env = child.call_args.kwargs["env"] - self.assertEqual(env["PYTHONUSERBASE"], str(python_userbase)) - self.assertNotIn("PYTHONPATH", env) - self.assertNotIn("PUBLIC_GITHUB_TOKEN", env) - self.assertNotIn("SSH_PASSWORD", env) - - def test_test_python_userbase_copies_only_trusted_pyyaml(self): - with tempfile.TemporaryDirectory() as temp: - base = Path(temp) / "installed" - package = base / "yaml" - package.mkdir(parents=True) - (package / "__init__.py").write_text("SOURCE = 'installed'\n") - (package / "scanner.py").write_text("SAFE = True\n") - userbase = Path(temp) / "isolated" - fake_distribution = Mock() - fake_distribution.locate_file.side_effect = ( - lambda rel: base if str(rel) == "" else package - ) - with patch.object(publisher, "distribution", - return_value=fake_distribution): - publisher.prepare_test_python_userbase(userbase) - target = ( - userbase / "lib" - / f"python{publisher.sys.version_info.major}.{publisher.sys.version_info.minor}" - / "site-packages" / "yaml" - ) - self.assertEqual((target / "__init__.py").read_text(), - "SOURCE = 'installed'\n") - self.assertTrue((target / "scanner.py").is_file()) - - def test_failed_required_check_never_merges(self): - methods = [] - def request(method, url, data=None): - methods.append(method) - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - return {"check_runs": [{"name": "Whole-tree publication security", - "status": "completed", "conclusion": "failure", - "app": {"id": 15368}}]} - with self.assertRaisesRegex(RuntimeError, "check failed"): - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") - self.assertNotIn("PUT", methods) - - def test_failed_lifecycle_matrix_check_never_merges(self): - methods = [] - failed = "install-node.sh e2e smoke test (update)" - - def request(method, url, data=None): - methods.append(method) - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - return {"check_runs": [ - {"name": name, "status": "completed", - "conclusion": "failure" if name == failed else "success", - "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}} - for name in publisher.REQUIRED_CHECKS - ]} - - with self.assertRaisesRegex(RuntimeError, "check failed"): - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") - self.assertNotIn("PUT", methods) - - def test_failed_codeql_check_never_merges(self): - methods = [] - failed = "CodeQL" - - def request(method, url, data=None): - methods.append(method) - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - return {"check_runs": [ - {"name": name, "status": "completed", - "conclusion": "failure" if name == failed else "success", - "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}} - for name in publisher.REQUIRED_CHECKS - ]} - - with self.assertRaisesRegex(RuntimeError, "check failed"): - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") - self.assertNotIn("PUT", methods) - - def test_neutral_codeql_requires_completed_go_analysis_and_no_open_pr_alerts(self): - methods = [] - - def request(method, url, data=None): - methods.append((method, url)) - if method == "PUT": - return {"merged": True} - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - if url.endswith("/code-scanning/alerts?pr=1&tool_name=CodeQL&state=open&per_page=100"): - return [] - checks = [] - for name in publisher.REQUIRED_CHECKS: - neutral = name == "CodeQL" - checks.append({ - "name": name, - "status": "completed", - "conclusion": "neutral" if neutral else "success", - "output": { - "title": publisher.CODEQL_NEUTRAL_ALLOWED_TITLE, - "summary": "Default setup: /language:go", - } if neutral else {}, - "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}, - }) - return {"check_runs": checks} - - with patch.object(publisher.time, "sleep"): - 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"), - methods, - ) - self.assertIn(("PUT", f"{publisher.API}/pulls/1/merge"), methods) - - def test_neutral_codeql_waits_for_go_analysis_before_evaluating(self): - methods = [] - check_reads = 0 - - def request(method, url, data=None): - nonlocal check_reads - methods.append((method, url)) - if method == "PUT": - return {"merged": True} - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - check_reads += 1 - checks = [] - for name in publisher.REQUIRED_CHECKS: - neutral = name == "CodeQL" and check_reads == 1 - pending_go = name == "Analyze (go)" and check_reads == 1 - checks.append({ - "name": name, - "status": "in_progress" if pending_go else "completed", - "conclusion": None if pending_go else ( - "neutral" if neutral else "success" - ), - "output": { - "title": publisher.CODEQL_NEUTRAL_ALLOWED_TITLE, - "summary": "Default setup: /language:go", - } if neutral else {}, - "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}, - }) - return {"check_runs": checks} - - with patch.object(publisher.time, "sleep"): - 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)) - self.assertIn(("PUT", f"{publisher.API}/pulls/1/merge"), methods) - - def test_neutral_codeql_with_open_alerts_never_merges(self): - methods = [] - - def request(method, url, data=None): - methods.append((method, url)) - if url.endswith("/pulls/1"): - return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} - if url.endswith("/code-scanning/alerts?pr=1&tool_name=CodeQL&state=open&per_page=100"): - return [{"number": 7, "state": "open"}] - checks = [] - for name in publisher.REQUIRED_CHECKS: - neutral = name == "CodeQL" - checks.append({ - "name": name, - "status": "completed", - "conclusion": "neutral" if neutral else "success", - "output": { - "title": publisher.CODEQL_NEUTRAL_ALLOWED_TITLE, - "summary": "Default setup: /language:go", - } if neutral else {}, - "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}, - }) - return {"check_runs": checks} - - with self.assertRaisesRegex(RuntimeError, "open pull-request alerts"): - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") - self.assertNotIn(("PUT", f"{publisher.API}/pulls/1/merge"), methods) - - def test_untrusted_status_provider_cannot_authorize_merge(self): - methods = [] - def request(method, url, data=None): - methods.append(method) - 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.GITHUB_ACTIONS_APP_ID if name == "CodeQL" - else publisher.TRUSTED_CHECK_APP_IDS.get( - name, publisher.GITHUB_ACTIONS_APP_ID)}} - for name in publisher.REQUIRED_CHECKS - ]} - with patch.object(publisher.time, "monotonic", side_effect=[0, 0, 2]), \ - patch.object(publisher.time, "sleep"): - with self.assertRaisesRegex(RuntimeError, "did not complete"): - publisher.await_merge( - {"number": 1, "html_url": "test"}, request, - timeout=1, expected_head_sha="reviewed", - ) - self.assertNotIn("PUT", methods) - - def test_merge_is_bound_to_reviewed_head(self): - bodies = [] - def request(method, url, data=None): - if method == "PUT": - bodies.append(data) - 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]} - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") - self.assertEqual(bodies[0]["sha"], "reviewed") - self.assertNotIn("force", bodies[0]) - - def test_await_merge_waits_for_uploaded_head_before_reading_checks(self): - heads = ["stale", "reviewed", "reviewed"] - pull_reads = 0 - events = [] - - def request(method, url, data=None): - nonlocal pull_reads - if method == "PUT": - events.append(("merge", pull_reads)) - return {"merged": True} - if url.endswith("/pulls/1"): - pull_reads += 1 - return { - "state": "open", - "head": {"sha": heads.pop(0)}, - "base": {"ref": "main"}, - } - if "/check-runs?" in url: - events.append(("checks", pull_reads)) - 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 - ]} - raise AssertionError(f"unexpected request: {method} {url}") - - with patch.object(publisher.time, "sleep"): - publisher.await_merge( - {"number": 1, "html_url": "test"}, request, - expected_head_sha="reviewed", - ) - self.assertEqual(events[0], ("checks", 2)) - self.assertEqual(events[1], ("merge", 3)) - - def test_await_merge_refuses_head_changed_after_checks(self): - heads = ["reviewed", "changed"] - methods = [] - - def request(method, url, data=None): - methods.append((method, url)) - if method == "PUT": - return {"merged": True} - if url.endswith("/pulls/1"): - return { - "state": "open", - "head": {"sha": heads.pop(0)}, - "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 self.assertRaisesRegex(RuntimeError, "head or base changed"): - publisher.await_merge( - {"number": 1, "html_url": "test"}, request, - expected_head_sha="reviewed", - ) - self.assertFalse(any(method == "PUT" for method, _ in methods)) - - def test_await_merge_refuses_non_main_base(self): - methods = [] - - def request(method, url, data=None): - methods.append(method) - return { - "state": "open", - "head": {"sha": "reviewed"}, - "base": {"ref": "release"}, - } - - with self.assertRaisesRegex(RuntimeError, "base is not main"): - publisher.await_merge( - {"number": 1, "html_url": "test"}, request, - expected_head_sha="reviewed", - ) - self.assertNotIn("PUT", methods) - - def test_policy_failure_prevents_every_network_request(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - fixture(root) - (root / "credential.key").write_bytes(b"\xff" * 64) - subprocess.run(["git", "-C", str(root), "add", "-f", "credential.key"], check=True) - tree = git(root, "write-tree").decode().strip() - request = Mock() - with self.assertRaisesRegex(RuntimeError, "Publication policy"): - publisher.publish(root, tree, "head", request) - request.assert_not_called() - - def test_missing_scanner_prevents_every_network_request(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - fixture(root) - tree = git(root, "write-tree").decode().strip() - request = Mock() - with patch.object(publisher.shutil, "which", return_value=None): - with self.assertRaisesRegex(RuntimeError, "scanner missing"): - publisher.publish(root, tree, "head", request) - request.assert_not_called() - - def test_deleted_historical_key_prevents_every_network_request(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - fixture(root) - (root / "retired.key").write_bytes(b"\xff" * 64) - subprocess.run(["git", "-C", str(root), "add", "."], check=True) - subprocess.run(["git", "-C", str(root), "commit", "-qm", "old key"], check=True) - (root / "retired.key").unlink() - subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) - tree = git(root, "write-tree").decode().strip() - request = Mock() - with patch.object(publisher.shutil, "which", return_value="/scanner"): - with self.assertRaisesRegex(RuntimeError, "Historical publication"): - publisher.publish(root, tree, "head", request) - request.assert_not_called() - - def test_scanner_error_cannot_upload(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - fixture(root) - tree = git(root, "write-tree").decode().strip() - request = Mock() - 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) - request.assert_not_called() - - def test_old_runtime_is_removed_only_from_disposable_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") - runtime = source / "data/testnet" - runtime.mkdir(parents=True) - (runtime / "validator.key").write_bytes(b"\xff" * 64) - (candidate / "data/testnet").mkdir(parents=True) - (candidate / "data/testnet/validator.key").write_bytes(b"\xff" * 64) - subprocess.run(["git", "-C", str(candidate), "add", "."], check=True) - tree = publisher.prepare(source, candidate) - self.assertEqual(inspect(candidate, tree)[1], []) - self.assertTrue((runtime / "validator.key").exists()) - self.assertFalse((candidate / "data/testnet/validator.key").exists()) - - def test_extensionless_build_files_are_included_in_public_snapshot(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") - dockerfile = source / "deploy/fixtures/node-lifecycle/Dockerfile" - dockerfile.parent.mkdir(parents=True) - dockerfile.write_text("FROM ubuntu:24.04\nCOPY tool /usr/local/libexec/tool\n") - (source / "Makefile").write_text("build:\n\tgo build ./...\n") - - tree = publisher.prepare(source, candidate) - paths = git(candidate, "ls-tree", "-r", "--name-only", tree).decode().splitlines() - - self.assertIn("deploy/fixtures/node-lifecycle/Dockerfile", paths) - self.assertIn("Makefile", paths) - self.assertEqual( - (candidate / "deploy/fixtures/node-lifecycle/Dockerfile").read_text(), - dockerfile.read_text(), - ) - self.assertEqual((candidate / "Makefile").read_text(), - (source / "Makefile").read_text()) - - 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): - self.assertIsNotNone(content_problem("documentation.txt", contents)) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file 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 deleted file mode 100644 index ea58cc7..0000000 --- a/deploy/test_sync_public_repo.py +++ /dev/null @@ -1,20 +0,0 @@ -import unittest -from unittest.mock import Mock -import sync_public_repo - - -class LegacyPublicationTests(unittest.TestCase): - def test_direct_upload_route_is_disabled(self): - request = Mock() - with self.assertRaisesRegex(RuntimeError, "Legacy publication disabled"): - sync_public_repo.sync("files", "deletions", "head", request) - request.assert_not_called() - - def test_cli_cannot_accept_a_token_argument(self): - with self.assertRaises(SystemExit) as result: - sync_public_repo.main() - self.assertIn("Legacy publication disabled", str(result.exception)) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/go.mod b/go.mod index e9395ed..fbda5bb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/aperod/aperod -go 1.26.10 +go 1.26.11 require ( filippo.io/edwards25519 v1.1.1 @@ -10,9 +10,9 @@ require ( github.com/syndtr/goleveldb v1.0.0 github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 github.com/tetratelabs/wazero v1.12.0 - golang.org/x/net v0.60.0 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 + golang.org/x/net v0.62.0 + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 4ae01c2..d2ad339 100644 --- a/go.sum +++ b/go.sum @@ -53,19 +53,19 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 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/node.yaml b/node.yaml index 2601dcb..13b6fa7 100644 --- a/node.yaml +++ b/node.yaml @@ -7,6 +7,11 @@ p2p: bootnodes: [] max_peers: 50 min_peers: 4 + # Limit stalled TLS ClientHello handshakes so slow peers cannot hold pending + # handshake slots for long (slowloris/goroutine-exhaustion attack). + # Use 2s-3s on normal links; raise for high latency. Restart the node to apply. + # Omitted: 3s production default. Explicit 0s: host fallback of 10s. + handshake_timeout: 3s # Peer IP whitelist (optional). When set, only inbound connections from # the listed IPs / CIDR ranges are accepted. Outbound dials to bootnodes # are not affected. Leave empty (default) for an open network. @@ -90,3 +95,10 @@ pruning: # Lower this to keep a tighter mempool; raise it to reduce GC pressure on # nodes with a small mempool load. # mempool_evict_interval_sec: 300 + +# In-process nightly restart scheduler (UTC). The environment gate +# SCHED_RESTART_ENABLED must also be "true" for the scheduler to run. +# To disable it in node.yaml, set restart_enabled: false and restart_at: "". +maintenance: + restart_enabled: true + restart_at: "04:00" diff --git a/node/node.go b/node/node.go index 8fd53dc..8109043 100644 --- a/node/node.go +++ b/node/node.go @@ -538,11 +538,29 @@ func (a *p2pAdapter) ValidateSyncHeader(header core.BlockHeader) error { } func (a *p2pAdapter) OnTransaction(tx *core.Transaction) { - if err := a.mempool.Add(*tx); err != nil { + if err := a.AdmitTransaction(tx); err != nil { a.log.Debug("p2p: tx rejected", "err", err) } } +// AdmitTransaction is called by P2P before gossip relay. A transaction is +// forwarded only after the mempool has accepted it. +func (a *p2pAdapter) AdmitTransaction(tx *core.Transaction) error { + hash := tx.Hash() + if _, alreadyAccepted := a.mempool.Get(hash); alreadyAccepted { + return nil + } + if err := a.mempool.Add(*tx); err != nil { + // Resolve a concurrent admission race: a duplicate is safe to relay + // only if the exact transaction is now already present in the pool. + if _, alreadyAccepted := a.mempool.Get(hash); alreadyAccepted { + return nil + } + return err + } + return nil +} + func (a *p2pAdapter) OnVote(vote p2p.VoteMsg) { pub, err := crypto.ValidatorPubKeyFromBytes(vote.ValidatorPub) if err != nil { diff --git a/p2p/badblock_ban_test.go b/p2p/badblock_ban_test.go index 702a87e..36f6367 100644 --- a/p2p/badblock_ban_test.go +++ b/p2p/badblock_ban_test.go @@ -12,12 +12,15 @@ import ( "bytes" "crypto/tls" "encoding/json" + "errors" "fmt" + "io" "net" "os" "path/filepath" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -2133,6 +2136,121 @@ func TestBadBlockBan_TLSAllConnectionsEvicted(t *testing.T) { } } +// TestTimestampBan_TLSAllConnectionsEvicted exercises the timestamp strike +// pipeline over real pinned mutual TLS, independently of wrong-fork strikes. +func TestTimestampBan_TLSAllConnectionsEvicted(t *testing.T) { + const threshold = 3 + cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("generate host TLS config: %v", err) + } + cfgPeer, fpPeer, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("generate peer TLS config: %v", err) + } + // Pin the host identity rather than bypassing certificate verification. + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) + h := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "test-timestamp-tls-evict", + UserAgent: "aperod/test", + TLSConfig: cfgHost, + AllowedPeers: []string{fpPeer}, + TimestampBanThreshold: threshold, + TimestampBanDuration: time.Hour, + }, &stubHandler{}, newTestLogger()) + if err := h.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer h.Stop() + + dialTLS := func(nodeID string) *tls.Conn { + t.Helper() + c, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", h.ListenAddr(), cfgPeer) + if err != nil { + t.Fatalf("TLS dial (%s): %v", nodeID, err) + } + t.Cleanup(func() { _ = c.Close() }) + // ReadMsg replaces connection deadlines; close only a stalled handshake, + // never a socket whose remote eviction we later need to prove. + stall := time.AfterFunc(3*time.Second, func() { _ = c.Close() }) + defer stall.Stop() + if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ + NodeID: nodeID, Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + t.Fatalf("write ping (%s): %v", nodeID, err) + } + msgType, _, err := p2p.ReadMsg(c) + if err != nil || msgType != p2p.MsgPong { + t.Fatalf("expected MsgPong (%s), got %v err=%v", nodeID, msgType, err) + } + if err := c.SetDeadline(time.Time{}); err != nil { + t.Fatalf("clear handshake deadline: %v", err) + } + return c + } + + first := dialTLS("timestamp-tls-sender") + second := dialTLS("timestamp-tls-idle") + firstIP, _, err := net.SplitHostPort(first.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + secondIP, _, err := net.SplitHostPort(second.LocalAddr().String()) + if err != nil || firstIP != secondIP || first.LocalAddr().String() == second.LocalAddr().String() { + t.Fatalf("expected two source ports on the same IP: %v, %v (%v)", first.LocalAddr(), second.LocalAddr(), err) + } + if !waitFor(600*time.Millisecond, func() bool { return h.PeerCount() == 2 }) { + t.Fatalf("expected 2 registered TLS peers, got %d", h.PeerCount()) + } + if bans := h.ListBans(); len(bans) != 0 { + t.Fatalf("healthy TLS connections unexpectedly banned: %v", bans) + } + + before := time.Now() + for i := 0; i < threshold; i++ { + // Height 1 is within the lead limit of the stub's tip (0). Only the + // timestamp, 30 seconds in the future, can trigger this ban. + sendFutureTimestampBlock(t, first) + } + if !waitFor(600*time.Millisecond, func() bool { return h.PeerCount() == 0 }) { + t.Errorf("timestamp ban left %d TLS peers registered", h.PeerCount()) + } + bans := h.ListBans() + if len(bans) != 1 || bans[0].Addr != firstIP || + bans[0].Reason != "repeated future-timestamped blocks (timejacking attack)" { + t.Errorf("expected timestamp-abuse ban for bare IP %s, got %v", firstIP, bans) + } + if !waitFor(600*time.Millisecond, func() bool { return len(h.GetBanEvents(before)) == 1 }) { + t.Fatal("timestamp ban event was not recorded") + } + event := h.GetBanEvents(before)[0] + if event.IP != firstIP || event.Violations != threshold || event.BanDurationSecs != 3600 { + t.Errorf("wrong timestamp ban event: %+v", event) + } + + for _, c := range []*tls.Conn{first, second} { + if err := c.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatalf("set eviction read deadline: %v", err) + } + // Drain buffered GetHeaders before checking actual socket closure. + buf := make([]byte, 256) + var readErr error + for readErr == nil { + _, readErr = c.Read(buf) + } + if nerr, ok := readErr.(net.Error); ok && nerr.Timeout() { + t.Errorf("TLS connection %v still open after timestamp ban: %v", c.LocalAddr(), readErr) + } else if !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) && + !errors.Is(readErr, syscall.ECONNRESET) { + t.Errorf("expected remote EOF/reset for %v, got %v", c.LocalAddr(), readErr) + } else { + t.Logf("TLS connection %v closed remotely: %v", c.LocalAddr(), readErr) + } + } +} + // ─── Test: TLS-whitelisted validator is still exempt from wrong-fork bans ──── // TestBadBlockBan_TLSWhitelistedPeerNotBanned is the regression test that diff --git a/p2p/dispatch_test.go b/p2p/dispatch_test.go index 131ea62..bafb456 100644 --- a/p2p/dispatch_test.go +++ b/p2p/dispatch_test.go @@ -5,6 +5,8 @@ package p2p_test import ( "encoding/json" + "errors" + "fmt" "net" "testing" "time" @@ -129,6 +131,99 @@ func TestDispatch_Peers(t *testing.T) { }) } +func TestPeerExchangeCannotFillKnownPeerListWithOneMessage(t *testing.T) { + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + addrs := make([]string, 32) + for i := range addrs { + addrs[i] = fmt.Sprintf("10.%d.%d.%d:30303", (i/65536)%256, (i/256)%256, i%256) + } + p2p.HostTryPeerExchange(host, addrs) + if got := p2p.HostKnownPeerCount(host); got > 16 { + t.Fatalf("one peer exchange added %d addresses from one source; maximum is 16", got) + } + if got := p2p.HostKnownPeerCount(host); got != 16 { + t.Fatalf("valid IP:port peers admitted = %d, want 16", got) + } + + tooMany := append(append([]string(nil), addrs...), addrs...) + p2p.HostTryPeerExchange(host, tooMany) + if got := p2p.HostKnownPeerCount(host); got != 16 { + t.Fatalf("oversized peer exchange changed list to %d entries", got) + } + oversizedMsg, err := json.Marshal(p2p.PeersMsg{Addrs: tooMany}) + if err != nil { + t.Fatal(err) + } + if err := p2p.HostDispatchForTest(host, "192.0.2.1:30303", p2p.MsgPeers, oversizedMsg); err == nil { + t.Fatal("oversized MsgPeers was not rejected") + } + + invalidHost := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + p2p.HostTryPeerExchange(invalidHost, []string{"not-an-address", "example.invalid:30303", "10.0.0.1:0"}) + if got := p2p.HostKnownPeerCount(invalidHost); got != 0 { + t.Fatalf("invalid peer addresses admitted: %d", got) + } +} + +func TestRejectedTransactionIsNotRelayed(t *testing.T) { + handler := &stubHandler{admitErr: errors.New("mempool rejected")} + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, handler, newTestLogger()) + hostConn, relayConn := net.Pipe() + defer hostConn.Close() + defer relayConn.Close() + p2p.HostRegisterPeerForTest(host, "10.0.0.2:30303", hostConn) + relayConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + + body, err := json.Marshal(core.Transaction{Fee: 1}) + if err != nil { + t.Fatal(err) + } + if err := p2p.HostDispatchForTest(host, "10.0.0.1:30303", p2p.MsgTx, body); err != nil { + t.Fatalf("dispatch rejected transaction: %v", err) + } + if _, _, err := p2p.ReadMsg(relayConn); err == nil { + t.Fatal("rejected transaction was relayed") + } +} + +func TestAcceptedTransactionCanBeRelayed(t *testing.T) { + handler := &stubHandler{} + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, handler, newTestLogger()) + hostConn, relayConn := net.Pipe() + defer hostConn.Close() + defer relayConn.Close() + p2p.HostRegisterPeerForTest(host, "10.0.0.2:30303", hostConn) + + type result struct { + msgType p2p.MessageType + err error + } + readResult := make(chan result, 1) + go func() { + msgType, _, err := p2p.ReadMsg(relayConn) + readResult <- result{msgType: msgType, err: err} + }() + + body, err := json.Marshal(core.Transaction{Fee: 1}) + if err != nil { + t.Fatal(err) + } + if err := p2p.HostDispatchForTest(host, "10.0.0.1:30303", p2p.MsgTx, body); err != nil { + t.Fatalf("dispatch accepted transaction: %v", err) + } + select { + case got := <-readResult: + if got.err != nil { + t.Fatalf("read relayed transaction: %v", got.err) + } + if got.msgType != p2p.MsgTx { + t.Fatalf("relayed message type = %v, want MsgTx", got.msgType) + } + case <-time.After(time.Second): + t.Fatal("accepted transaction was not relayed") + } +} + // ─── MsgBlock (handler.OnBlock) ─────────────────────────────────────────────── func TestDispatch_Block(t *testing.T) { diff --git a/p2p/export_test.go b/p2p/export_test.go index 18a1bbc..135dacf 100644 --- a/p2p/export_test.go +++ b/p2p/export_test.go @@ -95,7 +95,29 @@ func HostTriggerMaintain(h *Host) { // production peerList is populated by the MsgPeers peer-exchange // protocol; tests that skip the exchange call this helper instead. func HostAddKnownPeer(h *Host, addr string) { - h.addKnownPeers([]string{addr}) + h.addKnownPeers(&Peer{addr: "192.0.2.1:30303"}, []string{addr}) +} + +// HostTryPeerExchange exercises the untrusted MsgPeers admission path. +func HostTryPeerExchange(h *Host, addrs []string) { + h.addKnownPeers(&Peer{addr: "192.0.2.1:30303"}, addrs) +} + +func HostRegisterPeerForTest(h *Host, addr string, conn net.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + h.peers[addr] = &Peer{addr: addr, conn: conn} +} + +func HostDispatchForTest(h *Host, addr string, msgType MessageType, data []byte) error { + return h.dispatch(&Peer{addr: addr}, msgType, data) +} + +// HostKnownPeerCount returns the test host's discovered peer count. +func HostKnownPeerCount(h *Host) int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.peerList) } // HostKeepaliveInterval returns the KeepaliveInterval stored in the host's 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..8180787 100644 --- a/p2p/host.go +++ b/p2p/host.go @@ -209,12 +209,19 @@ type Handler interface { GetBlock(hash crypto.Hash32) *core.Block } +// TransactionAdmissionHandler lets a node signal whether a transaction has +// actually entered its mempool before the host relays it to other peers. +type TransactionAdmissionHandler interface { + AdmitTransaction(*core.Transaction) error +} + // BlockValidationHandler validates gossip without changing asynchronous // consensus admission or the wire protocol. type BlockValidationHandler interface { ValidateBlock(*core.Block) error } +// SyncHeaderValidationHandler validates a peer-provided header before it is accepted. type SyncHeaderValidationHandler interface { ValidateSyncHeader(core.BlockHeader) error } @@ -278,12 +285,13 @@ func (b *peerTokenBucket) wait() time.Duration { // Peer represents a connected remote node. type Peer struct { - conn net.Conn - addr string - id string - height uint64 - mu sync.Mutex - outbound bool + conn net.Conn + addr string + id string + height uint64 + mu sync.Mutex + outbound bool + peerExchangeCount int // lastPongAt is the Unix nanosecond timestamp of the most recent MsgPong // received from this peer. Initialized to the connection start time so @@ -492,9 +500,10 @@ type Host struct { handler Handler log *slog.Logger - mu sync.RWMutex - peers map[string]*Peer // addr → Peer - peerList []string // known peer addrs + mu sync.RWMutex + peers map[string]*Peer // addr → Peer + peerList []string // known peer addrs + peerExchangeSourceCounts map[string]int listener net.Listener done chan struct{} @@ -796,26 +805,27 @@ func NewHost(cfg Config, handler Handler, log *slog.Logger) *Host { // cancel in-flight dials via a single cancel func. defaultDialer := &net.Dialer{} h := &Host{ - cfg: cfg, - handler: handler, - log: log, - peers: make(map[string]*Peer), - done: make(chan struct{}), - mgr: newPeerMgrWithFile(cfg.BanFile), - gossip: NewGossipFilter(), - badBlockCounts: make(map[string]badBlockStrike), - tsStrikeCounts: make(map[string]badBlockStrike), - wlNets: wlNets, - wlIPs: wlIPs, - listenFunc: net.Listen, - dialContextFunc: defaultDialer.DialContext, - dialingIPs: make(map[string]map[uint64]context.CancelFunc), - pendingConns: make(map[string]map[uint64]net.Conn), - bootnodeLastResolved: make(map[string][]string), - bootnodeLastResolvedAt: make(map[string]time.Time), - bootnodeSet: make(map[string]struct{}), - maintainNow: make(chan struct{}, 1), - bootnodeFailState: make(map[string]bootnodeFailEntry), + cfg: cfg, + handler: handler, + log: log, + peers: make(map[string]*Peer), + peerExchangeSourceCounts: make(map[string]int), + done: make(chan struct{}), + mgr: newPeerMgrWithFile(cfg.BanFile), + gossip: NewGossipFilter(), + badBlockCounts: make(map[string]badBlockStrike), + tsStrikeCounts: make(map[string]badBlockStrike), + wlNets: wlNets, + wlIPs: wlIPs, + listenFunc: net.Listen, + dialContextFunc: defaultDialer.DialContext, + dialingIPs: make(map[string]map[uint64]context.CancelFunc), + pendingConns: make(map[string]map[uint64]net.Conn), + bootnodeLastResolved: make(map[string][]string), + bootnodeLastResolvedAt: make(map[string]time.Time), + bootnodeSet: make(map[string]struct{}), + maintainNow: make(chan struct{}, 1), + bootnodeFailState: make(map[string]bootnodeFailEntry), } h.txRate = newTxRateLimiter(cfg.TxRateBurst, cfg.TxRateSustained, cfg.TxRateBanThreshold) h.keepaliveIntervalNs.Store(int64(cfg.KeepaliveInterval)) @@ -2677,13 +2687,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 } @@ -3556,10 +3572,20 @@ func (h *Host) dispatch(peer *Peer, msgType MessageType, data []byte) error { if err := unmarshal(data, &tx); err != nil { return err } - // Gossip relay: forward to all other peers the first time we see this tx. + admitter, canAdmit := h.handler.(TransactionAdmissionHandler) + if !canAdmit { + // Preserve delivery for observers, but do not relay transactions + // unless the handler can confirm mempool acceptance. + h.handler.OnTransaction(&tx) + return nil + } + if err := admitter.AdmitTransaction(&tx); err != nil { + h.log.Debug("p2p: transaction rejected before gossip relay", "err", err) + return nil + } + // Gossip relay: forward only after mempool admission, and only once. txHash := tx.Hash() isNew := h.gossip.MarkAndCheck(txHash) - h.handler.OnTransaction(&tx) if isNew { fromAddr := peer.addr h.mu.RLock() @@ -3594,7 +3620,11 @@ func (h *Host) dispatch(peer *Peer, msgType MessageType, data []byte) error { if err := unmarshal(data, &msg); err != nil { return err } - h.addKnownPeers(msg.Addrs) + if len(msg.Addrs) > maxPeerExchangeAddrsPerMessage { + return fmt.Errorf("peer address message has %d entries; maximum is %d", + len(msg.Addrs), maxPeerExchangeAddrsPerMessage) + } + h.addKnownPeers(peer, msg.Addrs) return nil default: @@ -3754,25 +3784,77 @@ func (h *Host) peersToAdvertise() []string { // maxKnownPeers caps the peerList to prevent memory exhaustion from // unbounded peer-addr accumulation (and outbound dial-flood amplification). const maxKnownPeers = 512 +const maxPeerExchangeAddrsPerMessage = 32 +const maxPeerExchangeAddrsPerPeer = 32 +const maxPeerExchangeAddrsPerSource = 16 + +func (h *Host) addKnownPeers(source *Peer, addrs []string) { + if source == nil || len(addrs) == 0 || len(addrs) > maxPeerExchangeAddrsPerMessage { + return + } + sourceIP := connIP(source.addr) + if sourceIP == "" { + return + } + candidates := make([]string, 0, len(addrs)) + for _, addr := range addrs { + if normalized := normalizeGossipPeerAddress(addr); normalized != "" { + candidates = append(candidates, normalized) + } + } + if len(candidates) == 0 { + return + } + + source.mu.Lock() + remaining := maxPeerExchangeAddrsPerPeer - source.peerExchangeCount + if remaining <= 0 { + source.mu.Unlock() + return + } + if len(candidates) > remaining { + candidates = candidates[:remaining] + } + source.peerExchangeCount += len(candidates) + source.mu.Unlock() -func (h *Host) addKnownPeers(addrs []string) { h.mu.Lock() defer h.mu.Unlock() known := make(map[string]bool, len(h.peerList)) for _, a := range h.peerList { known[a] = true } - for _, a := range addrs { + for _, a := range candidates { if len(h.peerList) >= maxKnownPeers { break // cap reached; ignore excess peer addrs } + if h.peerExchangeSourceCounts[sourceIP] >= maxPeerExchangeAddrsPerSource { + break + } if !known[a] { h.peerList = append(h.peerList, a) known[a] = true + h.peerExchangeSourceCounts[sourceIP]++ } } } +// normalizeGossipPeerAddress accepts only literal IP:port endpoints. Peer +// exchange must not enqueue arbitrary strings, URL schemes, or DNS names for +// later outbound dialing. +func normalizeGossipPeerAddress(addr string) string { + host, portText, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return "" + } + ip := net.ParseIP(host) + port, err := strconv.Atoi(portText) + if ip == nil || err != nil || port < 1 || port > 65535 { + return "" + } + return net.JoinHostPort(ip.String(), strconv.Itoa(port)) +} + // ─── Block serialization (simplified JSON) ──────────────────────────────────── // SerializedBlock is a JSON-friendly block for network transmission. diff --git a/p2p/host_test.go b/p2p/host_test.go index ba5b8f3..5e471ee 100644 --- a/p2p/host_test.go +++ b/p2p/host_test.go @@ -25,10 +25,11 @@ import ( // ─── Stub handler ───────────────────────────────────────────────────────────── type stubHandler struct { - mu sync.Mutex - blocks []*core.Block - txs []*core.Transaction - votes []p2p.VoteMsg + mu sync.Mutex + blocks []*core.Block + txs []*core.Transaction + votes []p2p.VoteMsg + admitErr error } func (s *stubHandler) OnBlock(b *core.Block) { @@ -41,6 +42,13 @@ func (s *stubHandler) OnTransaction(tx *core.Transaction) { s.txs = append(s.txs, tx) s.mu.Unlock() } +func (s *stubHandler) AdmitTransaction(tx *core.Transaction) error { + if s.admitErr != nil { + return s.admitErr + } + s.OnTransaction(tx) + return nil +} func (s *stubHandler) OnVote(v p2p.VoteMsg) { s.mu.Lock() s.votes = append(s.votes, v) 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/p2p/transport_test.go b/p2p/transport_test.go index f3c5da6..3f12800 100644 --- a/p2p/transport_test.go +++ b/p2p/transport_test.go @@ -865,6 +865,103 @@ func TestTLS_HandshakeTimeout_SlotFreedOnDeadline(t *testing.T) { } } +// TestTLS_HandshakeFlood_AllSlotsFreedAfterTimeout verifies finite-burst +// recovery, not guaranteed admission under an attacker that continually refills +// slots. The per-source admission budget already prevents one IP from occupying +// all three slots, so use distinct loopback sources to genuinely saturate them. +func TestTLS_HandshakeFlood_AllSlotsFreedAfterTimeout(t *testing.T) { + const ( + slots = 3 + hsTimeout = 200 * time.Millisecond + budget = slots*hsTimeout + 100*time.Millisecond + ) + cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("generate host identity: %v", err) + } + cfgPeer, fpPeer, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("generate peer identity: %v", err) + } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "tls-burst-recovery", + UserAgent: "aperod/test", + TLSConfig: cfgHost, + AllowedPeers: []string{fpPeer}, + MaxPendingHandshakes: slots, + HandshakeTimeout: hsTimeout, + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer host.Stop() + + started := time.Now() + for _, source := range []string{"127.0.0.2", "127.0.0.3", "127.0.0.4"} { + dialer := net.Dialer{ + Timeout: time.Second, + LocalAddr: &net.TCPAddr{IP: net.ParseIP(source)}, + } + slow, err := dialer.Dial("tcp", host.ListenAddr()) + if err != nil { + t.Fatalf("dial stalled source %s: %v", source, err) + } + // Keep every client open until assertions finish: local cleanup must + // not be what releases the server's pending-handshake slots. + defer slow.Close() + if err := slow.SetWriteDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatal(err) + } + // A valid TLS record header with an incomplete ClientHello body. + if _, err := slow.Write([]byte{0x16, 0x03, 0x03, 0x00, 0x40, 0x01, 0x00}); err != nil { + t.Fatalf("send partial ClientHello from %s: %v", source, err) + } + } + if !waitFor(hsTimeout/2, func() bool { return host.PendingHandshakes() == slots }) { + t.Fatalf("flood never occupied all %d slots; pending=%d", slots, host.PendingHandshakes()) + } + if host.PeerCount() != 0 { + t.Fatal("incomplete TLS clients were registered as peers") + } + remaining := time.Until(started.Add(budget)) + if remaining <= 0 || !waitFor(remaining, func() bool { return host.PendingHandshakes() == 0 }) { + t.Fatalf("%d pending handshakes remain after %v budget", host.PendingHandshakes(), budget) + } + t.Logf("all %d stalled handshakes drained in %v (budget %v)", slots, time.Since(started), budget) + + // Admission is not proven by the counter alone: complete mutual TLS and + // the application's asymmetric Ping/Pong handshake using a pinned peer. + legitimate, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", host.ListenAddr(), cfgPeer) + if err != nil { + t.Fatalf("legitimate TLS peer cannot connect after drain: %v", err) + } + defer legitimate.Close() + // ReadMsg overwrites deadlines, so separately bound a stalled handshake. + stall := time.AfterFunc(time.Second, func() { _ = legitimate.Close() }) + defer stall.Stop() + if err := p2p.WriteMsg(legitimate, p2p.MsgPing, p2p.PingMsg{ + NodeID: "legitimate-after-flood", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + t.Fatalf("legitimate peer Ping: %v", err) + } + msgType, _, err := p2p.ReadMsg(legitimate) + if err != nil || msgType != p2p.MsgPong { + t.Fatalf("legitimate peer expected Pong, got %v: %v", msgType, err) + } + stall.Stop() + if p2p.PeerFingerprint(legitimate) != fpHost { + t.Fatal("legitimate peer connected to an unexpected TLS identity") + } + if !waitFor(100*time.Millisecond, func() bool { + return host.PeerCount() == 1 && host.PendingHandshakes() == 0 + }) { + t.Fatalf("handshake did not finish cleanly: peers=%d pending=%d", host.PeerCount(), host.PendingHandshakes()) + } +} + func TestKeepalive_SilentPeerEvicted(t *testing.T) { const ( ka = 100 * time.Millisecond // keepalive interval 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 { diff --git a/wallet/scanner.go b/wallet/scanner.go index 424882d..fcaf370 100644 --- a/wallet/scanner.go +++ b/wallet/scanner.go @@ -17,7 +17,8 @@ type Scanner struct { // NewScannerFromDerived creates a Scanner from a DerivedKeys set. // viewPriv is available via dk.Keys.View.Private. func NewScannerFromDerived(dk *DerivedKeys) *Scanner { - s := core.NewWalletScanner( + s := core.NewWalletScannerWithSpendKey( + dk.Keys.Spend.Private, dk.Keys.View.Private, dk.Keys.Spend.Public, dk.Keys.View.Public, @@ -26,6 +27,15 @@ func NewScannerFromDerived(dk *DerivedKeys) *Scanner { return &Scanner{inner: s, addr: dk.Address} } +// NewScannerFromKeysWithSpendKey creates a scanner that can authenticate and +// decrypt amount-bound outputs after the v8 protocol activation. +func NewScannerFromKeysWithSpendKey(spendPriv, viewPriv crypto.Scalar32, spendPub, viewPub crypto.Point32, net crypto.NetworkByte) *Scanner { + return &Scanner{ + inner: core.NewWalletScannerWithSpendKey(spendPriv, viewPriv, spendPub, viewPub, net), + addr: crypto.EncodeAddress(net, spendPub, viewPub), + } +} + // NewScannerFromKeys creates a Scanner from raw key material. func NewScannerFromKeys(viewPriv crypto.Scalar32, spendPub, viewPub crypto.Point32, net crypto.NetworkByte) *Scanner { return &Scanner{ @@ -42,12 +52,22 @@ func (s *Scanner) ScanBlock(block *core.Block) []core.OwnedUTXO { return s.inner.ScanBlock(block) } +// ScanBlockVerified preserves amount-proof or missing-spend-key errors. +func (s *Scanner) ScanBlockVerified(block *core.Block) ([]core.OwnedUTXO, error) { + return s.inner.ScanBlockVerified(block) +} + // ScanChain scans blocks [startHeight, endHeight] (inclusive) and returns all // owned UTXOs found. Stops early if chain.GetByHeight returns nil. func (s *Scanner) ScanChain(chain *core.Chain, startHeight, endHeight uint64) []core.OwnedUTXO { return s.inner.ScanChain(chain, startHeight, endHeight) } +// ScanChainVerified preserves amount-proof or missing-spend-key errors. +func (s *Scanner) ScanChainVerified(chain *core.Chain, startHeight, endHeight uint64) ([]core.OwnedUTXO, error) { + return s.inner.ScanChainVerified(chain, startHeight, endHeight) +} + // Balance scans the entire chain up to its tip height and returns the total // confirmed balance (in base units) along with the owned UTXO slice. func (s *Scanner) Balance(chain *core.Chain) (balance uint64, owned []core.OwnedUTXO) {