diff --git a/api/lpod_earnings_outputs_test.go b/api/lpod_earnings_outputs_test.go index ae5ff11..abf6c7c 100644 --- a/api/lpod_earnings_outputs_test.go +++ b/api/lpod_earnings_outputs_test.go @@ -4,15 +4,22 @@ package api_test import ( + "encoding/base64" + "encoding/binary" "encoding/json" "fmt" "net/http" + "net/url" + "os" + "strings" "testing" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" ) // This uses a synthetic, signed v3 funding transition and its real earnings @@ -40,6 +47,12 @@ func TestLPoDEarningsOutputsServesOnlyIndexedPureRewards(t *testing.T) { if code != http.StatusOK || body["state"] != "active" || body["checkpoint_hash"] != fmt.Sprintf("%x", tip[:]) { t.Fatalf("finalized earnings response: status=%d body=%#v", code, body) } + if _, ok := body["snapshot_id"]; ok { + t.Fatalf("legacy unpinned response unexpectedly contains snapshot_id: %#v", body) + } + if _, ok := body["checkpoint_height"]; ok { + t.Fatalf("legacy unpinned response unexpectedly contains checkpoint_height: %#v", body) + } outputs, ok := body["outputs"].([]interface{}) if !ok || len(outputs) == 0 { t.Fatalf("synthetic canonical leader reward missing from API: %#v", body["outputs"]) @@ -68,6 +81,250 @@ func TestLPoDEarningsOutputsServesOnlyIndexedPureRewards(t *testing.T) { } } +func TestLPoDEarningsOutputsPaginatesLegacyCursorAt128Rows(t *testing.T) { + dbPath := t.TempDir() + db, migration, address, _ := syntheticLPoDV3FundingAt(t, dbPath) + _, fixtureHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + db = seedSyntheticEarningsRows(t, dbPath, db, address, fixtureHeight) + + srv, _ := buildChainServer(t, 0) + srv.SetStore(db) + tip, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + srv.SetLPoDConfig(migration, func(h uint64, hash crypto.Hash32) bool { + return h == height && hash == tip + }) + + basePath := "/api/v1/lpod/earnings-outputs?address=" + string(address) + firstCode, first := restGet(t, srv, basePath) + firstRows, ok := first["outputs"].([]interface{}) + firstCursor, cursorOK := first["next_cursor"].(string) + wantCheckpoint := fmt.Sprintf("%x", tip[:]) + if firstCode != http.StatusOK || !ok || len(firstRows) != 128 || + first["state"] != "active" || first["checkpoint_hash"] != wantCheckpoint || + !cursorOK || firstCursor == "" { + t.Fatalf("first legacy earnings page: status=%d body=%#v", firstCode, first) + } + if _, ok := first["snapshot_id"]; ok { + t.Fatalf("legacy response unexpectedly contains snapshot_id: %#v", first) + } + if _, ok := first["checkpoint_height"]; ok { + t.Fatalf("legacy response unexpectedly contains checkpoint_height: %#v", first) + } + for index, value := range firstRows { + row, ok := value.(map[string]interface{}) + if !ok || row["block_height"] != float64(fixtureHeight) || + row["out_idx"] != float64(index+1) { + t.Fatalf("first page is not in index order at position %d: %#v", index, value) + } + } + + decodedCursor, err := base64.RawURLEncoding.DecodeString(firstCursor) + if err != nil { + t.Fatalf("decode legacy cursor: %v", err) + } + cursorParts := strings.SplitN(string(decodedCursor), ":", 2) + if len(cursorParts) != 2 || cursorParts[0] != wantCheckpoint { + t.Fatalf("legacy cursor is not bound to the returned checkpoint: %q", decodedCursor) + } + + secondCode, second := restGet(t, srv, basePath+"&cursor="+firstCursor) + secondRows, ok := second["outputs"].([]interface{}) + if secondCode != http.StatusOK || !ok || len(secondRows) != 1 || + second["state"] != "active" || second["checkpoint_hash"] != wantCheckpoint || + second["next_cursor"] != "" { + t.Fatalf("second legacy earnings page: status=%d body=%#v", secondCode, second) + } + lastRow, ok := secondRows[0].(map[string]interface{}) + if !ok || lastRow["block_height"] != float64(fixtureHeight) || + lastRow["out_idx"] != float64(129) { + t.Fatalf("second page did not continue after row 128: %#v", secondRows) + } + if _, ok := second["snapshot_id"]; ok { + t.Fatalf("legacy response unexpectedly contains snapshot_id: %#v", second) + } + if _, ok := second["checkpoint_height"]; ok { + t.Fatalf("legacy response unexpectedly contains checkpoint_height: %#v", second) + } + + if fixturePath := os.Getenv("APERO_NODE_LPOD_EARNINGS_PAGE_FIXTURE"); fixturePath != "" { + fixture, err := json.Marshal(map[string]interface{}{ + "address": string(address), + "checkpoint_hash": wantCheckpoint, + "checkpoint_height": height, + "first_page": first, + "second_page": second, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fixturePath, fixture, 0o600); err != nil { + t.Fatalf("write sanitized earnings fixture: %v", err) + } + } +} + +func TestLPoDEarningsOutputsRejectsCursorFromDifferentAddress(t *testing.T) { + dbPath := t.TempDir() + db, migration, addressA, _ := syntheticLPoDV3FundingAt(t, dbPath) + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addressB := crypto.AddressFromKeys(crypto.MainnetByte, keys) + if addressA == addressB { + t.Fatal("synthetic wallets unexpectedly share an address") + } + _, fixtureHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + db = seedSyntheticEarningsRows(t, dbPath, db, addressA, fixtureHeight, addressB) + + srv, _ := buildChainServer(t, 0) + srv.SetStore(db) + tip, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + srv.SetLPoDConfig(migration, func(h uint64, hash crypto.Hash32) bool { + return h == height && hash == tip + }) + + firstCode, first := restGet( + t, + srv, + "/api/v1/lpod/earnings-outputs?address="+string(addressA), + ) + firstRows, ok := first["outputs"].([]interface{}) + cursor, cursorOK := first["next_cursor"].(string) + if firstCode != http.StatusOK || !ok || len(firstRows) != 128 || !cursorOK || cursor == "" { + t.Fatalf("first wallet page did not return a continuation cursor: status=%d body=%#v", firstCode, first) + } + decodedCursor, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + t.Fatalf("decode wallet A cursor: %v", err) + } + cursorParts := strings.SplitN(string(decodedCursor), ":", 2) + if len(cursorParts) != 2 { + t.Fatalf("wallet A cursor has an invalid envelope: %q", decodedCursor) + } + if _, _, err := db.LPoDEarningsOutputs(addressB, cursorParts[1], 128); err == nil { + t.Fatal("store accepted wallet A's LevelDB cursor for wallet B") + } + + walletBCode, walletB := restGet( + t, + srv, + "/api/v1/lpod/earnings-outputs?address="+string(addressB), + ) + walletBRows, ok := walletB["outputs"].([]interface{}) + if walletBCode != http.StatusOK || !ok || len(walletBRows) != 128 { + t.Fatalf("wallet B's own first page was not available: status=%d body=%#v", walletBCode, walletB) + } + firstBRow, ok := walletBRows[0].(map[string]interface{}) + if !ok || firstBRow["tx_hash"] == nil { + t.Fatalf("wallet B's first indexed output is malformed: %#v", walletBRows[0]) + } + firstARow, ok := firstRows[0].(map[string]interface{}) + if !ok || firstARow["tx_hash"] == firstBRow["tx_hash"] { + t.Fatalf("wallet B page exposed wallet A's indexed output: A=%#v B=%#v", firstARow, firstBRow) + } + + reusedCode, reused := restGet( + t, + srv, + "/api/v1/lpod/earnings-outputs?"+url.Values{ + "address": {string(addressB)}, + "cursor": {cursor}, + }.Encode(), + ) + if reusedCode != http.StatusBadRequest || reused["error"] != "invalid cursor" || + reused["outputs"] != nil { + t.Fatalf("cursor from wallet A was not rejected for wallet B: status=%d body=%#v", reusedCode, reused) + } +} + +func seedSyntheticEarningsRows( + t *testing.T, + path string, + db *store.DB, + address crypto.Address, + height uint64, + additionalAddresses ...crypto.Address, +) *store.DB { + t.Helper() + existing, _, err := db.LPoDEarningsOutputs(address, "", 128) + if err != nil || len(existing) == 0 { + t.Fatalf("synthetic funding did not create an earnings row: rows=%d err=%v", len(existing), err) + } + template := existing[0] + if err := db.Close(); err != nil { + t.Fatalf("close synthetic store before LevelDB fixture seeding: %v", err) + } + + raw, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatalf("open temporary LevelDB for synthetic earnings rows: %v", err) + } + batch := new(leveldb.Batch) + addresses := append([]crypto.Address{address}, additionalAddresses...) + for _, owner := range addresses { + addressHash := crypto.HashBytes([]byte(owner)) + prefix := append([]byte("lpod/earnings-output/v2/"), addressHash[:]...) + iter := raw.NewIterator(util.BytesPrefix(prefix), nil) + for iter.Next() { + batch.Delete(append([]byte(nil), iter.Key()...)) + } + if err := iter.Error(); err != nil { + iter.Release() + raw.Close() + t.Fatalf("scan synthetic earnings index: %v", err) + } + iter.Release() + + for i := uint64(1); i <= 129; i++ { + row := template + row.BlockHeight = height + row.OutputIndex = uint32(i) + row.TxHash = crypto.HashBytes([]byte(fmt.Sprintf("synthetic-earnings-tx-%s-%d", owner, i))) + row.AmountNAPRO = 1_000_000 + i + + var suffix [12]byte + binary.BigEndian.PutUint64(suffix[:8], row.BlockHeight) + binary.BigEndian.PutUint32(suffix[8:], row.OutputIndex) + blockHash := crypto.HashBytes([]byte(fmt.Sprintf("synthetic-earnings-block-%s-%d", owner, i))) + key := append(append(append([]byte(nil), prefix...), suffix[:]...), blockHash[:]...) + key = append(key, row.TxHash[:]...) + value, err := json.Marshal(row) + if err != nil { + raw.Close() + 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() + t.Fatalf("write synthetic earnings rows: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close temporary LevelDB after seeding: %v", err) + } + + reopened, err := store.Open(path) + if err != nil { + t.Fatalf("reopen synthetic store after seeding: %v", err) + } + t.Cleanup(func() { reopened.Close() }) + return reopened +} + func syntheticLPoDV3Funding(t *testing.T) (*store.DB, *store.LPoDMigration, crypto.Address) { db, m, address, _ := syntheticLPoDV3FundingWithSigner(t) return db, m, address diff --git a/api/lpod_wallet_outputs.go b/api/lpod_wallet_outputs.go index 986b4ee..900be2a 100644 --- a/api/lpod_wallet_outputs.go +++ b/api/lpod_wallet_outputs.go @@ -8,9 +8,11 @@ import ( "encoding/hex" "encoding/json" "fmt" - "github.com/aperod/aperod/crypto" "net/http" "strings" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func (s *Server) restLPoDWalletOutputs(w http.ResponseWriter, r *http.Request) { @@ -88,40 +90,79 @@ func (s *Server) restLPoDEarningsOutputs(w http.ResponseWriter, r *http.Request) writeJSONError(w, 503, "canonical store unavailable") return } - hash, height, err := s.blockStore.GetTip() - if err != nil || s.lpodFinalized == nil || !s.lpodFinalized(height, hash) { - writeJSONError(w, 503, "finalized tip required") - return - } - c, err := s.blockStore.LoadLPoDCheckpointAt(hash) - if err != nil || c == nil || c.Allocation == nil || c.Allocation.Version != 3 || - c.Allocation.Genesis != s.lpodMigration.Genesis || - c.Allocation.ReconciliationRoot != s.lpodMigration.ReconciliationRoot || - c.Allocation.FundingHeight != s.lpodMigration.Height || c.State.LastHeight != height { - writeJSONError(w, 503, "canonical v3 LPoD checkpoint required") - return - } - ready, err := s.blockStore.LPoDEarningsIndexReady(c.Allocation.FundingBlock, hash, height) - if err != nil || !ready { - writeJSONError(w, 503, "v2 earnings index is incomplete through the finalized tip") - return - } - cursor := r.URL.Query().Get("cursor") - if cursor != "" { - decoded, err := base64.RawURLEncoding.DecodeString(cursor) - if err != nil { - writeJSONError(w, 400, "invalid cursor") + query := r.URL.Query() + hash := crypto.Hash32{} + var height uint64 + var rows []store.StoredUTXO + var next string + var err error + var pinnedSnapshotID string + + if query.Has("snapshot_id") { + pinnedSnapshotID = query.Get("snapshot_id") + lease := s.acquireWalletSnapshot(w, pinnedSnapshotID, address) + if lease == nil { return } - parts := strings.SplitN(string(decoded), ":", 2) - bound, err := hex.DecodeString(parts[0]) - if err != nil || len(parts) != 2 || len(bound) != 32 || !strings.EqualFold(parts[0], fmt.Sprintf("%x", hash[:])) { - writeJSONError(w, 409, "cursor is bound to a different finalized checkpoint") + defer lease.Release() + + entry := lease.entry + checkpoint := entry.checkpoint + if entry.read == nil || checkpoint == nil || checkpoint.Allocation == nil || + checkpoint.Allocation.Version != 3 || + checkpoint.Allocation.Genesis != s.lpodMigration.Genesis || + checkpoint.Allocation.ReconciliationRoot != s.lpodMigration.ReconciliationRoot || + checkpoint.Allocation.FundingHeight != s.lpodMigration.Height || + checkpoint.State.LastHeight != entry.height { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_UNAVAILABLE", "pinned v3 LPoD checkpoint is unavailable") return } - cursor = parts[1] + ready, readyErr := entry.read.LPoDEarningsIndexReady( + checkpoint.Allocation.FundingBlock, entry.hash, entry.height, + ) + if readyErr != nil || !ready { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "EARNINGS_INDEX_NOT_READY", "LPoD earnings index is incomplete at the pinned wallet snapshot") + return + } + hash, height = entry.hash, entry.height + cursor, ok := unwrapLPoDEarningsCursor(w, query.Get("cursor"), hash) + if !ok { + return + } + rows, next, err = entry.read.LPoDEarningsOutputs(address, cursor, 128) + } else { + hash, height, err = s.blockStore.GetTip() + if err != nil || s.lpodFinalized == nil || !s.lpodFinalized(height, hash) { + writeJSONError(w, 503, "finalized tip required") + return + } + checkpoint, checkpointErr := s.blockStore.LoadLPoDCheckpointAt(hash) + if checkpointErr != nil || checkpoint == nil || checkpoint.Allocation == nil || checkpoint.Allocation.Version != 3 || + checkpoint.Allocation.Genesis != s.lpodMigration.Genesis || + checkpoint.Allocation.ReconciliationRoot != s.lpodMigration.ReconciliationRoot || + checkpoint.Allocation.FundingHeight != s.lpodMigration.Height || checkpoint.State.LastHeight != height { + writeJSONError(w, 503, "canonical v3 LPoD checkpoint required") + return + } + ready, readyErr := s.blockStore.LPoDEarningsIndexReady(checkpoint.Allocation.FundingBlock, hash, height) + if readyErr != nil || !ready { + writeJSONError(w, 503, "v2 earnings index is incomplete through the finalized tip") + return + } + cursor, ok := unwrapLPoDEarningsCursor(w, query.Get("cursor"), hash) + if !ok { + return + } + rows, next, err = s.blockStore.LPoDEarningsOutputs(address, cursor, 128) + if err == nil { + after, afterHeight, tipErr := s.blockStore.GetTip() + if tipErr != nil || after != hash || afterHeight != height || + s.lpodFinalized == nil || !s.lpodFinalized(afterHeight, after) { + writeJSONError(w, 409, "canonical finalized checkpoint changed") + return + } + } } - rows, next, err := s.blockStore.LPoDEarningsOutputs(address, cursor, 128) if err != nil { writeJSONError(w, 400, err.Error()) return @@ -139,20 +180,54 @@ func (s *Server) restLPoDEarningsOutputs(w http.ResponseWriter, r *http.Request) "amount_napro": fmt.Sprintf("%d", u.AmountNAPRO), }) } - after, h, err := s.blockStore.GetTip() - if err != nil || after != hash || h != height || s.lpodFinalized == nil || !s.lpodFinalized(h, after) { - writeJSONError(w, 409, "canonical finalized checkpoint changed") - return - } nextCursor := "" if next != "" { token := fmt.Sprintf("%x:%s", hash[:], next) nextCursor = base64.RawURLEncoding.EncodeToString([]byte(token)) } - writeJSON(w, 200, map[string]interface{}{ + response := map[string]interface{}{ "state": "active", "checkpoint_hash": fmt.Sprintf("%x", hash[:]), "outputs": outputs, "next_cursor": nextCursor, - }) + } + if pinnedSnapshotID != "" { + response["snapshot_id"] = pinnedSnapshotID + response["checkpoint_height"] = height + } + writeJSON(w, 200, response) +} + +func unwrapLPoDEarningsCursor( + w http.ResponseWriter, + cursor string, + checkpointHash crypto.Hash32, +) (string, bool) { + if cursor == "" { + return "", true + } + if len(cursor) > 512 { + writeJSONError(w, 400, "invalid cursor") + return "", false + } + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + writeJSONError(w, 400, "invalid cursor") + return "", false + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 || parts[1] == "" { + writeJSONError(w, 400, "invalid cursor") + return "", false + } + bound, err := hex.DecodeString(parts[0]) + if err != nil || len(bound) != 32 { + writeJSONError(w, 400, "invalid cursor") + return "", false + } + if !strings.EqualFold(parts[0], fmt.Sprintf("%x", checkpointHash[:])) { + writeJSONError(w, 409, "cursor is bound to a different finalized checkpoint") + return "", false + } + return parts[1], true } // Public key images, not keys/blinds, identify locally scanned consumed outputs. diff --git a/api/middleware.go b/api/middleware.go index acf91f8..547e683 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -158,15 +158,18 @@ func (h *heavyRL) allow(ip string) bool { // globalHeavyRL is shared across all rate-limiter middleware instances. var globalHeavyRL = newHeavyRL() -// isHeavyPath returns true for endpoints whose response cost is O(N) in the -// UTXO-set size. These get the stricter heavyRL bucket (2 req/s, burst 5). +// isHeavyPath returns true for endpoints with chain- or UTXO-sized scan costs. +// These get the stricter heavyRL bucket (2 req/s, burst 5). func isHeavyPath(path string) bool { - // /api/v1/address/{addr}/utxos — full UTXO-set copy + per-UTXO point ops. + // Address history scans retained blocks; address UTXOs scan the full UTXO set. const pfx = "/api/v1/address/" - const sfx = "/utxos" - if len(path) > len(pfx)+len(sfx) { - if path[:len(pfx)] == pfx && path[len(path)-len(sfx):] == sfx { - return true + if len(path) > len(pfx) { + if path[:len(pfx)] == pfx { + for _, suffix := range [...]string{"/utxos", "/transactions"} { + if len(path) > len(pfx)+len(suffix) && path[len(path)-len(suffix):] == suffix { + return true + } + } } } switch path { diff --git a/api/middleware_path_test.go b/api/middleware_path_test.go new file mode 100644 index 0000000..9592187 --- /dev/null +++ b/api/middleware_path_test.go @@ -0,0 +1,25 @@ +package api + +import "testing" + +func TestIsHeavyPathIncludesAddressHistory(t *testing.T) { + for _, path := range []string{ + "/api/v1/address/aproExample/transactions", + "/api/v1/address/aproExample/utxos", + "/api/v1/avm/query", + } { + if !isHeavyPath(path) { + t.Errorf("isHeavyPath(%q) = false, want true", path) + } + } + for _, path := range []string{ + "/api/v1/address/aproExample", + "/api/v1/address/aproExample/transactions/extra", + "/api/v1/address/aproExample/scan", + "/api/v1/status", + } { + if isHeavyPath(path) { + t.Errorf("isHeavyPath(%q) = true, want false", path) + } + } +} diff --git a/api/rest.go b/api/rest.go index 9200d22..30b8f26 100644 --- a/api/rest.go +++ b/api/rest.go @@ -730,7 +730,7 @@ type AddressUTXO struct { // 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 *uint64 `json:"amount_napr"` + 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 // these out so +0 APRO block-reward entries never pollute user history. @@ -939,10 +939,12 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { return } - // Scan canonical chain for outputs matching the spend pub key (transparent match only). + // Scan the currently retained canonical chain window for outputs matching + // the spend pub key (transparent match only). Older block bodies are no + // longer available through Chain.GetByHeight. // Full privacy scanning requires a view key — that is a Phase 2+ feature. - tip := s.chain.Tip() - if tip == nil { + blocks := s.chain.BlocksNewestFirst() + if len(blocks) == 0 { writeJSON(w, http.StatusOK, map[string]interface{}{"transactions": []AddressTx{}, "address": addrStr}) return } @@ -955,11 +957,11 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { } results := make([]AddressTx, 0, 16) - for h := int64(tip.Header.Height); h >= 0 && len(results) < limit; h-- { - b := s.chain.GetByHeight(uint64(h)) - if b == nil { - continue + for _, b := range blocks { + if len(results) >= limit { + break } + h := b.Header.Height txHash := func(i int) string { hh := b.Txs[i].Hash() return fmt.Sprintf("%x", hh[:]) @@ -968,7 +970,7 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { // (see core.BuildMintTx). Recompute the expected key for this height // so per-block coinbase rewards are still discoverable by address scan. expectedMintPub := spendPub - if heightPub, hErr := crypto.ScalarMulBase(crypto.ScalarFromUint64(uint64(h))); hErr == nil { + if heightPub, hErr := crypto.ScalarMulBase(crypto.ScalarFromUint64(h)); hErr == nil { if shifted, aErr := crypto.AddPoints(spendPub, heightPub); aErr == nil { expectedMintPub = shifted } @@ -979,7 +981,7 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { if out.OneTimePub == spendPub || out.OneTimePub == expectedMintPub { results = append(results, AddressTx{ TxHash: txHash(i), - BlockHeight: uint64(h), + BlockHeight: h, TxIndex: i, OutputIndex: j, IsCoinbase: isCoinbase, @@ -2184,15 +2186,34 @@ func (s *Server) restKeyImageIsSpent(w http.ResponseWriter, r *http.Request) { } func (s *Server) restUTXODecoys(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSONError(w, http.StatusMethodNotAllowed, "GET only") - return - } - + w.Header().Set("Cache-Control", "no-store") const defaultCount = 120 const maxCount = 512 count := defaultCount - if raw := r.URL.Query().Get("count"); raw != "" { + rawID, rawAddress := "", "" + snapshotScoped := false + if r.Method == http.MethodPost { + var request struct { + Count int `json:"count"` + Address string `json:"address"` + SnapshotID string `json:"snapshot_id"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid snapshot decoy request") + return + } + if request.Count <= 0 || request.Count > maxCount || request.Address == "" || request.SnapshotID == "" { + writeJSONError(w, http.StatusBadRequest, "count, address, and snapshot_id are required") + return + } + count, rawID, rawAddress, snapshotScoped = request.Count, request.SnapshotID, request.Address, true + } else if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "GET or snapshot-scoped POST only") + return + } else if r.URL.Query().Get("address") != "" || r.URL.Query().Get("snapshot_id") != "" { + writeJSONError(w, http.StatusBadRequest, "snapshot-bound decoys require POST") + return + } else if raw := r.URL.Query().Get("count"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 { writeJSONError(w, http.StatusBadRequest, "count must be a positive integer") @@ -2203,16 +2224,87 @@ func (s *Server) restUTXODecoys(w http.ResponseWriter, r *http.Request) { } count = n } + if s.utxos == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ring members unavailable") + return + } // Browser-local signers build v5 CLSAG transactions, whose ring commits // bind every member to the canonical on-chain commitment. Sample from the // persistent ring-member store rather than the legacy spent-only pool. - decoys := s.utxos.SampleCLSAGDecoys(count, nil) - type decoyEntry struct { OneTimePubHex string `json:"one_time_pub_hex"` AmountCommitHex string `json:"amount_commit_hex"` } + if snapshotScoped { + address, err := validSnapshotAddress(rawAddress) + if err != nil || rawID == "" { + writeJSONError(w, http.StatusBadRequest, "valid address and snapshot_id are required") + return + } + if s.walletSnapshots == nil { + writeSnapshotLeaseError(w, errWalletSnapshotExpired) + return + } + lease := s.acquireWalletSnapshot(w, rawID, address) + if lease == nil { + return + } + defer lease.Release() + if s.utxos == nil { + writeJSONError(w, http.StatusServiceUnavailable, "ring members unavailable") + return + } + type snapshotDecoy struct { + TxHash string `json:"tx_hash"` + OutputIndex uint32 `json:"out_idx"` + BlockHeight uint64 `json:"block_height"` + OneTimePub string `json:"one_time_pub"` + TxPubKey string `json:"tx_pub_key"` + AmountCommit string `json:"amount_commit"` + EncAmount string `json:"enc_amount"` + } + // The caller's count is bounded above, but keep initial allocation + // independent of request data so future parser changes cannot turn it + // into an attacker-sized preallocation. + excluded := make(map[crypto.Point32]bool) + outputs := make([]snapshotDecoy, 0, defaultCount) + for attempt := 0; attempt < 8 && len(outputs) < count; attempt++ { + candidates := s.utxos.SampleCLSAGDecoys(count-len(outputs), excluded) + if len(candidates) == 0 { + break + } + for _, d := range candidates { + excluded[d.OneTimePub] = true + member, err := lease.entry.read.LookupRingMember(d.OneTimePub) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "pinned ring member lookup failed") + return + } + // The sampler reads the live ring index. A block may have + // landed after this wallet pin, or a rollback may have changed + // the index, so keep only exact pairs present in the pin. + // Retry a bounded number of times rather than mixing snapshots. + if member == nil || member.OneTimePub != d.OneTimePub || + member.AmountCommit != d.AmountCommit || member.ProtocolLocked || + member.BlockHeight > lease.entry.height { + continue + } + outputs = append(outputs, snapshotDecoy{ + TxHash: fmt.Sprintf("%x", member.TxHash[:]), OutputIndex: member.OutputIndex, + BlockHeight: member.BlockHeight, OneTimePub: fmt.Sprintf("%x", member.OneTimePub[:]), + TxPubKey: fmt.Sprintf("%x", member.TxPubKey[:]), AmountCommit: fmt.Sprintf("%x", member.AmountCommit[:]), + EncAmount: fmt.Sprintf("%x", member.EncAmount[:]), + }) + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "snapshot_id": rawID, "checkpoint_hash": fmt.Sprintf("%x", lease.entry.hash[:]), + "checkpoint_height": lease.entry.height, "count": len(outputs), "outputs": outputs, + }) + return + } + decoys := s.utxos.SampleCLSAGDecoys(count, nil) entries := make([]decoyEntry, len(decoys)) for i, d := range decoys { entries[i] = decoyEntry{ diff --git a/api/rest_test.go b/api/rest_test.go index 3821a0d..a2a403e 100644 --- a/api/rest_test.go +++ b/api/rest_test.go @@ -346,6 +346,64 @@ func TestREST_AddressTxs_IsCoinbaseFlagged(t *testing.T) { } } +func TestREST_AddressTxsUsesNewestRetainedBlocks(t *testing.T) { + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + spendPub := crypto.Point32(pub) + makeBlock := func(height uint64, prev crypto.Hash32) *core.Block { + txs := []core.Transaction{core.CoinbaseTx(spendPub, 1_000_000+height)} + header := core.BlockHeader{ + Height: height, + PrevHash: prev, + Timestamp: time.Now().UnixNano() + int64(height), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(txs), + } + if err := header.Sign(priv); err != nil { + t.Fatalf("Sign block %d: %v", height, err) + } + return &core.Block{Header: header, Txs: txs} + } + + chain := core.NewChain(3) + prev := makeBlock(0, crypto.Hash32{}) + if err := chain.SetGenesis(prev); err != nil { + t.Fatalf("SetGenesis: %v", err) + } + for height := uint64(1); height <= 5; height++ { + block := makeBlock(height, prev.Hash()) + if err := chain.AddBlock(block); err != nil { + t.Fatalf("AddBlock(%d): %v", height, err) + } + prev = block + } + + srv := api.NewServer(":0", chain, core.NewMempool(core.DefaultMempoolConfig()), core.NewUTXOSet(), testLogger()) + addr := crypto.EncodeAddress(crypto.MainnetByte, spendPub, spendPub) + code, resp := restGet(t, srv, "/api/v1/address/"+string(addr)+"/transactions?limit=10") + if code != http.StatusOK { + t.Fatalf("status = %d, want 200", code) + } + txs, ok := resp["transactions"].([]interface{}) + if !ok { + t.Fatalf("transactions = %T, want array", resp["transactions"]) + } + if len(txs) != 3 { + t.Fatalf("got %d transactions, want only the 3 retained blocks", len(txs)) + } + for i, wantHeight := range []float64{5, 4, 3} { + tx, ok := txs[i].(map[string]interface{}) + if !ok { + t.Fatalf("transactions[%d] = %T, want object", i, txs[i]) + } + if got := tx["block_height"]; got != wantHeight { + t.Errorf("transactions[%d].block_height = %v, want %v", i, got, wantHeight) + } + } +} + func TestREST_AddressTxs_MethodNotAllowed(t *testing.T) { srv, _ := buildChainServer(t, 0) wk, _ := crypto.GenerateWalletKeys() @@ -872,7 +930,7 @@ func TestREST_AddressUTXOs_StealthNotReturnedWithoutViewKey(t *testing.T) { } if entry["amount_napr"] == nil { t.Error("with view key: amount_napr must be decoded (non-null) for stealth output") - } else if uint64(entry["amount_napr"].(float64)) != testAmountNapr { + } else if entry["amount_napr"] != fmt.Sprint(testAmountNapr) { t.Errorf("with view key: amount_napr = %v, want %d", entry["amount_napr"], testAmountNapr) } } @@ -1226,7 +1284,7 @@ func TestREST_AddressScan_PostViewKey(t *testing.T) { entry := list[0].(map[string]interface{}) if entry["amount_napr"] == nil { t.Error("amount_napr must be non-null when view key is provided via POST body") - } else if uint64(entry["amount_napr"].(float64)) != testAmt { + } else if entry["amount_napr"] != fmt.Sprint(testAmt) { t.Errorf("amount_napr = %v, want %d", entry["amount_napr"], testAmt) } } diff --git a/api/rest_utxo_viewkey_test.go b/api/rest_utxo_viewkey_test.go index fa717d8..a643cef 100644 --- a/api/rest_utxo_viewkey_test.go +++ b/api/rest_utxo_viewkey_test.go @@ -8,6 +8,7 @@ package api_test import ( "encoding/hex" + "fmt" "net/http" "testing" @@ -74,12 +75,12 @@ func TestREST_AddressUTXOs_StealthDiscoveredByViewKeyHeader(t *testing.T) { } // amount_napr must be populated and correct. - gotAmt, ok := entry["amount_napr"].(float64) + gotAmt, ok := entry["amount_napr"].(string) if !ok || entry["amount_napr"] == nil { t.Fatalf("amount_napr = %v (type %T), want %d (must be populated when view key provided)", entry["amount_napr"], entry["amount_napr"], wantAmount) } - if uint64(gotAmt) != wantAmount { - t.Errorf("amount_napr = %d, want %d", uint64(gotAmt), wantAmount) + if gotAmt != fmt.Sprint(wantAmount) { + t.Errorf("amount_napr = %s, want %d", gotAmt, wantAmount) } } @@ -113,12 +114,36 @@ func TestREST_AddressUTXOs_StealthDiscoveredByNodeViewKey(t *testing.T) { } entry := list[0].(map[string]interface{}) - gotAmt, ok := entry["amount_napr"].(float64) + 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 uint64(gotAmt) != wantAmount { - t.Errorf("amount_napr = %d, want %d", uint64(gotAmt), wantAmount) + if gotAmt != fmt.Sprint(wantAmount) { + t.Errorf("amount_napr = %s, want %d", gotAmt, wantAmount) + } +} + +// Wire-boundary test, not a claim that uint64-max issuance is permitted by +// consensus. The real canonical large-wallet funding witness is in consensus. +func TestREST_AddressUTXOs_ExactUint64Strings(t *testing.T) { + for _, amount := range []uint64{1<<53 - 1, 1 << 53, 1<<53 + 1, 9_990_000_000_000_000, ^uint64(0)} { + t.Run(fmt.Sprint(amount), func(t *testing.T) { + srv, utxos := buildUTXOServer(t) + wk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + makeStealthUTXO(t, utxos, wk.Spend.Public, wk.View.Public, amount, 0x42) + code, resp := restGetHeader(t, srv, "/api/v1/address/"+string(addr)+"/utxos", "X-View-Key", hex.EncodeToString(wk.View.Private[:])) + if code != http.StatusOK { + t.Fatalf("HTTP %d", code) + } + rows := resp["utxos"].([]interface{}) + if len(rows) != 1 || rows[0].(map[string]interface{})["amount_napr"] != fmt.Sprint(amount) { + t.Fatalf("uint64 not serialized as exact decimal string: %v", rows) + } + }) } } diff --git a/api/wallet_snapshot.go b/api/wallet_snapshot.go index 6855279..161ac13 100644 --- a/api/wallet_snapshot.go +++ b/api/wallet_snapshot.go @@ -472,6 +472,27 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re if !targetBound { throughHeight, throughHash = entry.height, entry.hash } + includeEarnings := q.Get("include_earnings") == "1" + earningsIndexReady := false + if includeEarnings && + entry.read != nil && + entry.checkpoint != nil && + entry.checkpoint.Allocation != nil && + s.lpodMigration != nil && + entry.checkpoint.Allocation.Version == 3 && + entry.checkpoint.Allocation.Genesis == s.lpodMigration.Genesis && + entry.checkpoint.Allocation.ReconciliationRoot == s.lpodMigration.ReconciliationRoot && + entry.checkpoint.Allocation.FundingHeight == s.lpodMigration.Height && + entry.checkpoint.State.LastHeight == entry.height { + ready, readyErr := entry.read.LPoDEarningsIndexReady( + entry.checkpoint.Allocation.FundingBlock, entry.hash, entry.height, + ) + if readyErr != nil { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "EARNINGS_INDEX_NOT_READY", "LPoD earnings index is unavailable at the pinned wallet snapshot") + return + } + earningsIndexReady = ready + } if q.Has("through_height") != q.Has("through_hash") { writeJSONError(w, http.StatusBadRequest, "through_height and through_hash must be supplied together") return @@ -553,9 +574,17 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re } cursor = resumeCursor } - rows, next, lastExamined, err := entry.read.LPoDWalletOutputsAfterHeight( - address, afterHeight, hasAfterHeight, cursor, throughHeight, 128, - ) + var rows []store.StoredUTXO + var next, lastExamined string + if earningsIndexReady { + rows, next, lastExamined, err = entry.read.LPoDWalletOutputsAfterHeightWithEarnings( + address, afterHeight, hasAfterHeight, cursor, throughHeight, 128, + ) + } else { + rows, next, lastExamined, err = entry.read.LPoDWalletOutputsAfterHeight( + address, afterHeight, hasAfterHeight, cursor, throughHeight, 128, + ) + } if err != nil { var missing *store.WalletReconciliationError if errors.As(err, &missing) { @@ -578,12 +607,10 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re "amount_commit": fmt.Sprintf("%x", u.AmountCommit[:]), "enc_amount": fmt.Sprintf("%x", u.EncAmount[:]), } var zeroPoint crypto.Point32 - if u.TxPubKey == zeroPoint { - if u.AmountNAPRO > 0 { - output["amount_napr"] = fmt.Sprintf("%d", u.AmountNAPRO) - } else { - output["opening_status"] = "OUTPUT_OPENING_UNAVAILABLE" - } + if u.AmountNAPRO > 0 && (earningsIndexReady || u.TxPubKey == zeroPoint) { + output["amount_napr"] = fmt.Sprintf("%d", u.AmountNAPRO) + } else if u.TxPubKey == zeroPoint { + output["opening_status"] = "OUTPUT_OPENING_UNAVAILABLE" } outputs = append(outputs, output) } @@ -594,6 +621,9 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re "outputs": outputs, "next_cursor": snapshotCursor(id, next), "resume_cursor": lastExamined, } + if includeEarnings { + response["earnings_index_ready"] = earningsIndexReady + } if creating { delivered = writeCreatedWalletSnapshot(w, r, response) return diff --git a/api/wallet_snapshot_integration_test.go b/api/wallet_snapshot_integration_test.go index 6c2fe5a..bf5db80 100644 --- a/api/wallet_snapshot_integration_test.go +++ b/api/wallet_snapshot_integration_test.go @@ -6,6 +6,7 @@ package api import ( "bytes" "context" + "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" @@ -16,7 +17,8 @@ import ( "net/http" "net/http/httptest" "net/url" -"os" + "os" + "reflect" "sync/atomic" "testing" "time" @@ -217,53 +219,53 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { } } -// A replacement lease must accept the real opaque address-index cursor -// emitted by the prior HTTP page while keeping its original through anchor. -resumeQuery := url.Values{ -"address": {string(address)}, -"snapshot": {"1"}, -"through_height": {fmt.Sprint(initialHeight)}, -"through_hash": {fmt.Sprintf("%x", initialHash[:])}, -"resume_cursor": {resumeCursor}, -} -resumeURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + resumeQuery.Encode() -resumeStatus, resumedPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, resumeURL, nil) -if resumeStatus != http.StatusOK { -t.Fatalf("resume native outputs from real raw index cursor: status=%d body=%#v", resumeStatus, resumedPage) -} -resumeSnapshotID := stringField(t, resumedPage, "snapshot_id") -snapshotIDs = append(snapshotIDs, resumeSnapshotID) -if resumedPage["through_height"] != float64(initialHeight) || -resumedPage["through_hash"] != fmt.Sprintf("%x", initialHash[:]) || -stringField(t, resumedPage, "resume_cursor") == "" { -t.Fatalf("renewed native page changed its anchor or omitted its raw cursor: %#v", resumedPage) -} -priorRefs := make(map[string]struct{}, len(firstRows)) -for _, value := range firstRows { -row := value.(map[string]interface{}) -priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])] = struct{}{} -} -renewedRows := resumedPage["outputs"].([]interface{}) -if len(renewedRows) == 0 { -t.Fatal("renewed native page unexpectedly had no continuation outputs") -} -for _, value := range renewedRows { -row := value.(map[string]interface{}) -if _, duplicated := priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])]; duplicated { -t.Fatalf("raw-cursor renewal repeated prior native output: %#v", row) -} -} -releaseStatus, releaseResponse := snapshotRequest( - t, - httpServer.Client(), - http.MethodDelete, - snapshotURL(httpServer.URL, "/api/v1/wallet/snapshot", address, resumeSnapshotID, ""), - nil, -) -if releaseStatus != http.StatusOK || releaseResponse["released"] != true { - t.Fatalf("release raw-cursor renewal snapshot: status=%d body=%#v", releaseStatus, releaseResponse) -} -snapshotIDs = snapshotIDs[:len(snapshotIDs)-1] + // A replacement lease must accept the real opaque address-index cursor + // emitted by the prior HTTP page while keeping its original through anchor. + resumeQuery := url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + "through_height": {fmt.Sprint(initialHeight)}, + "through_hash": {fmt.Sprintf("%x", initialHash[:])}, + "resume_cursor": {resumeCursor}, + } + resumeURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + resumeQuery.Encode() + resumeStatus, resumedPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, resumeURL, nil) + if resumeStatus != http.StatusOK { + t.Fatalf("resume native outputs from real raw index cursor: status=%d body=%#v", resumeStatus, resumedPage) + } + resumeSnapshotID := stringField(t, resumedPage, "snapshot_id") + snapshotIDs = append(snapshotIDs, resumeSnapshotID) + if resumedPage["through_height"] != float64(initialHeight) || + resumedPage["through_hash"] != fmt.Sprintf("%x", initialHash[:]) || + stringField(t, resumedPage, "resume_cursor") == "" { + t.Fatalf("renewed native page changed its anchor or omitted its raw cursor: %#v", resumedPage) + } + priorRefs := make(map[string]struct{}, len(firstRows)) + for _, value := range firstRows { + row := value.(map[string]interface{}) + priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])] = struct{}{} + } + renewedRows := resumedPage["outputs"].([]interface{}) + if len(renewedRows) == 0 { + t.Fatal("renewed native page unexpectedly had no continuation outputs") + } + for _, value := range renewedRows { + row := value.(map[string]interface{}) + if _, duplicated := priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])]; duplicated { + t.Fatalf("raw-cursor renewal repeated prior native output: %#v", row) + } + } + releaseStatus, releaseResponse := snapshotRequest( + t, + httpServer.Client(), + http.MethodDelete, + snapshotURL(httpServer.URL, "/api/v1/wallet/snapshot", address, resumeSnapshotID, ""), + nil, + ) + if releaseStatus != http.StatusOK || releaseResponse["released"] != true { + t.Fatalf("release raw-cursor renewal snapshot: status=%d body=%#v", releaseStatus, releaseResponse) + } + snapshotIDs = snapshotIDs[:len(snapshotIDs)-1] // A second independent session lets the test prove the opaque cursor is // bound to its originating snapshot token. @@ -404,11 +406,11 @@ snapshotIDs = snapshotIDs[:len(snapshotIDs)-1] initialSpentMap := initialStatuses["spent"].(map[string]interface{}) initialCanonicalMap := initialStatuses["canonical_spent"].(map[string]interface{}) initialPendingMap := initialStatuses["pending_locked"].(map[string]interface{}) -if initialSpentMap[spentKIHex] != true || initialSpentMap[unspentKIHex] != false || + if initialSpentMap[spentKIHex] != true || initialSpentMap[unspentKIHex] != false || initialSpentMap[pendingKIHex] != false || -initialCanonicalMap[unspentKIHex] != false || -initialCanonicalMap[pendingKIHex] != false || initialPendingMap[pendingKIHex] != false { -t.Fatalf("public output marker must not turn an unspent owner key image into spent: %#v", initialSpentMap) + initialCanonicalMap[unspentKIHex] != false || + initialCanonicalMap[pendingKIHex] != false || initialPendingMap[pendingKIHex] != false { + t.Fatalf("public output marker must not turn an unspent owner key image into spent: %#v", initialSpentMap) } pendingTx := core.Transaction{ Version: core.TxVersionCommitmentBinding, @@ -453,6 +455,672 @@ t.Fatalf("public output marker must not turn an unspent owner key image into spe } } +func TestLPoDEarningsOutputPagesStayPinnedToWalletSnapshot(t *testing.T) { + db, migration, address, _, validator, validatorPub := syntheticWalletSnapshotLPoDV3Funding(t) + initialHash, initialHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + var finalityCalls atomic.Int32 + server.SetLPoDConfig(migration, func(height uint64, hash crypto.Hash32) bool { + finalityCalls.Add(1) + return height == initialHeight && hash == initialHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK || + walletPage["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + walletPage["checkpoint_height"] != float64(initialHeight) { + t.Fatalf("create wallet snapshot: status=%d body=%#v", status, walletPage) + } + snapshotID := stringField(t, walletPage, "snapshot_id") + + earningsURL := httpServer.URL + "/api/v1/lpod/earnings-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + status, before := snapshotRequest(t, httpServer.Client(), http.MethodGet, earningsURL, nil) + if status != http.StatusOK || + before["snapshot_id"] != snapshotID || + before["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + before["checkpoint_height"] != float64(initialHeight) { + t.Fatalf("initial snapshot-pinned earnings page: status=%d body=%#v", status, before) + } + beforeRows, ok := before["outputs"].([]interface{}) + if !ok || len(beforeRows) == 0 { + t.Fatalf("synthetic canonical reward missing from pinned earnings page: %#v", before) + } + expectedReward := beforeRows[0].(map[string]interface{}) + expectedRef := fmt.Sprintf("%s:%v", expectedReward["tx_hash"], expectedReward["out_idx"]) + expectedAmount := expectedReward["amount_napro"] + ordinaryWalletRows, ok := walletPage["outputs"].([]interface{}) + if !ok { + t.Fatalf("ordinary wallet output page malformed: %#v", walletPage["outputs"]) + } + for _, raw := range ordinaryWalletRows { + row := raw.(map[string]interface{}) + if fmt.Sprintf("%s:%v", row["tx_hash"], row["out_idx"]) == expectedRef && row["amount_napr"] != nil { + t.Fatal("ordinary wallet page exposed confidential reward amount without earnings enrichment") + } + } + inlineWalletURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + "include_earnings": {"1"}, + }.Encode() + status, inlineWalletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, inlineWalletURL, nil) + if status != http.StatusOK || inlineWalletPage["earnings_index_ready"] != true { + t.Fatalf("inline earnings wallet page not pinned/ready: status=%d body=%#v", status, inlineWalletPage) + } + inlineRows, ok := inlineWalletPage["outputs"].([]interface{}) + if !ok { + t.Fatalf("inline earnings wallet outputs malformed: %#v", inlineWalletPage["outputs"]) + } + foundInlineReward := false + for _, raw := range inlineRows { + row := raw.(map[string]interface{}) + if fmt.Sprintf("%s:%v", row["tx_hash"], row["out_idx"]) == expectedRef { + foundInlineReward = true + if row["amount_napr"] != expectedAmount { + t.Fatalf("inline reward amount=%v, want earnings-index amount %v", row["amount_napr"], expectedAmount) + } + } + } + if !foundInlineReward { + t.Fatalf("inline snapshot wallet page omitted the active indexed reward %s", expectedRef) + } + + advancedHash, advancedHeight := advanceSyntheticLPoDV3(t, db, address, validator, validatorPub) + if advancedHeight != initialHeight+1 || advancedHash == initialHash { + t.Fatalf("live chain did not advance: height=%d hash=%x", advancedHeight, advancedHash[:]) + } + legacyURL := httpServer.URL + "/api/v1/lpod/earnings-outputs?address=" + url.QueryEscape(string(address)) + status, legacy := snapshotRequest(t, httpServer.Client(), http.MethodGet, legacyURL, nil) + if status != http.StatusServiceUnavailable || legacy["error"] != "finalized tip required" { + t.Fatalf("unpinned live earnings endpoint should reject the non-finalized tip: status=%d body=%#v", status, legacy) + } + + status, after := snapshotRequest(t, httpServer.Client(), http.MethodGet, earningsURL, nil) + if status != http.StatusOK || + after["snapshot_id"] != snapshotID || + after["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + after["checkpoint_height"] != float64(initialHeight) || + !reflect.DeepEqual(after["outputs"], before["outputs"]) { + t.Fatalf("earnings page changed with the live tip instead of staying pinned: status=%d body=%#v", status, after) + } + + first := beforeRows[0].(map[string]interface{}) + txHash, err := hex.DecodeString(first["tx_hash"].(string)) + if err != nil || len(txHash) != 32 { + t.Fatalf("synthetic reward transaction hash malformed: %v", err) + } + addressHash := crypto.HashBytes([]byte(address)) + indexKey := append([]byte("lpod/earnings-output/v2/"), addressHash[:]...) + var suffix [76]byte + binary.BigEndian.PutUint64(suffix[:8], uint64(first["block_height"].(float64))) + binary.BigEndian.PutUint32(suffix[8:12], uint32(first["out_idx"].(float64))) + copy(suffix[12:44], initialHash[:]) + copy(suffix[44:], txHash) + indexKey = append(indexKey, suffix[:]...) + cursor := base64.RawURLEncoding.EncodeToString([]byte( + fmt.Sprintf("%x:%x", initialHash[:], indexKey), + )) + continuationURL := snapshotURL(httpServer.URL, "/api/v1/lpod/earnings-outputs", address, snapshotID, cursor) + status, continuation := snapshotRequest(t, httpServer.Client(), http.MethodGet, continuationURL, nil) + if status != http.StatusOK || + continuation["snapshot_id"] != snapshotID || + continuation["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + continuation["checkpoint_height"] != float64(initialHeight) { + t.Fatalf("snapshot-bound earnings cursor failed after live-tip change: status=%d body=%#v", status, continuation) + } + wrongBoundCursor := base64.RawURLEncoding.EncodeToString([]byte( + fmt.Sprintf("%x:%x", advancedHash[:], indexKey), + )) + wrongBoundURL := snapshotURL(httpServer.URL, "/api/v1/lpod/earnings-outputs", address, snapshotID, wrongBoundCursor) + status, wrongBound := snapshotRequest(t, httpServer.Client(), http.MethodGet, wrongBoundURL, nil) + if status != http.StatusConflict || + wrongBound["error"] != "cursor is bound to a different finalized checkpoint" || + wrongBound["outputs"] != nil || wrongBound["next_cursor"] != nil { + t.Fatalf("earnings cursor from a different checkpoint was accepted: status=%d body=%#v", status, wrongBound) + } + server.walletSnapshots.mu.Lock() + entry := server.walletSnapshots.entries[snapshotID] + inflight, active := -1, server.walletSnapshots.active + if entry != nil { + inflight = entry.inflight + } + server.walletSnapshots.mu.Unlock() + if entry == nil || inflight != 0 || active != 1 { + t.Fatalf("checkpoint-mismatched cursor leaked or retired snapshot lease: entry=%v inflight=%d active=%d", + entry != nil, inflight, active) + } + + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + otherAddress := crypto.AddressFromKeys(crypto.MainnetByte, keys) + wrongAddressURL := snapshotURL(httpServer.URL, "/api/v1/lpod/earnings-outputs", otherAddress, snapshotID, "") + status, wrongAddress := snapshotRequest(t, httpServer.Client(), http.MethodGet, wrongAddressURL, nil) + if status != http.StatusConflict || wrongAddress["code"] != "SNAPSHOT_ADDRESS_MISMATCH" { + t.Fatalf("earnings endpoint accepted another address for the snapshot: status=%d body=%#v", status, wrongAddress) + } + if got := finalityCalls.Load(); got != 2 { + t.Fatalf("pinned earnings reads rechecked live finality: callback calls=%d, want 2 (capture and unpinned request)", got) + } + releaseURL := httpServer.URL + "/api/v1/wallet/snapshot?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + releaseStatus, released := snapshotRequest(t, httpServer.Client(), http.MethodDelete, releaseURL, nil) + if releaseStatus != http.StatusOK || released["released"] != true || activeSnapshotSessions(server.walletSnapshots) != 0 { + t.Fatalf("snapshot could not be released after checkpoint-mismatched cursor: status=%d body=%#v active=%d", + releaseStatus, released, activeSnapshotSessions(server.walletSnapshots)) + } +} + +func TestPinnedLPoDEarningsRejectMalformedCursorsWithoutPartialResults(t *testing.T) { + db, migration, address, _, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + tipHash, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == tipHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK { + t.Fatalf("create wallet snapshot: status=%d body=%#v", status, walletPage) + } + snapshotID := stringField(t, walletPage, "snapshot_id") + + validURL := snapshotURL(httpServer.URL, "/api/v1/lpod/earnings-outputs", address, snapshotID, "") + validStatus, validPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, validURL, nil) + validOutputs, outputsOK := validPage["outputs"].([]interface{}) + if validStatus != http.StatusOK || !outputsOK || len(validOutputs) == 0 { + t.Fatalf("synthetic earnings snapshot has no valid page to protect: status=%d body=%#v", validStatus, validPage) + } + + invalidHash := append(bytes.Repeat([]byte("0"), 63), 'g') + addressHash := crypto.HashBytes([]byte(address)) + validAddressPrefix := append([]byte("lpod/earnings-output/v2/"), addressHash[:]...) + oversizedEnvelope := append( + []byte(fmt.Sprintf("%x:", tipHash[:])), + bytes.Repeat([]byte("a"), 320)..., + ) + oversizedCursor := base64.RawURLEncoding.EncodeToString(oversizedEnvelope) + nonHexRawKeyCursor := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%x:zz", tipHash[:]))) + shortRawKeyCursor := base64.RawURLEncoding.EncodeToString( + []byte(fmt.Sprintf("%x:%x", tipHash[:], validAddressPrefix)), + ) + if len(oversizedCursor) <= 512 { + t.Fatalf("test cursor length=%d, want more than the handler's 512-byte limit", len(oversizedCursor)) + } + cases := []struct { + name string + cursor string + }{ + {name: "invalid-base64", cursor: "%%%not-base64%%%"}, + {name: "missing-key-separator", cursor: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%x", tipHash[:])))}, + {name: "invalid-checkpoint-hash-bytes", cursor: base64.RawURLEncoding.EncodeToString(append(append(invalidHash, ':'), 'a'))}, + {name: "oversized-valid-base64-envelope", cursor: oversizedCursor}, + {name: "non-hex-raw-leveldb-key", cursor: nonHexRawKeyCursor}, + {name: "valid-hex-key-with-invalid-length", cursor: shortRawKeyCursor}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requestURL := snapshotURL( + httpServer.URL, + "/api/v1/lpod/earnings-outputs", + address, + snapshotID, + tc.cursor, + ) + status, body := snapshotRequest(t, httpServer.Client(), http.MethodGet, requestURL, nil) + if status != http.StatusBadRequest || body["error"] != "invalid cursor" || + body["outputs"] != nil || body["next_cursor"] != nil { + t.Fatalf("malformed cursor was not rejected without partial results: status=%d body=%#v", status, body) + } + + server.walletSnapshots.mu.Lock() + entry := server.walletSnapshots.entries[snapshotID] + inflight, active := -1, server.walletSnapshots.active + if entry != nil { + inflight = entry.inflight + } + server.walletSnapshots.mu.Unlock() + if entry == nil || inflight != 0 || active != 1 { + t.Fatalf("malformed cursor request leaked or retired snapshot lease: entry=%v inflight=%d active=%d", + entry != nil, inflight, active) + } + }) + } + + releaseURL := httpServer.URL + "/api/v1/wallet/snapshot?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + releaseStatus, released := snapshotRequest(t, httpServer.Client(), http.MethodDelete, releaseURL, nil) + if releaseStatus != http.StatusOK || released["released"] != true || activeSnapshotSessions(server.walletSnapshots) != 0 { + t.Fatalf("snapshot could not be released after malformed cursor requests: status=%d body=%#v active=%d", + releaseStatus, released, activeSnapshotSessions(server.walletSnapshots)) + } +} + +func TestPinnedLPoDEarningsCorruptStoredRowIsAllOrNothingAndReleasable(t *testing.T) { + db, migration, address, dbPath, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + validRows, validNext, err := db.LPoDEarningsOutputs(address, "", 128) + if err != nil || len(validRows) == 0 || len(validRows) >= 128 || validNext != "" { + t.Fatalf("fixture needs a complete page of fewer than 128 valid indexed rewards: rows=%d next=%q err=%v", + len(validRows), validNext, err) + } + for _, row := range validRows { + if row.BlockHeight == ^uint64(0) { + t.Fatal("fixture reward height leaves no later LevelDB key for the malformed row") + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + rawDB, err := leveldb.OpenFile(dbPath, nil) + if err != nil { + t.Fatal(err) + } + addressHash := crypto.HashBytes([]byte(address)) + prefix := append([]byte("lpod/earnings-output/v2/"), addressHash[:]...) + corruptKey := make([]byte, len(prefix)+76) + copy(corruptKey, prefix) + for i := len(prefix); i < len(prefix)+8; i++ { + corruptKey[i] = 0xff + } + if err := rawDB.Put(corruptKey, []byte("{"), nil); err != nil { + _ = rawDB.Close() + t.Fatal(err) + } + if err := rawDB.Close(); err != nil { + t.Fatal(err) + } + + db, err = store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if rows, next, err := db.LPoDEarningsOutputs(address, "", 128); err == nil || rows != nil || next != "" { + t.Fatalf("corrupt stored row returned a partial unpinned page: rows=%d next=%q err=%v", len(rows), next, err) + } + tipHash, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == tipHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK { + t.Fatalf("create pinned wallet snapshot: status=%d body=%#v", status, walletPage) + } + snapshotID := stringField(t, walletPage, "snapshot_id") + + earningsURL := snapshotURL(httpServer.URL, "/api/v1/lpod/earnings-outputs", address, snapshotID, "") + status, page := snapshotRequest(t, httpServer.Client(), http.MethodGet, earningsURL, nil) + if status < http.StatusBadRequest || page["error"] == nil || + page["outputs"] != nil || page["next_cursor"] != nil { + t.Fatalf("corrupt indexed row leaked a partial pinned page: status=%d body=%#v", status, page) + } + + server.walletSnapshots.mu.Lock() + entry := server.walletSnapshots.entries[snapshotID] + inflight, active := -1, server.walletSnapshots.active + if entry != nil { + inflight = entry.inflight + } + server.walletSnapshots.mu.Unlock() + if entry == nil || inflight != 0 || active != 1 { + t.Fatalf("corrupt-row read leaked its reader lease: entry=%v inflight=%d active=%d", + entry != nil, inflight, active) + } + + releaseURL := httpServer.URL + "/api/v1/wallet/snapshot?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + releaseStatus, released := snapshotRequest(t, httpServer.Client(), http.MethodDelete, releaseURL, nil) + if releaseStatus != http.StatusOK || released["released"] != true || + activeSnapshotSessions(server.walletSnapshots) != 0 { + t.Fatalf("snapshot could not be deleted after corrupt-row response: status=%d body=%#v active=%d", + releaseStatus, released, activeSnapshotSessions(server.walletSnapshots)) + } +} + +func TestPinnedLPoDEarningsReleasesReaderAfterResponseWriteFailure(t *testing.T) { + db, migration, address, _, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + tipHash, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + rows, _, err := db.LPoDEarningsOutputs(address, "", 128) + if err != nil || len(rows) == 0 { + t.Fatalf("synthetic wallet has no earnings output: rows=%d err=%v", len(rows), err) + } + + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == tipHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK { + t.Fatalf("create wallet snapshot: status=%d body=%#v", status, walletPage) + } + snapshotID := stringField(t, walletPage, "snapshot_id") + + requestURL := "/api/v1/lpod/earnings-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + requestContext, cancel := context.WithCancel(context.Background()) + failedWriter := &failedSnapshotResponseWriter{cancel: cancel} + server.ServeHTTP( + failedWriter, + httptest.NewRequest(http.MethodGet, requestURL, nil).WithContext(requestContext), + ) + cancel() + if failedWriter.status != http.StatusOK || failedWriter.writeErr == nil { + t.Fatalf("pinned earnings response did not reach the failing writer: %#v", failedWriter) + } + if !bytes.Contains(failedWriter.attempted, []byte(fmt.Sprintf("%x", rows[0].TxHash[:]))) { + t.Fatal("failed response did not attempt to write the synthetic earnings row") + } + + server.walletSnapshots.mu.Lock() + entry := server.walletSnapshots.entries[snapshotID] + inflight, active := -1, server.walletSnapshots.active + if entry != nil { + inflight = entry.inflight + } + server.walletSnapshots.mu.Unlock() + if entry == nil || inflight != 0 || active != 1 { + t.Fatalf("failed response changed snapshot lease state: entry=%v inflight=%d active=%d", + entry != nil, inflight, active) + } + + releaseURL := httpServer.URL + "/api/v1/wallet/snapshot?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + releaseStatus, released := snapshotRequest(t, httpServer.Client(), http.MethodDelete, releaseURL, nil) + if releaseStatus != http.StatusOK || released["released"] != true || activeSnapshotSessions(server.walletSnapshots) != 0 { + t.Fatalf("snapshot could not be explicitly released after response failure: status=%d body=%#v active=%d", + releaseStatus, released, activeSnapshotSessions(server.walletSnapshots)) + } +} + +func TestPinnedLPoDEarningsRejectsCursorFromDifferentAddress(t *testing.T) { + db, migration, addressA, dbPath, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + keysB, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addressB := crypto.AddressFromKeys(crypto.MainnetByte, keysB) + if addressA == addressB { + t.Fatal("synthetic wallets unexpectedly share an address") + } + + tipHash, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + addressARows, _, err := db.LPoDEarningsOutputs(addressA, "", 128) + if err != nil || len(addressARows) == 0 { + t.Fatalf("synthetic wallet A has no earnings output to seed from: rows=%d err=%v", len(addressARows), err) + } + foreignRow := addressARows[0] + foreignRow.TxHash = crypto.HashBytes([]byte("synthetic-wallet-b-earnings-output")) + foreignRow.AmountNAPRO++ + if err := db.Close(); err != nil { + t.Fatalf("close synthetic store before seeding wallet B earnings row: %v", err) + } + rawDB, err := leveldb.OpenFile(dbPath, nil) + if err != nil { + t.Fatalf("open synthetic LevelDB to seed wallet B earnings row: %v", err) + } + addressBHash := crypto.HashBytes([]byte(addressB)) + foreignKey := append([]byte("lpod/earnings-output/v2/"), addressBHash[:]...) + var foreignSuffix [76]byte + binary.BigEndian.PutUint64(foreignSuffix[:8], foreignRow.BlockHeight) + binary.BigEndian.PutUint32(foreignSuffix[8:12], foreignRow.OutputIndex) + copy(foreignSuffix[12:44], tipHash[:]) + copy(foreignSuffix[44:], foreignRow.TxHash[:]) + foreignKey = append(foreignKey, foreignSuffix[:]...) + foreignValue, err := json.Marshal(foreignRow) + if err != nil { + _ = rawDB.Close() + t.Fatalf("marshal synthetic wallet B earnings row: %v", err) + } + if err := rawDB.Put(foreignKey, foreignValue, nil); err != nil { + _ = rawDB.Close() + t.Fatalf("seed synthetic wallet B earnings row: %v", err) + } + if err := rawDB.Close(); err != nil { + t.Fatalf("close synthetic LevelDB after seeding wallet B earnings row: %v", err) + } + db, err = store.Open(dbPath) + if err != nil { + t.Fatalf("reopen synthetic store after seeding wallet B earnings row: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + addressBRows, _, err := db.LPoDEarningsOutputs(addressB, "", 128) + if err != nil || len(addressBRows) != 1 || addressBRows[0].TxHash != foreignRow.TxHash { + t.Fatalf("synthetic wallet B earnings row unavailable: rows=%d err=%v", len(addressBRows), err) + } + + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == tipHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(addressA)}, + "snapshot": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK || + walletPage["checkpoint_hash"] != fmt.Sprintf("%x", tipHash[:]) || + walletPage["checkpoint_height"] != float64(height) { + t.Fatalf("create wallet A snapshot: status=%d body=%#v", status, walletPage) + } + snapshotID := stringField(t, walletPage, "snapshot_id") + + var cursorAddressSuffix [76]byte + binary.BigEndian.PutUint64(cursorAddressSuffix[:8], foreignRow.BlockHeight) + binary.BigEndian.PutUint32(cursorAddressSuffix[8:12], foreignRow.OutputIndex) + copy(cursorAddressSuffix[12:44], tipHash[:]) + copy(cursorAddressSuffix[44:], foreignRow.TxHash[:]) + foreignCursorKey := append([]byte("lpod/earnings-output/v2/"), addressBHash[:]...) + foreignCursorKey = append(foreignCursorKey, cursorAddressSuffix[:]...) + cursor := base64.RawURLEncoding.EncodeToString([]byte( + fmt.Sprintf("%x:%x", tipHash[:], foreignCursorKey), + )) + foreignCursorURL := snapshotURL( + httpServer.URL, + "/api/v1/lpod/earnings-outputs", + addressA, + snapshotID, + cursor, + ) + status, rejected := snapshotRequest(t, httpServer.Client(), http.MethodGet, foreignCursorURL, nil) + if status != http.StatusBadRequest || rejected["error"] != "invalid cursor" || + rejected["outputs"] != nil || rejected["next_cursor"] != nil { + t.Fatalf("snapshot earnings endpoint accepted another wallet's index cursor: status=%d body=%#v", status, rejected) + } + + server.walletSnapshots.mu.Lock() + entry := server.walletSnapshots.entries[snapshotID] + inflight := -1 + if entry != nil { + inflight = entry.inflight + } + server.walletSnapshots.mu.Unlock() + if entry == nil || inflight != 0 { + t.Fatalf("failed cursor request leaked a snapshot reader lease: entry=%v inflight=%d", entry != nil, inflight) + } + + releaseURL := httpServer.URL + "/api/v1/wallet/snapshot?" + url.Values{ + "address": {string(addressA)}, + "snapshot_id": {snapshotID}, + }.Encode() + releaseStatus, released := snapshotRequest(t, httpServer.Client(), http.MethodDelete, releaseURL, nil) + if releaseStatus != http.StatusOK || released["released"] != true || activeSnapshotSessions(server.walletSnapshots) != 0 { + t.Fatalf("snapshot lease could not be released after invalid cursor: status=%d body=%#v active=%d", + releaseStatus, released, activeSnapshotSessions(server.walletSnapshots)) + } +} + +func TestWalletSnapshotUsesLegacyWalletPagesButRejectsUnreadyEarningsIndex(t *testing.T) { + db, migration, address, dbPath, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + if err := db.Close(); err != nil { + t.Fatal(err) + } + rawDB, err := leveldb.OpenFile(dbPath, nil) + if err != nil { + t.Fatal(err) + } + if err := rawDB.Delete([]byte("lpod/earnings-ready/v2"), nil); err != nil { + _ = rawDB.Close() + t.Fatalf("remove only the earnings readiness marker: %v", err) + } + if err := rawDB.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(dbPath) + if err != nil { + t.Fatalf("reopen test chain without earnings readiness: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + initialHash, initialHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(height uint64, hash crypto.Hash32) bool { + return height == initialHeight && hash == initialHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + createURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + "include_earnings": {"1"}, + }.Encode() + status, walletPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, createURL, nil) + if status != http.StatusOK || walletPage["earnings_index_ready"] != false { + t.Fatalf("unready earnings index should select the pinned legacy wallet path: status=%d body=%#v", status, walletPage) + } + rows, ok := walletPage["outputs"].([]interface{}) + if !ok || len(rows) == 0 { + t.Fatalf("pinned legacy wallet page omitted spendability-checked outputs: %#v", walletPage) + } + for _, raw := range rows { + row, ok := raw.(map[string]interface{}) + if !ok || row["amount_napr"] != nil { + t.Fatalf("unready earnings metadata leaked into legacy wallet row: %#v", raw) + } + } + + snapshotID := stringField(t, walletPage, "snapshot_id") + earningsURL := httpServer.URL + "/api/v1/lpod/earnings-outputs?" + url.Values{ + "address": {string(address)}, + "snapshot_id": {snapshotID}, + }.Encode() + status, earningsPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, earningsURL, nil) + if status != http.StatusServiceUnavailable || earningsPage["code"] != "EARNINGS_INDEX_NOT_READY" { + t.Fatalf("unready earnings index must fail closed, not return a partial page: status=%d body=%#v", status, earningsPage) + } + if earningsPage["outputs"] != nil || earningsPage["next_cursor"] != nil { + t.Fatalf("unready earnings response exposed partial index rows or a continuation cursor: %#v", earningsPage) + } +} + func activeSnapshotSessions(manager *walletSnapshotManager) int { manager.mu.Lock() defer manager.mu.Unlock() @@ -618,10 +1286,11 @@ func syntheticWalletSnapshotLPoDV3Funding(t *testing.T) ( } type failedSnapshotResponseWriter struct { - header http.Header - status int - writeErr error - cancel context.CancelFunc + header http.Header + status int + writeErr error + attempted []byte + cancel context.CancelFunc } func (w *failedSnapshotResponseWriter) Header() http.Header { @@ -633,10 +1302,11 @@ func (w *failedSnapshotResponseWriter) Header() http.Header { func (w *failedSnapshotResponseWriter) WriteHeader(status int) { w.status = status } -func (w *failedSnapshotResponseWriter) Write([]byte) (int, error) { +func (w *failedSnapshotResponseWriter) Write(p []byte) (int, error) { if w.cancel != nil { w.cancel() } + w.attempted = append(w.attempted, p...) w.writeErr = errors.New("simulated disconnected client") return 0, w.writeErr } @@ -651,6 +1321,7 @@ func seedWalletSnapshotAPIFixture( db *store.DB, path string, address crypto.Address, + additionalAddresses ...crypto.Address, ) (*store.DB, snapshotRef, snapshotRef, crypto.KeyImage, crypto.KeyImage, crypto.Hash32, uint64) { t.Helper() tipHash, height, err := db.GetTip() @@ -690,25 +1361,34 @@ func seedWalletSnapshotAPIFixture( t.Fatal(err) } - addrHash := crypto.HashBytes([]byte(address)) - prefix := append([]byte("lpod/wallet/v1/"), addrHash[:]...) type walletOutput struct { - hash crypto.Hash32 - index uint32 - value store.StoredUTXO - } - indexed := make([]walletOutput, 129) - for i := range indexed { - hash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-indexed-output-%03d", i))) - value := store.StoredUTXO{TxHash: hash, OutputIndex: uint32(i), BlockHeight: height} - keyHash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-one-time-pub-%03d", i))) - copy(value.OneTimePub[:], keyHash[:]) - txPubHash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-tx-pub-%03d", i))) - copy(value.TxPubKey[:], txPubHash[:]) - if err := db.PutUTXO(hash, uint32(i), &value); err != nil { - t.Fatal(err) + prefix []byte + hash crypto.Hash32 + index uint32 + value store.StoredUTXO + } + indexed := make([]walletOutput, 0, 129*(1+len(additionalAddresses))) + owners := append([]crypto.Address{address}, additionalAddresses...) + for ownerIndex, owner := range owners { + addrHash := crypto.HashBytes([]byte(owner)) + prefix := append([]byte("lpod/wallet/v1/"), addrHash[:]...) + ownerTag := "" + if ownerIndex > 0 { + ownerHash := crypto.HashBytes([]byte(owner)) + ownerTag = fmt.Sprintf("%x-", ownerHash[:4]) + } + for i := 0; i < 129; i++ { + hash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-indexed-output-%s%03d", ownerTag, i))) + value := store.StoredUTXO{TxHash: hash, OutputIndex: uint32(i), BlockHeight: height} + keyHash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-one-time-pub-%s%03d", ownerTag, i))) + copy(value.OneTimePub[:], keyHash[:]) + txPubHash := crypto.HashBytes([]byte(fmt.Sprintf("wallet-snapshot-tx-pub-%s%03d", ownerTag, i))) + copy(value.TxPubKey[:], txPubHash[:]) + if err := db.PutUTXO(hash, uint32(i), &value); err != nil { + t.Fatal(err) + } + indexed = append(indexed, walletOutput{prefix: prefix, hash: hash, index: uint32(i), value: value}) } - indexed[i] = walletOutput{hash: hash, index: uint32(i), value: value} } if err := db.Close(); err != nil { t.Fatal(err) @@ -721,7 +1401,7 @@ func seedWalletSnapshotAPIFixture( var suffix [12]byte binary.BigEndian.PutUint64(suffix[:8], height) binary.BigEndian.PutUint32(suffix[8:], row.index) - key := append([]byte(nil), prefix...) + key := append([]byte(nil), row.prefix...) key = append(key, suffix[:]...) key = append(key, tipHash[:]...) key = append(key, row.hash[:]...) @@ -771,8 +1451,8 @@ func advanceSyntheticLPoDV3( request := &store.LPoDSettlement{ Parent: parentHash, PositionProtocol: true, Transactions: extraTransactions, - Timestamp: parent.Header.Timestamp + 3_000_000_000, - Proposer: validatorPub.Hex(), Leader: leader, + Timestamp: parent.Header.Timestamp + 3_000_000_000, + Proposer: validatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{ validatorPub.Hex(): {Amount: 100_000 * 100_000_000, Active: true}, }, @@ -1009,3 +1689,85 @@ func TestWalletOutputSnapshotRenewsFromRawResumeCursorAtFixedTarget(t *testing.T t.Fatalf("renewed lease silently expanded its original through target: status=%d body=%#v", status, wrongTarget) } } + +func TestLegacyWalletOutputsRejectCursorFromDifferentAddress(t *testing.T) { + db, migration, addressA, dbPath, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + otherKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addressB := crypto.AddressFromKeys(crypto.MainnetByte, otherKeys) + if addressA == addressB { + t.Fatal("synthetic wallets unexpectedly share an address") + } + db, _, _, _, _, tipHash, height := seedWalletSnapshotAPIFixture(t, db, dbPath, addressA, addressB) + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == tipHash + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + addressAQuery := url.Values{"address": {string(addressA)}} + firstAURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + addressAQuery.Encode() + firstAStatus, firstA := snapshotRequest( + t, + httpServer.Client(), + http.MethodGet, + firstAURL, + nil, + ) + firstARows, ok := firstA["outputs"].([]interface{}) + cursorA, cursorOK := firstA["next_cursor"].(string) + if firstAStatus != http.StatusOK || !ok || len(firstARows) != 128 || !cursorOK || cursorA == "" { + t.Fatalf("wallet A first output page: status=%d body=%#v", firstAStatus, firstA) + } + firstARow, ok := firstARows[0].(map[string]interface{}) + if !ok { + t.Fatalf("wallet A first output is malformed: %#v", firstARows[0]) + } + + reusedQuery := url.Values{ + "address": {string(addressB)}, + "cursor": {cursorA}, + } + reusedURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + reusedQuery.Encode() + reusedStatus, reused := snapshotRequest( + t, + httpServer.Client(), + http.MethodGet, + reusedURL, + nil, + ) + if reusedStatus != http.StatusBadRequest || reused["error"] != "invalid cursor" || reused["outputs"] != nil { + t.Fatalf("wallet A cursor was not rejected for wallet B: status=%d body=%#v", reusedStatus, reused) + } + + addressBQuery := url.Values{"address": {string(addressB)}} + firstBURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + addressBQuery.Encode() + firstBStatus, firstB := snapshotRequest( + t, + httpServer.Client(), + http.MethodGet, + firstBURL, + nil, + ) + firstBRows, ok := firstB["outputs"].([]interface{}) + if firstBStatus != http.StatusOK || !ok || len(firstBRows) != 128 || firstB["next_cursor"] == "" { + t.Fatalf("wallet B first output page: status=%d body=%#v", firstBStatus, firstB) + } + firstBRow, ok := firstBRows[0].(map[string]interface{}) + if !ok || firstBRow["tx_hash"] == firstARow["tx_hash"] { + t.Fatalf("wallet B page exposed wallet A's first output: A=%#v B=%#v", firstARow, firstBRow) + } + for i, value := range firstBRows { + row, ok := value.(map[string]interface{}) + if !ok || row["out_idx"] != float64(i) { + t.Fatalf("wallet B's own page is out of order at index %d: %#v", i, value) + } + } +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 7f31f25..8ef712b 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -225,7 +225,7 @@ var walletCreateCmd = &cobra.Command{ return fmt.Errorf("password must be at least 8 characters") } - ks, err := wallet.EncryptMnemonic(phrase, pw, string(addr)) + ks, err := wallet.EncryptMnemonicWithDerivation(phrase, pw, string(addr), accountIdx, 0) if err != nil { return fmt.Errorf("encrypt keystore: %w", err) } @@ -306,7 +306,7 @@ var walletRestoreCmd = &cobra.Command{ return fmt.Errorf("password must be at least 8 characters") } - ks, err := wallet.EncryptMnemonic(phrase, pw, string(addr)) + ks, err := wallet.EncryptMnemonicWithDerivation(phrase, pw, string(addr), accountIdx, 0) if err != nil { return fmt.Errorf("encrypt keystore: %w", err) } @@ -351,7 +351,17 @@ var walletUnlockCmd = &cobra.Command{ } accountIdx, _ := cmd.Flags().GetUint32("account") - dk, err := wallet.DeriveFromMnemonic(phrase, "", accountIdx, 0) + if ks.AccountIndex != nil { + if cmd.Flags().Changed("account") && accountIdx != *ks.AccountIndex { + return fmt.Errorf("keystore account index is %d; refusing requested index %d", *ks.AccountIndex, accountIdx) + } + accountIdx = *ks.AccountIndex + } + addressIdx := uint32(0) + if ks.AddressIndex != nil { + addressIdx = *ks.AddressIndex + } + dk, err := wallet.DeriveFromMnemonic(phrase, "", accountIdx, addressIdx) if err != nil { return fmt.Errorf("derive keys: %w", err) } @@ -362,11 +372,14 @@ var walletUnlockCmd = &cobra.Command{ } addr := crypto.AddressFromKeys(net, dk.Keys) + if err := ks.VerifyDerivation(accountIdx, addressIdx, string(addr)); err != nil { + return fmt.Errorf("keystore derivation check failed: %w", err) + } fmt.Println() fmt.Println(" ✓ Keystore unlocked") fmt.Printf(" Address: %s\n", addr) - fmt.Printf(" Derivation: m/44'/7777'/%d'/0/0\n", accountIdx) + fmt.Printf(" Derivation: %s\n", dk.Path) fmt.Printf(" Spend Private: %s\n", hex.EncodeToString(dk.Keys.Spend.Private[:])) fmt.Printf(" Spend Public: %s\n", hex.EncodeToString(dk.Keys.Spend.Public[:])) fmt.Printf(" View Private: %s\n", hex.EncodeToString(dk.Keys.View.Private[:])) diff --git a/cmd/genesis-reserve/main.go b/cmd/genesis-reserve/main.go index 2e21b3f..e7c78a7 100644 --- a/cmd/genesis-reserve/main.go +++ b/cmd/genesis-reserve/main.go @@ -52,7 +52,7 @@ func run() error { keys, err := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) if err != nil { return err } address := crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys) - keystore, err := wallet.EncryptMnemonic(mnemonic, password, string(address)) + keystore, err := wallet.EncryptMnemonicWithDerivation(mnemonic, password, string(address), 0, 0) if err != nil { return err } if err = writeExclusive(*path, keystore); err != nil { return err } return json.NewEncoder(os.Stdout).Encode(map[string]string{"address": string(address), "custody": "encrypted_operator_keystore"}) @@ -75,7 +75,7 @@ func run() error { if err != nil || string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address { return fmt.Errorf("keystore address integrity failed") } newPassword := os.Getenv("TREASURY_EXPORT_PASSWORD") if len(newPassword) < 12 { return fmt.Errorf("export password must have at least 12 characters") } - rekeyed, err := wallet.EncryptMnemonic(mnemonic, newPassword, keystore.Address) + rekeyed, err := wallet.EncryptMnemonicWithDerivation(mnemonic, newPassword, keystore.Address, 0, 0) if err != nil { return err } return json.NewEncoder(os.Stdout).Encode(rekeyed) } diff --git a/cmd/ring-index-maintenance/files.go b/cmd/ring-index-maintenance/files.go new file mode 100644 index 0000000..fc03459 --- /dev/null +++ b/cmd/ring-index-maintenance/files.go @@ -0,0 +1,272 @@ +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +func syncDirectory(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +func writeControl(path string, value any, exclusive bool) error { + if exclusive { + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("existing operation/control must be recovered, not overwritten") + } else if !os.IsNotExist(err) { + return err + } + } + f, err := os.CreateTemp(filepath.Dir(path), ".control-*") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if err := json.NewEncoder(f).Encode(value); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + return syncDirectory(filepath.Dir(path)) +} + +func removeControl(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return syncDirectory(filepath.Dir(path)) +} + +type backupEntry struct { + Tree string `json:"tree"` + Kind string `json:"kind,omitempty"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + Mode uint32 `json:"mode"` + UID uint32 `json:"uid"` + GID uint32 `json:"gid"` +} + +// 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 { + root, err := os.OpenRoot(source) + if err != nil { + return err + } + defer root.Close() + if err := os.Mkdir(destination, 0700); err != nil { + return err + } + var visit func(string) error + visit = func(relative string) error { + dir, err := root.Open(relative) + if err != nil { + return err + } + defer dir.Close() + info, err := dir.Stat() + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("directory ownership unavailable") + } + if err := manifest.Encode(backupEntry{Tree: "data", Kind: "directory", Path: relative, Mode: uint32(info.Mode().Perm()), UID: stat.Uid, GID: stat.Gid}); err != nil { + return err + } + for { + entries, err := dir.ReadDir(256) + if err != nil && err != io.EOF { + return err + } + for _, entry := range entries { + name := filepath.Join(relative, entry.Name()) + info, err := root.Lstat(name) + if err != nil { + return err + } + target := filepath.Join(destination, name) + if info.IsDir() { + if err := os.Mkdir(target, 0700); err != nil { + return err + } + if err := visit(name); err != nil { + return err + } + continue + } + if !info.Mode().IsRegular() { + return fmt.Errorf("backup refuses links/devices/special files") + } + in, err := root.Open(name) + if err != nil { + return err + } + opened, err := in.Stat() + if err != nil || !os.SameFile(info, opened) { + in.Close() + return fmt.Errorf("source file identity changed") + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + in.Close() + return err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, hash), in) + after, statErr := in.Stat() + 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 n != info.Size() || after.Size() != info.Size() || after.ModTime() != info.ModTime() { + return fmt.Errorf("source changed during coherent copy") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("backup ownership unavailable") + } + if err := manifest.Encode(backupEntry{Tree: "data", Path: name, SHA256: hex.EncodeToString(hash.Sum(nil)), Bytes: n, Mode: uint32(info.Mode().Perm()), UID: stat.Uid, GID: stat.Gid}); err != nil { + return err + } + } + if err == io.EOF { + break + } + } + return syncDirectory(filepath.Join(destination, relative)) + } + if err := visit("."); err != nil { + return err + } + return syncDirectory(filepath.Dir(destination)) +} + +// 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 { + root, err := os.OpenRoot(backup) + if err != nil { + return err + } + defer root.Close() + f, err := root.Open("manifest.jsonl") + if err != nil { + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 4096), 16384) + header, complete := false, false + for scanner.Scan() { + var row struct { + backupEntry + Schema int `json:"schema"` + DBPath string `json:"db_path"` + Tip tipReceipt `json:"tip"` + ApprovalID string `json:"approval_id"` + Complete bool `json:"complete"` + } + if err := json.Unmarshal(scanner.Bytes(), &row); err != nil { + return err + } + if complete { + return fmt.Errorf("backup data after completion") + } + if !header { + if row.Schema != 1 || row.DBPath == "" || !digestRE.MatchString(row.Tip.Hash) { + return fmt.Errorf("backup header incomplete") + } + header = true + continue + } + if row.Complete { + complete = true + continue + } + if row.Tree != "data" && row.Tree != "meta" { + return fmt.Errorf("backup tree invalid") + } + if filepath.IsAbs(row.Path) || filepath.Clean(row.Path) != row.Path || row.Path == ".." || strings.HasPrefix(row.Path, "../") { + return fmt.Errorf("backup entry invalid") + } + path := row.Path + if row.Tree == "data" { + path = filepath.Join("data", row.Path) + } + info, err := root.Lstat(path) + if err != nil { + return err + } + if row.Kind == "directory" { + if !info.IsDir() { + return fmt.Errorf("backup directory differs") + } + continue + } + if row.Kind != "" || !digestRE.MatchString(row.SHA256) || row.Path == "." { + return fmt.Errorf("backup entry type/hash invalid") + } + if !info.Mode().IsRegular() || info.Size() != row.Bytes { + return fmt.Errorf("backup file type/size differs") + } + in, err := root.Open(path) + if err != nil { + return err + } + h := sha256.New() + _, copyErr := io.Copy(h, in) + in.Close() + if copyErr != nil { + return copyErr + } + if hex.EncodeToString(h.Sum(nil)) != row.SHA256 { + return fmt.Errorf("backup file checksum differs") + } + } + if err := scanner.Err(); err != nil { + return err + } + if !header || !complete { + return fmt.Errorf("backup has no durable completion") + } + return nil +} diff --git a/cmd/ring-index-maintenance/main.go b/cmd/ring-index-maintenance/main.go new file mode 100644 index 0000000..5d9e9f5 --- /dev/null +++ b/cmd/ring-index-maintenance/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/aperod/aperod/store" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "ring maintenance refused:", err) + os.Exit(1) + } +} + +func run(args []string) 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") + db := flags.String("db", "", "internal approved database scope") + generation := flags.String("generation", "", "internal receipt generation") + candidate := flags.String("candidate", "", "already source-approved node candidate") + current := flags.String("current", "", "current/rollback node executable") + config := flags.String("config", "", "approved node configuration") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments") + } + if *workerMode != "" { + if *phase != "" || *candidate != "" || *current != "" || *config != "" { + return fmt.Errorf("mixed worker/controller mode") + } + return worker(*workerMode, *db, *generation) + } + if os.Geteuid() != 0 { + return fmt.Errorf("separately authorized root coordinator required") + } + if *db != "" || *generation != "" { + return fmt.Errorf("runtime scope overrides forbidden") + } + p, err := loadApprovedPolicy() + if err != nil { + return err + } + if *phase == "guard-update" { + if *config != p.ConfigPath || *current == "" || *candidate == "" { + return fmt.Errorf("update scope/arguments not approved") + } + if err := store.CheckRingMaintenanceFence(p.DBPath); err != nil { + return err + } + if err := approvedIndexAware(p, *current); err != nil { + return err + } + return approvedIndexAware(p, *candidate) + } + if *candidate != "" || *current != "" || *config != "" { + return fmt.Errorf("unexpected update arguments") + } + if *phase != "prepare" && *phase != "legacy" && *phase != "finalize" { + return fmt.Errorf("explicit supported phase required") + } + data, err := os.Stat(p.DataDir) + if err != nil || !data.IsDir() { + return fmt.Errorf("approved full data directory absent") + } + 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 { + return err + } + s := serviceController{} + if err := s.validate(p, true); err != nil { + return err + } + l, err := store.AcquireRingMaintenance(p.DBPath) + if err != nil { + return err + } + defer l.Close() + switch *phase { + case "prepare": + return prepare(p, l, s) + case "legacy": + return enterLegacy(p, l, s) + case "finalize": + return finalize(p, l, s) + } + return fmt.Errorf("unreachable phase") +} diff --git a/cmd/ring-index-maintenance/maintenance_test.go b/cmd/ring-index-maintenance/maintenance_test.go new file mode 100644 index 0000000..56374f7 --- /dev/null +++ b/cmd/ring-index-maintenance/maintenance_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func testPolicy() maintenancePolicy { + h := strings.Repeat("a", 64) + unit := func(name string) unitPolicy { + return unitPolicy{Name: name, Files: map[string]string{"/etc/systemd/system/" + name: h}} + } + return maintenancePolicy{Schema: 1, ApprovalID: "synthetic-only", ToolSHA256: h, Operation: "repair", DataDir: "/opt/synthetic/data", DBPath: "/opt/synthetic/data/chain.db", BackupRoot: "/opt/synthetic/backup", ConfigPath: "/etc/synthetic/node.yaml", ConfigSHA256: h, NodeBinary: "/usr/local/bin/synthetic-node", IndexAwareBinaries: []string{h}, Clients: []unitPolicy{unit("client.service")}, Writers: []unitPolicy{unit("writer.service")}, Legacy: unit("offline.service"), LegacyBinary: "/usr/local/bin/synthetic-legacy", LegacySHA256: h} +} + +func TestPolicyStrictIndependentApprovals(t *testing.T) { + p := testPolicy() + body, _ := json.Marshal(p) + if _, err := strictPolicy(strings.NewReader(string(body))); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*maintenancePolicy){ + "no-tool-approval": func(p *maintenancePolicy) { p.ToolSHA256 = "" }, + "unknown-binary": func(p *maintenancePolicy) { p.IndexAwareBinaries = nil }, + "backup-overlap": func(p *maintenancePolicy) { p.BackupRoot = p.DataDir + "/backups" }, + "scope-outside-data": func(p *maintenancePolicy) { p.DBPath = "/other/chain.db" }, + "unknown-operation": func(p *maintenancePolicy) { p.Operation = "execute" }, + "duplicate-unit": func(p *maintenancePolicy) { p.Legacy = p.Writers[0] }, + "wildcard-unit": func(p *maintenancePolicy) { p.Clients[0].Name = "*.service" }, + "absent-unit-pin": func(p *maintenancePolicy) { p.Legacy.Files = nil }, + } { + t.Run(name, func(t *testing.T) { + p := testPolicy() + change(&p) + b, _ := json.Marshal(p) + if _, err := strictPolicy(strings.NewReader(string(b))); err == nil { + t.Fatal("unsafe policy accepted") + } + }) + } + for _, text := range []string{string(body) + `{}`, strings.Replace(string(body), `"schema":1`, `"schema":1,"override":true`, 1)} { + if _, err := strictPolicy(strings.NewReader(text)); err == nil { + t.Fatal("trailing/unknown policy accepted") + } + } +} + +func syntheticBackup(t *testing.T) (string, string) { + t.Helper() + source := filepath.Join(t.TempDir(), "source") + backup := filepath.Join(t.TempDir(), "backup") + if err := os.MkdirAll(filepath.Join(source, "chain.db"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(backup, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "chain.db", "synthetic.sst"), []byte("synthetic-only immutable content"), 0600); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(backup, "manifest.jsonl")) + if err != nil { + 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 := copyTree(source, filepath.Join(backup, "data"), encoder); err != nil { + t.Fatal(err) + } + encoder.Encode(map[string]any{"complete": true}) + if err := f.Sync(); err != nil { + t.Fatal(err) + } + f.Close() + return source, backup +} + +func TestCoherentCopyChecksEveryFileAndCompleteness(t *testing.T) { + _, backup := syntheticBackup(t) + if err := verifyBackup(backup); err != nil { + t.Fatal(err) + } + path := filepath.Join(backup, "data", "chain.db", "synthetic.sst") + if err := os.WriteFile(path, []byte("synthetic-only immutable contenX"), 0600); err != nil { + t.Fatal(err) + } + if err := verifyBackup(backup); err == nil { + t.Fatal("corrupted copy accepted") + } + _, backup = syntheticBackup(t) + manifest := filepath.Join(backup, "manifest.jsonl") + body, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + body = []byte(strings.Replace(string(body), `{"complete":true}`, "", 1)) + os.WriteFile(manifest, body, 0600) + if err := verifyBackup(backup); err == nil { + t.Fatal("partial backup accepted") + } +} + +func TestCopyRefusesLinksAndSpecialFiles(t *testing.T) { + source := t.TempDir() + backup := filepath.Join(t.TempDir(), "data") + if err := os.Symlink("/etc/passwd", filepath.Join(source, "escape")); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(t.TempDir(), "manifest")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := copyTree(source, backup, json.NewEncoder(f)); err == nil { + t.Fatal("source symlink accepted") + } +} + +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 := emptyCgroup(root); err == nil { + t.Fatal("descendant writer ignored") + } + os.WriteFile(filepath.Join(root, "child", "cgroup.procs"), nil, 0600) + if err := emptyCgroup(root); err != nil { + t.Fatal(err) + } +} + +func TestResumeDoesNotStartInactiveWriterOrLegacy(t *testing.T) { + p := testPolicy() + p.Writers = append(p.Writers, unitPolicy{Name: "backup.timer"}) + got := previouslyActive(p.Writers, []string{"writer.service", "offline.service"}) + if len(got) != 1 || got[0].Name != "writer.service" { + t.Fatal("inactive/legacy unit selected") + } +} + +func TestControlFileSurvivesReopenAndExclusiveRetry(t *testing.T) { + path := filepath.Join(t.TempDir(), "journal.json") + if err := writeControl(path, map[string]string{"phase": "prepared"}, true); err != nil { + t.Fatal(err) + } + if err := writeControl(path, map[string]string{"phase": "legacy-attempted"}, true); err == nil { + t.Fatal("exclusive retry overwrote journal") + } + if err := writeControl(path, map[string]string{"phase": "legacy-attempted"}, false); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(body), "legacy-attempted") { + t.Fatal("durable journal unreadable") + } + if err := removeControl(path); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/ring-index-maintenance/policy.go b/cmd/ring-index-maintenance/policy.go new file mode 100644 index 0000000..b048d7e --- /dev/null +++ b/cmd/ring-index-maintenance/policy.go @@ -0,0 +1,241 @@ +// Command ring-index-maintenance is an independently approved offline tool, +// never an automatically installed side effect of a node/source update. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" +) + +const policyPath = "/etc/aperod/ring-index-maintenance/policy.json" +const toolPath = "/usr/local/libexec/aperod-ring-index-maintenance" + +type unitPolicy struct { + Name string `json:"name"` + Files map[string]string `json:"files"` +} + +type maintenancePolicy struct { + Schema int `json:"schema"` + ApprovalID string `json:"approval_id"` + ToolSHA256 string `json:"tool_sha256"` + Operation string `json:"operation"` + DataDir string `json:"data_dir"` + DBPath string `json:"db_path"` + BackupRoot string `json:"backup_root"` + ConfigPath string `json:"config_path"` + ConfigSHA256 string `json:"config_sha256"` + NodeBinary string `json:"node_binary"` + IndexAwareBinaries []string `json:"index_aware_binaries"` + Clients []unitPolicy `json:"clients"` + Writers []unitPolicy `json:"writers"` + Legacy unitPolicy `json:"legacy"` + LegacyBinary string `json:"legacy_binary"` + LegacySHA256 string `json:"legacy_sha256"` + AllowColdPrepare bool `json:"allow_cold_prepare"` +} + +var digestRE = regexp.MustCompile(`^[0-9a-f]{64}$`) +var approvalRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$`) +var unitRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_@.-]{0,127}\.(service|socket|timer)$`) + +func strictPolicy(r io.Reader) (maintenancePolicy, error) { + var p maintenancePolicy + decoder := json.NewDecoder(io.LimitReader(r, 128*1024+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&p); err != nil { + return p, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return p, fmt.Errorf("policy trailing content: %v", err) + } + if p.Schema != 1 || !approvalRE.MatchString(p.ApprovalID) || !digestRE.MatchString(p.ToolSHA256) || !digestRE.MatchString(p.ConfigSHA256) || !digestRE.MatchString(p.LegacySHA256) { + return p, fmt.Errorf("invalid approval schema or digest") + } + if p.Operation != "downgrade" && p.Operation != "import" && p.Operation != "repair" { + return p, fmt.Errorf("unsupported operation") + } + for _, path := range []string{p.DataDir, p.DBPath, p.BackupRoot, p.ConfigPath, p.NodeBinary, p.LegacyBinary} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { + return p, fmt.Errorf("canonical absolute paths required") + } + } + if !strings.HasPrefix(p.DBPath, p.DataDir+string(os.PathSeparator)) { + return p, fmt.Errorf("database must be within full node data directory") + } + if overlaps(p.DataDir, p.BackupRoot) { + return p, fmt.Errorf("backup and live data overlap") + } + if len(p.IndexAwareBinaries) == 0 || len(p.IndexAwareBinaries) > 64 { + return p, fmt.Errorf("index-aware binary approval required") + } + for _, d := range p.IndexAwareBinaries { + if !digestRE.MatchString(d) { + return p, fmt.Errorf("invalid binary approval") + } + } + if len(p.Clients) == 0 || len(p.Writers) == 0 || len(p.Clients)+len(p.Writers) > 32 { + return p, fmt.Errorf("explicit client/writer unit sets required") + } + units := append(append(append([]unitPolicy{}, p.Clients...), p.Writers...), p.Legacy) + names := map[string]bool{} + for _, u := range units { + if !unitRE.MatchString(u.Name) || names[u.Name] || len(u.Files) == 0 || len(u.Files) > 16 { + return p, fmt.Errorf("invalid/duplicate unit or absent unit-file approval") + } + names[u.Name] = true + for path, d := range u.Files { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || !digestRE.MatchString(d) { + return p, fmt.Errorf("invalid unit-file approval") + } + } + } + if !strings.HasSuffix(p.Legacy.Name, ".service") { + return p, fmt.Errorf("dedicated legacy service required") + } + return p, nil +} + +func overlaps(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") +} + +func filepathCanonical(path string) (string, error) { return filepath.EvalSymlinks(path) } + +// 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 { + 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 { + return fmt.Errorf("untrusted policy/tool path") + } + if current == "/" { + return nil + } + } +} + +func fileDigest(path string) (string, error) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return "", err + } + f := os.NewFile(uintptr(fd), path) + defer f.Close() + info, err := f.Stat() + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Size() > 512*1024*1024 { + return "", fmt.Errorf("regular bounded file required") + } + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func loadApprovedPolicy() (maintenancePolicy, error) { + return loadApprovedPolicyMode(true) +} + +func loadApprovedPolicyMode(checkConfig bool) (maintenancePolicy, error) { + var p maintenancePolicy + if err := protectedPath(policyPath, 0); 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() + info, err := f.Stat() + if err != nil { + return p, err + } + if !info.Mode().IsRegular() || info.Size() > 128*1024 { + return p, fmt.Errorf("bounded regular policy required") + } + p, err = strictPolicy(f) + if err != nil { + return p, err + } + self, err := os.Executable() + if err != nil { + return p, err + } + self, err = filepath.EvalSymlinks(self) + if err != nil { + return p, err + } + approvedTool, err := filepath.EvalSymlinks(toolPath) + if err != nil { + return p, err + } + if self != approvedTool { + return p, fmt.Errorf("only separately installed tool may execute") + } + if err := protectedPath(self, 0); err != nil { + return p, err + } + image, err := os.Open("/proc/self/exe") + if err != nil { + return p, err + } + hash := sha256.New() + _, err = io.Copy(hash, image) + image.Close() + if err != nil { + return p, err + } + digest := hex.EncodeToString(hash.Sum(nil)) + if err != nil { + return p, err + } + if digest != p.ToolSHA256 { + return p, fmt.Errorf("privileged tool bytes not independently approved") + } + if !checkConfig { + return p, nil + } + if err := protectedPath(p.ConfigPath, 0); err != nil { + return p, err + } + digest, err = fileDigest(p.ConfigPath) + if err != nil { + return p, err + } + if digest != p.ConfigSHA256 { + return p, fmt.Errorf("approved node configuration changed") + } + return p, nil +} + +func approvedIndexAware(p maintenancePolicy, path string) error { + digest, err := fileDigest(path) + if err != nil { + return err + } + for _, approved := range p.IndexAwareBinaries { + if digest == approved { + return nil + } + } + return fmt.Errorf("legacy/unknown writer requires the approved offline fence procedure") +} diff --git a/cmd/ring-index-maintenance/services.go b/cmd/ring-index-maintenance/services.go new file mode 100644 index 0000000..1932e1b --- /dev/null +++ b/cmd/ring-index-maintenance/services.go @@ -0,0 +1,346 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/aperod/aperod/store" +) + +type serviceController struct{} + +func systemctl(args ...string) (string, error) { + if err := protectedPath("/usr/bin/systemctl", 0); err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", args...) + cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C"} + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("systemctl operation failed: %w", err) + } + if out.Len() > 65536 { + return "", fmt.Errorf("unexpected systemctl output size") + } + return strings.TrimSpace(out.String()), nil +} + +func property(unit, key string) (string, error) { + return systemctl("show", unit, "--property="+key, "--value") +} + +func approvedUnitFiles(p maintenancePolicy) error { + for _, u := range allUnits(p) { + for path, want := range u.Files { + if strings.ContainsAny(path, " \t\r\n\\\"") { + return fmt.Errorf("ambiguous unit-file path") + } + if err := protectedPath(path, 0); err != nil { + return err + } + got, err := fileDigest(path) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("unit bytes differ from separate tool approval") + } + } + } + return nil +} + +func allUnits(p maintenancePolicy) []unitPolicy { + return append(append(append([]unitPolicy{}, p.Clients...), p.Writers...), p.Legacy) +} + +func blockerDropIn(u unitPolicy, control string, invert bool) error { + path := filepath.Join("/etc/systemd/system", u.Name+".d", "zzzz-aperod-ring-maintenance.conf") + if _, ok := u.Files[path]; !ok { + return fmt.Errorf("independently approved persistent service interlock missing") + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + condition := control + if invert { + condition = "!" + control + } + want := "[Unit]\nConditionPathExists=" + condition + "\n" + if string(body) != want { + return fmt.Errorf("service interlock bytes do not bind approved database scope") + } + return nil +} + +func (serviceController) validate(p maintenancePolicy, reload bool) error { + if err := approvedUnitFiles(p); err != nil { + return err + } + hold, err := store.RingMaintenanceControlPath(p.DBPath, "quiesced") + if err != nil { + return err + } + allow, err := store.RingMaintenanceControlPath(p.DBPath, "legacy-authorized") + if err != nil { + return err + } + for _, u := range append(append([]unitPolicy{}, p.Clients...), p.Writers...) { + if err := blockerDropIn(u, hold, true); err != nil { + return err + } + } + if err := blockerDropIn(p.Legacy, allow, false); err != nil { + return err + } + if reload { + if _, err := systemctl("daemon-reload"); err != nil { + return err + } + } + for _, u := range allUnits(p) { + fragment, err := property(u.Name, "FragmentPath") + if err != nil { + return err + } + dropins, err := property(u.Name, "DropInPaths") + if err != nil { + return err + } + actual := map[string]bool{fragment: true} + for _, path := range strings.Fields(dropins) { + if filepath.Base(path) > "zzzz-aperod-ring-maintenance.conf" { + return fmt.Errorf("later drop-in can override maintenance condition") + } + actual[path] = true + } + if fragment == "" || len(actual) != len(u.Files) { + return fmt.Errorf("runtime unit file set not approved") + } + for path := range actual { + if _, ok := u.Files[path]; !ok { + return fmt.Errorf("unapproved runtime unit override") + } + } + } + for key, want := range map[string]string{"User": "aperod", "DynamicUser": "no", "PrivateNetwork": "yes", "KillMode": "control-group", "Restart": "no", "Type": "oneshot", "RemainAfterExit": "no", "NoNewPrivileges": "yes", "Delegate": "no"} { + got, err := property(p.Legacy.Name, key) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("dedicated legacy worker isolation/ownership not approved") + } + } + enabled, err := property(p.Legacy.Name, "UnitFileState") + if err != nil { + return err + } + if enabled != "static" && enabled != "disabled" { + return fmt.Errorf("legacy worker must not auto-start at boot") + } + if err := protectedPath(p.LegacyBinary, 0); err != nil { + return err + } + digest, err := fileDigest(p.LegacyBinary) + if err != nil { + return err + } + if digest != p.LegacySHA256 { + return fmt.Errorf("legacy worker binary not approved") + } + return nil +} + +func (serviceController) stopped(u unitPolicy) error { + state, err := property(u.Name, "ActiveState") + if err != nil { + return err + } + pid, err := property(u.Name, "MainPID") + if err != nil { + return err + } + pidEmptyOK := !strings.HasSuffix(u.Name, ".service") && pid == "" + if (state != "inactive" && state != "failed") || (pid != "0" && !pidEmptyOK) { + return fmt.Errorf("unit not quiescent") + } + job, err := property(u.Name, "Job") + if err != nil { + return err + } + if job != "" && job != "0" { + return fmt.Errorf("pending unit job prevents quiescence") + } + group, err := property(u.Name, "ControlGroup") + if err != nil { + return err + } + if group == "" { + return nil + } + if !strings.HasPrefix(group, "/") || group == "/" || filepath.Clean(group) != group { + return fmt.Errorf("invalid runtime cgroup scope") + } + return emptyCgroup(filepath.Join("/sys/fs/cgroup", group)) +} + +func emptyCgroup(path string) error { + procs, err := os.Open(filepath.Join(path, "cgroup.procs")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + data, err := io.ReadAll(io.LimitReader(procs, 65537)) + procs.Close() + if err != nil { + return err + } + if len(data) > 65536 || strings.TrimSpace(string(data)) != "" { + return fmt.Errorf("writer cgroup still contains processes") + } + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + for { + entries, err := dir.ReadDir(256) + if err != nil && err != io.EOF { + return err + } + for _, entry := range entries { + if entry.IsDir() { + if err := emptyCgroup(filepath.Join(path, entry.Name())); err != nil { + return err + } + } + } + if err == io.EOF { + return nil + } + } +} + +func (serviceController) activeBefore(p maintenancePolicy) ([]string, error) { + var names []string + for _, u := range append(append([]unitPolicy{}, p.Clients...), p.Writers...) { + state, err := property(u.Name, "ActiveState") + if err != nil { + return nil, err + } + switch state { + case "active": + names = append(names, u.Name) + case "inactive", "failed": + default: + return nil, fmt.Errorf("unit transition in progress; prepare refused") + } + } + return names, nil +} + +func previouslyActive(units []unitPolicy, names []string) []unitPolicy { + selected := make(map[string]bool, len(names)) + for _, name := range names { + selected[name] = true + } + var result []unitPolicy + for _, u := range units { + if selected[u.Name] { + result = append(result, u) + } + } + return result +} + +func (s serviceController) stop(units []unitPolicy) error { + for _, u := range units { + if _, err := systemctl("stop", u.Name); err != nil { + return err + } + if err := s.stopped(u); err != nil { + return err + } + } + return nil +} + +func (serviceController) start(units []unitPolicy) error { + for _, u := range units { + if _, err := systemctl("start", u.Name); err != nil { + return err + } + } + return nil +} + +// A running writer's actual LOCK descriptor witnesses scope without reading +// credentials/environment or trusting a guessed/default configuration path. +func (serviceController) witnessDB(p maintenancePolicy) error { + if p.AllowColdPrepare { + return nil + } // must be separately approved in policy + expected, err := fileDigest(p.NodeBinary) + if err != nil { + return err + } + for _, u := range p.Writers { + text, err := property(u.Name, "MainPID") + if err != nil { + return err + } + pid, err := strconv.ParseUint(text, 10, 32) + if err != nil { + return err + } + if pid == 0 { + continue + } + dir := filepath.Join("/proc", text, "fd") + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + target, err := os.Readlink(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + if target == filepath.Join(p.DBPath, "LOCK") { + 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 + } + if hex.EncodeToString(h.Sum(nil)) != expected { + return fmt.Errorf("running image differs from backup binary") + } + return nil + } + } + } + return fmt.Errorf("live writer database scope unproven; explicit cold-copy approval required") +} diff --git a/cmd/ring-index-maintenance/worker.go b/cmd/ring-index-maintenance/worker.go new file mode 100644 index 0000000..9d33d0e --- /dev/null +++ b/cmd/ring-index-maintenance/worker.go @@ -0,0 +1,182 @@ +package main + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" + "time" + + "github.com/aperod/aperod/store" + "golang.org/x/sys/unix" +) + +type tipReceipt struct { + Hash string `json:"hash"` + Height uint64 `json:"height"` +} + +func tipMetadata(db *store.DB) (tipReceipt, error) { + hash, err := db.GetMeta("tip/hash") + if err != nil { + return tipReceipt{}, err + } + height, err := db.GetMeta("tip/height") + if err != nil { + return tipReceipt{}, err + } + if len(hash) != 32 || len(height) != 8 { + return tipReceipt{}, fmt.Errorf("complete canonical tip metadata required") + } + zero := true + for _, b := range hash { + if b != 0 { + zero = false + } + } + if zero { + return tipReceipt{}, fmt.Errorf("zero tip hash refused") + } + return tipReceipt{hex.EncodeToString(hash), binary.LittleEndian.Uint64(height)}, nil +} + +func worker(mode, path, generation string) error { + if os.Geteuid() == 0 { + return fmt.Errorf("database worker must not run as root") + } + // Do not expose inherited capabilities/worker memory to unrelated + // processes of the data-owner UID via ptrace or /proc//fd. + if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { + return err + } + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return err + } + p, err := loadApprovedPolicyMode(false) + if err != nil { + return err + } + if path != p.DBPath { + return fmt.Errorf("worker scope differs from independent approval") + } + lease, err := store.BorrowRingMaintenanceLease(path, generation, os.NewFile(3, "inherited-lease")) + if err != nil { + return err + } + defer lease.Close() + switch mode { + case "backup-lock": + db, err := lease.OpenReadOnly() + if err != nil { + return err + } + defer db.Close() + tip, err := tipMetadata(db) + if err != nil { + return err + } + if err := json.NewEncoder(os.Stdout).Encode(tip); err != nil { + return err + } + line, err := bufio.NewReader(io.LimitReader(os.Stdin, 16)).ReadString('\n') + if err != nil || line != "release\n" { + return fmt.Errorf("backup lock controller disconnected") + } + return nil + case "invalidate": + return lease.Invalidate() + case "rebuild": + inventory, err := lease.RebuildAndVerify() + if err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(store.RingMaintenanceWorkerReceipt{Schema: 1, DBPath: path, Generation: generation, Inventory: inventory}) + default: + return fmt.Errorf("unsupported worker operation") + } +} + +func workerCommand(p maintenancePolicy, l *store.RingMaintenanceLease, mode string) (*exec.Cmd, *os.File, context.CancelFunc, error) { + // Recheck the installed executable immediately before exec, not merely its + // name; approval of a source tree does not authorize these root tool bytes. + digest, err := fileDigest(toolPath) + if err != nil || digest != p.ToolSHA256 { + return nil, nil, nil, fmt.Errorf("worker tool changed") + } + account, err := user.Lookup("aperod") + if err != nil { + return nil, nil, nil, err + } + uid, err := strconv.ParseUint(account.Uid, 10, 32) + if err != nil || uid == 0 { + return nil, nil, nil, fmt.Errorf("non-root data owner required") + } + gid, err := strconv.ParseUint(account.Gid, 10, 32) + if err != nil { + return nil, nil, nil, err + } + fd, err := l.WorkerFile() + if err != nil { + return nil, nil, nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + 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} + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, Cloneflags: syscall.CLONE_NEWNET, Pdeathsig: syscall.SIGKILL} + return cmd, fd, cancel, nil +} + +func invalidateWorker(p maintenancePolicy, l *store.RingMaintenanceLease) error { + cmd, fd, cancel, err := workerCommand(p, l, "invalidate") + if err != nil { + return err + } + defer fd.Close() + defer cancel() + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func rebuildWorker(p maintenancePolicy, l *store.RingMaintenanceLease) (store.RingMaintenanceWorkerReceipt, error) { + var receipt store.RingMaintenanceWorkerReceipt + cmd, fd, cancel, err := workerCommand(p, l, "rebuild") + if err != nil { + return receipt, err + } + defer fd.Close() + defer cancel() + out, err := cmd.StdoutPipe() + if err != nil { + return receipt, err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return receipt, err + } + decoder := json.NewDecoder(io.LimitReader(out, 8193)) + decoder.DisallowUnknownFields() + decodeErr := decoder.Decode(&receipt) + var extra any + if decodeErr == nil { + if err := decoder.Decode(&extra); err != io.EOF { + decodeErr = fmt.Errorf("unexpected worker stdout") + } + } + if decodeErr != nil { + cancel() + } + waitErr := cmd.Wait() + if decodeErr != nil { + return receipt, decodeErr + } + return receipt, waitErr +} diff --git a/cmd/ring-index-maintenance/workflow.go b/cmd/ring-index-maintenance/workflow.go new file mode 100644 index 0000000..b664937 --- /dev/null +++ b/cmd/ring-index-maintenance/workflow.go @@ -0,0 +1,403 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/aperod/aperod/store" +) + +type operationJournal struct { + Schema int `json:"schema"` + ApprovalID string `json:"approval_id"` + PolicySHA256 string `json:"policy_sha256"` + DBPath string `json:"db_path"` + Backup string `json:"backup"` + ManifestSHA256 string `json:"manifest_sha256"` + Phase string `json:"phase"` + Resume []string `json:"resume"` +} + +func control(p maintenancePolicy, kind string) string { + path, err := store.RingMaintenanceControlPath(p.DBPath, kind) + if err != nil { + panic(err) + } // validated canonical scope, not request input + return path +} + +func readJournal(p maintenancePolicy) (operationJournal, error) { + j, err := readJournalHeader(p) + if err != nil { + return j, err + } + 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 { + return j, err + } + got, err := fileDigest(filepath.Join(j.Backup, "manifest.jsonl")) + if err != nil { + return j, err + } + if got != j.ManifestSHA256 { + return j, fmt.Errorf("coherent backup manifest changed") + } + if err := verifyBackup(j.Backup); err != nil { + return j, err + } + return j, nil +} + +func readJournalHeader(p maintenancePolicy) (operationJournal, error) { + var j operationJournal + path := control(p, "journal.json") + if err := protectedPath(path, 0); err != nil { + return j, err + } + f, err := os.Open(path) + if err != nil { + return j, err + } + defer f.Close() + decoder := json.NewDecoder(io.LimitReader(f, 65537)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&j); err != nil { + return j, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return j, fmt.Errorf("journal trailing content") + } + digest, err := fileDigest(policyPath) + if err != nil { + return j, err + } + if j.Schema != 1 || j.ApprovalID != p.ApprovalID || j.DBPath != p.DBPath || j.PolicySHA256 != digest { + return j, fmt.Errorf("operation approval/scope changed; independent recovery review required") + } + 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 { + return "", "", err + } + cmd, fd, cancel, err := workerCommand(p, l, "backup-lock") + if err != nil { + return "", "", err + } + defer fd.Close() + defer cancel() + input, err := cmd.StdinPipe() + if err != nil { + return "", "", err + } + output, err := cmd.StdoutPipe() + if err != nil { + return "", "", err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return "", "", err + } + done := false + defer func() { + if !done { + cancel() + input.Close() + cmd.Wait() + } + }() + var original tipReceipt + decoder := json.NewDecoder(io.LimitReader(output, 4096)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&original); err != nil { + return "", "", err + } + backup, err := os.MkdirTemp(p.BackupRoot, "ring-maintenance-") + if err != nil { + return "", "", err + } + if err := register(backup); err != nil { + return backup, "", err + } + 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 + } + closed := false + defer func() { + if !closed { + manifest.Close() + } + }() + encoder := json.NewEncoder(manifest) + if err := encoder.Encode(map[string]any{"schema": 1, "db_path": p.DBPath, "tip": original, "approval_id": p.ApprovalID}); err != nil { + return backup, "", err + } + if err := copyTree(p.DataDir, filepath.Join(backup, "data"), encoder); err != nil { + return backup, "", err + } + // 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"}} { + in, err := os.Open(pair[0]) + if err != nil { + return backup, "", err + } + 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 + } + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, h), in) + 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 := encoder.Encode(backupEntry{Tree: "meta", Path: pair[1], SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n, Mode: 0600}); err != nil { + return backup, "", err + } + } + copyDB := filepath.Join(backup, "data", mustRelative(p.DataDir, p.DBPath)) + db, err := store.OpenReadOnly(copyDB) + if err != nil { + return backup, "", err + } + copied, tipErr := tipMetadata(db) + closeErr := db.Close() + if tipErr != nil { + return backup, "", tipErr + } + if closeErr != nil { + return backup, "", closeErr + } + if copied != original { + return backup, "", fmt.Errorf("copy tip differs from quiesced source") + } + if err := encoder.Encode(map[string]any{"complete": true, "tip": copied}); err != nil { + return backup, "", err + } + if err := manifest.Sync(); err != nil { + return backup, "", err + } + if err := manifest.Close(); err != nil { + return backup, "", err + } + closed = true + if err := syncDirectory(backup); err != nil { + return backup, "", err + } + if err := syncDirectory(p.BackupRoot); err != nil { + return backup, "", err + } + if err := verifyBackup(backup); err != nil { + return backup, "", err + } + if _, err := io.WriteString(input, "release\n"); err != nil { + return backup, "", err + } + input.Close() + if err := cmd.Wait(); err != nil { + return backup, "", err + } + done = true + digest, err := fileDigest(filepath.Join(backup, "manifest.jsonl")) + return backup, digest, err +} + +func mustRelative(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + panic(err) + } + return rel +} + +func prepare(p maintenancePolicy, l *store.RingMaintenanceLease, s serviceController) error { + var j operationJournal + var partial string + if _, err := os.Lstat(control(p, "journal.json")); os.IsNotExist(err) { + if err := s.witnessDB(p); err != nil { + return err + } + resume, err := s.activeBefore(p) + if err != nil { + return err + } + digest, err := fileDigest(policyPath) + if err != nil { + return err + } + j = operationJournal{Schema: 1, ApprovalID: p.ApprovalID, PolicySHA256: digest, DBPath: p.DBPath, Phase: "quiesced", Resume: resume} + if err := writeControl(control(p, "journal.json"), j, true); err != nil { + return err + } + } else if err != nil { + return err + } else { + j, err = readJournalHeader(p) + if err != nil { + return err + } + if j.Phase != "quiesced" && j.Phase != "copying" { + return fmt.Errorf("prepared/attempted operation cannot be replayed") + } + partial = j.Backup + } + if _, err := os.Lstat(control(p, "active")); !os.IsNotExist(err) { + return fmt.Errorf("unexpected prior active fence requires reviewed recovery") + } + if _, err := os.Lstat(control(p, "quiesced")); os.IsNotExist(err) { + if err := writeControl(control(p, "quiesced"), map[string]string{"approval_id": p.ApprovalID}, true); err != nil { + return err + } + } else if err != nil { + return err + } + if err := s.stop(allUnits(p)); err != nil { + return err + } + backup, manifest, err := coherentBackup(p, l, func(path string) error { + j.Phase = "copying" + j.Backup = path + return writeControl(control(p, "journal.json"), j, false) + }) + if err != nil { + return err + } + j.Backup = backup + j.ManifestSHA256 = manifest + j.Phase = "backup-complete" + if err := writeControl(control(p, "journal.json"), j, false); err != nil { + return err + } + // Only this operation's abandoned pre-write copy is disposable, and only + // after a newer full, re-read verified copy exists. Never prune completed + // rollback evidence, by age or because disk space is tight. + if partial != "" && partial != backup { + if filepath.Dir(partial) != p.BackupRoot { + return fmt.Errorf("partial backup scope invalid") + } + if err := protectedPath(partial, 0); err != nil { + return err + } + if err := os.RemoveAll(partial); err != nil { + return err + } + if err := syncDirectory(p.BackupRoot); err != nil { + return err + } + } + if err := l.Fence(); err != nil { + return err + } + if err := invalidateWorker(p, l); err != nil { + return err + } + j.Phase = "prepared" + return writeControl(control(p, "journal.json"), j, false) +} + +func enterLegacy(p maintenancePolicy, l *store.RingMaintenanceLease, s serviceController) error { + j, err := readJournal(p) + if err != nil { + return err + } + if j.Phase != "prepared" { + return fmt.Errorf("legacy operation already attempted/not durably prepared") + } + if err := s.stop(allUnits(p)); err != nil { + return err + } + if err := invalidateWorker(p, l); err != nil { + return err + } + // Fence the attempt BEFORE starting; a crash must never silently replay + // an import/repair/write operation with possible monetary side effects. + j.Phase = "legacy-attempted" + if err := writeControl(control(p, "journal.json"), j, false); err != nil { + return err + } + if err := writeControl(control(p, "legacy-authorized"), map[string]string{"approval_id": p.ApprovalID}, true); err != nil { + return err + } + return s.start([]unitPolicy{p.Legacy}) +} + +func finalize(p maintenancePolicy, l *store.RingMaintenanceLease, s serviceController) error { + j, err := readJournal(p) + if err != nil { + return err + } + if j.Phase == "completed" { + return nil + } + if j.Phase != "prepared" && j.Phase != "legacy-attempted" && j.Phase != "backup-complete" && j.Phase != "audited" { + return fmt.Errorf("unknown/incomplete recovery phase") + } + if _, err := os.Lstat(control(p, "quiesced")); os.IsNotExist(err) { + if err := writeControl(control(p, "quiesced"), map[string]string{"approval_id": p.ApprovalID}, true); err != nil { + return err + } + } else if err != nil { + return err + } + if err := removeControl(control(p, "legacy-authorized")); err != nil { + return err + } + if err := s.stop(allUnits(p)); err != nil { + return err + } + if err := approvedIndexAware(p, p.NodeBinary); err != nil { + return err + } + // backup-complete may mean the controller crashed just before fence + // publication; no legacy action was authorized in that state. + if err := l.Fence(); err != nil && !os.IsExist(err) { + return err + } + receipt, err := rebuildWorker(p, l) + if err != nil { + return err + } + if err := writeControl(control(p, "receipt.json"), map[string]any{"verified": receipt, "backup": j.Backup, "time": time.Now().UTC(), "policy_sha256": j.PolicySHA256}, false); err != nil { + return err + } + j.Phase = "audited" + if err := writeControl(control(p, "journal.json"), j, false); err != nil { + return err + } + if err := l.CommitVerifiedWorker(receipt); err != nil { + return err + } + // External quiescence remains until the verified commit was fsynced. + if err := removeControl(control(p, "quiesced")); err != nil { + return err + } + if err := s.start(previouslyActive(p.Writers, j.Resume)); err != nil { + return err + } + if err := s.start(previouslyActive(p.Clients, j.Resume)); err != nil { + return err + } + j.Phase = "completed" + return writeControl(control(p, "journal.json"), j, false) +} diff --git a/config/rehearsal-offline.yaml b/config/rehearsal-offline.yaml index dc898e2..8f937c1 100644 --- a/config/rehearsal-offline.yaml +++ b/config/rehearsal-offline.yaml @@ -16,7 +16,7 @@ consensus: tail_reward_napro: 100000000 ringct_v4_activation_height: 1750000 ring_ct_clsag_activation_height: 1769500 - staked_collateral_ring_exclusion_activation_height: 2880000 + staked_collateral_ring_exclusion_activation_height: 2950000 reward_authorization_activation_height: 1750000 avm_activation_height: 1850000 api: diff --git a/consensus/native_chrome_transfer_test.go b/consensus/native_chrome_transfer_test.go index eb439c4..03ac14d 100644 --- a/consensus/native_chrome_transfer_test.go +++ b/consensus/native_chrome_transfer_test.go @@ -51,35 +51,60 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { e.pool.SetVerifier(e.txVerifier) acceptPositionBlock(t, e, priv, 3_000_000_000, *deposit) // These are consensus-authorized payout outputs, not UTXO/decoy mocks. - for i := 0; i < 20; i++ { +manyRewards := os.Getenv("NATIVE_CHROME_MANY_REWARDS") == "1" +rewardBlocks := 20 +if manyRewards { +rewardBlocks = 160 +} +for i := 0; i < rewardBlocks; i++ { acceptPositionBlock(t, e, priv, 3_000_000_000) } exit := action - // Keep each JSON-wire monetary value below the frozen signer's safe integer - // boundary. This is a real partial canonical withdrawal, not a rounded value. - exit.Action, exit.Nonce, exit.WithdrawAmount = core.LPoDWithdraw, 1, 1_000_000*100_000_000 + // Exercise the full canonical withdrawal above JavaScript's safe integer + // boundary with the unchanged frozen signer, including large change outputs. + exit.Action, exit.Nonce, exit.WithdrawAmount = core.LPoDWithdraw, 1, 0 withdrawal, err := core.BuildLPoDPositionTx(exit, keys.Spend.Private, crypto.Scalar32{}) if err != nil { t.Fatal(err) } - acceptPositionBlock(t, e, priv, 1, *withdrawal) +if !manyRewards { +acceptPositionBlock(t, e, priv, 1, *withdrawal) +} tip := e.chain.Tip() if !e.IsFinalizedHash(tip.Header.Height, tip.Hash()) { t.Fatal("fixture lacks real finality") } - outputs, _, err := db.LPoDWalletOutputs(action.Beneficiary, "", 128) - if err != nil { - t.Fatal(err) - } - var starting uint64 - for _, u := range outputs { - hs, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, u.TxPubKey, u.OneTimePub) - if err != nil || hs == nil { - t.Fatal("fixture output is not owned") - } - starting += core.DecryptAmount(u.EncAmount, hs) - } - if len(outputs) < 16 || starting == 0 { +readAmounts := func() (uint64, int, error) { +var total uint64 +count := 0 +cursor := "" +for { +rows, next, err := db.LPoDWalletOutputs(action.Beneficiary, cursor, 128) +if err != nil { +return 0, 0, err +} +for _, u := range rows { +hs, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, u.TxPubKey, u.OneTimePub) +if err != nil || hs == nil { +return 0, 0, fmt.Errorf("fixture output is not owned") +} +total += core.DecryptAmount(u.EncAmount, hs) +count++ +} +if next == "" { +return total, count, nil +} +if next == cursor { +return 0, 0, fmt.Errorf("fixture cursor did not advance") +} +cursor = next +} +} +starting, count, err := readAmounts() +if err != nil { +t.Fatal(err) +} +if count < 16 || starting == 0 { t.Fatal("fixture funding/decoy prerequisites not met") } server := api.NewServer(":0", e.chain, e.pool, e.utxos, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -93,20 +118,11 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { server.SetUTXOReady() var mu sync.Mutex issuedToOwner := func() uint64 { - rows, _, err := db.LPoDWalletOutputs(action.Beneficiary, "", 128) +total, _, err := readAmounts() if err != nil { t.Error(err) return 0 } - var total uint64 - for _, u := range rows { - hs, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, u.TxPubKey, u.OneTimePub) - if err != nil || hs == nil { - t.Error("unowned canonical funding output") - return 0 - } - total += core.DecryptAmount(u.EncAmount, hs) - } return total } mux := http.NewServeMux() @@ -166,7 +182,15 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { }) }) mux.Handle("/", server) - httpServer := httptest.NewServer(mux) +httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +diagnostic := &nativeChromeResponseDiagnostic{ResponseWriter: w, status: http.StatusOK} +mux.ServeHTTP(diagnostic, r) +if diagnostic.status >= http.StatusBadRequest { +// Log only fixture responses and the path, never credentials, request bodies, +// seeds or query parameters. Preserve the underlying response unchanged. +t.Logf("isolated node: %s %s -> %d: %s", r.Method, r.URL.Path, diagnostic.status, diagnostic.failure) +} +})) defer httpServer.Close() fixtureDir := t.TempDir() input := filepath.Join(fixtureDir, "private-input.json") @@ -174,6 +198,7 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { "senderPhrase": senderPhrase, "recipientPhrase": recipientPhrase, "sender": string(action.Beneficiary), "recipient": string(crypto.AddressFromKeys(crypto.MainnetByte, recipient.Keys)), "starting": fmt.Sprint(starting), "nodeURL": httpServer.URL, "rpcKey": rpcKey, +"manyRewards": manyRewards, }) if err := os.WriteFile(input, raw, 0600); err != nil { t.Fatal(err) @@ -182,7 +207,7 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { "src/routes/wallet-native-chrome.integration.test.ts", "--maxWorkers=1") cmd.Dir = "../.." // No workspace credentials may be inherited by the fixture or browser. -for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH", "NATIVE_CHROME_ARCHIVE", "NATIVE_CHROME_EXECUTABLE", "NATIVE_WALLET_SCREENSHOT"} { +for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH", "NATIVE_CHROME_ARCHIVE", "NATIVE_CHROME_FROZEN_SIGNER_DIR", "NATIVE_CHROME_EXECUTABLE", "NATIVE_WALLET_SCREENSHOT"} { if value, ok := os.LookupEnv(name); ok { cmd.Env = append(cmd.Env, name+"="+value) } @@ -195,3 +220,25 @@ for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LI } t.Log(string(result)) } + +type nativeChromeResponseDiagnostic struct { +http.ResponseWriter +status int +failure []byte +} + +func (w *nativeChromeResponseDiagnostic) WriteHeader(status int) { +w.status = status +w.ResponseWriter.WriteHeader(status) +} + +func (w *nativeChromeResponseDiagnostic) Write(body []byte) (int, error) { +if w.status >= http.StatusBadRequest && len(w.failure) < 512 { +remaining := 512-len(w.failure) +if len(body) < remaining { +remaining = len(body) +} +w.failure = append(w.failure, body[:remaining]...) +} +return w.ResponseWriter.Write(body) +} diff --git a/consensus/sale_http_rehearsal_test.go b/consensus/sale_http_rehearsal_test.go new file mode 100644 index 0000000..71d8fa5 --- /dev/null +++ b/consensus/sale_http_rehearsal_test.go @@ -0,0 +1,182 @@ +package consensus + +import ( + "bytes" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "filippo.io/edwards25519" + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// Real private node HTTP endpoints, disposable LevelDBs, signatures and wallet +// keys. The invoice is synthetic: no merchant API, production secret or money. +type saleHTTPRehearsal struct { + t *testing.T + server *api.Server + http *httptest.Server + order crypto.Hash32 + payment crypto.Hash32 + cp crypto.Hash32 + address crypto.Address +} + +func newSaleHTTPRehearsal(t *testing.T, e *Engine, m *store.SaleMigration, address crypto.Address) *saleHTTPRehearsal { + t.Helper() + s := api.NewServer("127.0.0.1:0", e.chain, e.pool, e.utxos, testNopLogger()) + s.SetStore(e.cfg.Store) + s.SetValidatorKey(e.cfg.MyKey) + s.SetAPIKey("offline-sale-fixture-only") + s.SetSaleConfig(m, e.CanSettleSale, e.IsSaleFinalizedHash) + h := httptest.NewServer(s) + h.Client().Timeout = 5 * time.Second + t.Cleanup(h.Close) + // Same globally separated order/payment identities as the API adapter. + order := sha256.Sum256([]byte("aperod/protocol-sale/order/v1\x00" + + hex.EncodeToString(m.Checkpoint.Genesis[:]) + "\x00offline-epoch-purchase")) + return &saleHTTPRehearsal{t: t, server: s, http: h, order: crypto.Hash32(order), + payment: store.SaleProviderPayment("offline-provider-invoice"), cp: m.Checkpoint.Message(), address: address} +} + +func (f *saleHTTPRehearsal) request(path string, body any, expected int) map[string]any { + f.t.Helper() + method := http.MethodGet + var raw []byte + if body != nil { + method = http.MethodPost + var err error + raw, err = json.Marshal(body) + if err != nil { + f.t.Fatal(err) + } + } + r, err := http.NewRequest(method, f.http.URL+"/api/v1/admin/protocol-sales/"+path, bytes.NewReader(raw)) + if err != nil { + f.t.Fatal(err) + } + r.Header.Set("X-API-Key", "offline-sale-fixture-only") + r.Header.Set("Content-Type", "application/json") + response, err := f.http.Client().Do(r) + if err != nil { + f.t.Fatal(err) + } + defer response.Body.Close() + var result map[string]any + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + f.t.Fatal(err) + } + if response.StatusCode != expected { + f.t.Fatalf("%s: HTTP %d, expected %d: %v", path, response.StatusCode, expected, result) + } + return result +} + +func (f *saleHTTPRehearsal) reserveBody() map[string]any { + return map[string]any{"order": hex.EncodeToString(f.order[:]), "checkpoint": hex.EncodeToString(f.cp[:]), + "address": string(f.address), "amount_napro": "10000000000", "quote_usd": "8"} +} + +func (f *saleHTTPRehearsal) receipt(status string) map[string]any { + f.t.Helper() + r := f.request("receipt?order="+hex.EncodeToString(f.order[:]), nil, 200) + if r["status"] != status || r["amount_napro"] != "10000000000" { + f.t.Fatal("wrong canonical purchase receipt", r) + } + return r +} + +func (f *saleHTTPRehearsal) enqueue() { + f.t.Helper() + capability := f.request("status", nil, 200) + if capability["ready"] != true || capability["allocation_basis"] != "operator_attested_lpod_epoch" || + capability["historical_supply_verified"] != false { + f.t.Fatal("epoch admission misrepresented history", capability) + } + for i := 0; i < 2; i++ { + f.request("reserve", f.reserveBody(), 200) + } + changed := f.reserveBody() + changed["amount_napro"] = "10000000001" + f.request("reserve", changed, 409) + invoice := map[string]any{"order": hex.EncodeToString(f.order[:]), "invoice": store.SaleInvoice{ + ProviderID: "offline-provider-invoice", Currency: "btc", Address: "offline-only-deposit", + Amount: "0.00100000001", PriceUSD: "8", + }} + for i := 0; i < 2; i++ { + f.request("invoice", invoice, 200) + } + paid := map[string]any{"order": hex.EncodeToString(f.order[:]), "payment": hex.EncodeToString(f.payment[:])} + incorrect := crypto.HashBytes([]byte("another-payment")) + f.request("paid", map[string]any{"order": paid["order"], "payment": hex.EncodeToString(incorrect[:])}, 409) + for i := 0; i < 2; i++ { + f.request("paid", paid, 200) + } + f.receipt("payment_confirmed") // A fully paid invoice is still NOT completion. +} + +func (f *saleHTTPRehearsal) acknowledge(keys *crypto.WalletKeyPair) { + f.t.Helper() + r := f.receipt("finalized") + challenge, ok := r["owner_challenge"].(string) + if !ok || challenge == "" { + f.t.Fatal("missing canonical owner challenge") + } + body := map[string]any{"order": hex.EncodeToString(f.order[:]), "block_hash": r["block_hash"], + "tx_hash": r["tx_hash"], "signature": strings.Repeat("00", 64)} + f.request("recover", body, 409) + x, err := new(edwards25519.Scalar).SetCanonicalBytes(keys.Spend.Private[:]) + if err != nil { + f.t.Fatal(err) + } + nonce := sha512.New() + nonce.Write([]byte("APRO_WEB_AUTH_NONCE_V1\x00")) + nonce.Write(keys.Spend.Private[:]) + nonce.Write([]byte(challenge)) + secret, err := new(edwards25519.Scalar).SetUniformBytes(nonce.Sum(nil)) + if err != nil { + f.t.Fatal(err) + } + R := new(edwards25519.Point).ScalarBaseMult(secret).Bytes() + h := sha512.New() + h.Write([]byte("APRO_WEB_AUTH_CHALLENGE_V1\x00")) + h.Write([]byte(challenge)) + h.Write(keys.Spend.Public[:]) + h.Write(R) + c, err := new(edwards25519.Scalar).SetUniformBytes(h.Sum(nil)) + if err != nil { + f.t.Fatal(err) + } + body["signature"] = hex.EncodeToString(append(append([]byte{}, R...), new(edwards25519.Scalar).MultiplyAdd(c, x, secret).Bytes()...)) + for i := 0; i < 2; i++ { + f.request("recover", body, 200) + } + f.receipt("completed") +} + +func finalizeSaleRehearsal(t *testing.T, engines []*Engine, keys []crypto.ValidatorPrivKey, b *core.Block) { + t.Helper() + for _, e := range engines { + for _, key := range keys { + sig, err := key.Sign(crypto.HashBytes([]byte("aperod/finalize/v1"), b.Hash().Bytes())) + if err != nil { + t.Fatal(err) + } + if err := e.HandleVote(FinalizeMsg{Height: b.Header.Height, BlockHash: b.Hash(), ValidatorPub: key.Public(), Signature: sig}); err != nil { + t.Fatal(err) + } + } + if !e.IsSaleFinalizedHash(b.Header.Height, b.Hash()) { + t.Fatal("actual two-validator finality missing") + } + } +} diff --git a/consensus/sale_integration_test.go b/consensus/sale_integration_test.go index 5f59c0d..a92c58b 100644 --- a/consensus/sale_integration_test.go +++ b/consensus/sale_integration_test.go @@ -20,15 +20,21 @@ import ( // LPoD funding. This is an offline forward-anchor rehearsal, not a claim of // historical body coverage or a live network rollout. func TestSaleTwoValidatorCanonicalDelivery(t *testing.T) { - testSaleTwoValidatorCanonicalDelivery(t, false) + testSaleTwoValidatorCanonicalDelivery(t, "forward") } func TestSaleTwoValidatorNetAllocationCanonicalDelivery(t *testing.T) { - testSaleTwoValidatorCanonicalDelivery(t, true) + testSaleTwoValidatorCanonicalDelivery(t, "net") } -func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { +func TestSaleTwoValidatorOperatorEpochCanonicalDelivery(t *testing.T) { + testSaleTwoValidatorCanonicalDelivery(t, "epoch") +} + +func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { t.Helper() + netBasis := basis == "net" + epochBasis := basis == "epoch" keys := []crypto.ValidatorPrivKey{validatorKey(t, 91), validatorKey(t, 92)} pubs := []crypto.ValidatorPubKey{keys[0].Public(), keys[1].Public()} buyer, err := wallet.DeriveFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", "", 0, 0) @@ -137,11 +143,27 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { reconciliation.GuardianReserved = lp.State.Balance reconciliation.LPoDReserved = lp.Allocation.Remaining + lp.PrincipalLocked } + if epochBasis { + epoch := &sale.AllocationEpochBasis{ + FundingHeight: lp.Allocation.FundingHeight, FundingHash: lp.Allocation.FundingBlock, + Burned: 13745052310704501, ProtectedLPoDPrincipal: lp.PrincipalLocked, + LegacyHoldingsUnverified: true, + } + reconciliation.AllocationEpoch = epoch + reconciliation.HistoricIssued = epoch.Burned + reconciliation.GuardianReserved = lp.State.Balance + reconciliation.LPoDReserved = lp.Allocation.Remaining + epoch.Available = sale.CapNAPRO - epoch.Burned - reconciliation.ValidatorReserved - + reconciliation.GuardianReserved - reconciliation.LPoDReserved + } cp := sale.Checkpoint{Genesis: g.Hash(), Parent: funding.Hash(), Height: funding.Header.Height + 1, Network: crypto.MainnetByte, SnapshotRoot: reconciliation.Digest(), HistoricIssued: reconciliation.HistoricIssued, OtherReserved: reconciliation.ValidatorReserved + reconciliation.GuardianReserved + reconciliation.LPoDReserved, SaleRemaining: 100_000 * lpod.Unit, SaleAuthority: pubs[0]} + if epochBasis { + cp.SaleRemaining = reconciliation.AllocationEpoch.Available + } for i, k := range keys { sig, err := k.Sign(cp.Message()) if err != nil { @@ -157,7 +179,13 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { t.Fatal("funded control must include post-declaration reward issuance") } stale := *migration - if netBasis { + if epochBasis { + // A nominal epoch must never masquerade as historically verified issuance, + // even if both validators genuinely re-sign the changed declaration. + altered := *reconciliation.AllocationEpoch + altered.LegacyHoldingsUnverified = false + stale.Reconciliation.AllocationEpoch = &altered + } else if netBasis { if reconciliation.NetAllocation.WalletNet == 0 { t.Fatal("funded net control has no known reward payment") } @@ -260,16 +288,62 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { if producer == nil { t.Fatal("scheduled producer missing") } - producer.cfg.SaleTransactions = func(h uint64, parent crypto.Hash32) ([]core.Transaction, error) { - tx, err := core.BuildSaleTransaction(core.SaleAuthorization{Genesis: cp.Genesis, Height: h, Parent: parent, - Order: crypto.HashBytes([]byte("paid offline order")), Payment: crypto.HashBytes([]byte("verified offline payment")), - Amount: 100 * lpod.Unit}, buyer.Address, cp.Network, keys[0]) + var httpRehearsal *saleHTTPRehearsal + slot := uint64(2) + if epochBasis { + httpRehearsal = newSaleHTTPRehearsal(t, producer, migration, buyer.Address) + // Do not accept even a fixture invoice until the checkpoint itself has + // become a canonical, quorum-finalized block. + httpRehearsal.request("reserve", httpRehearsal.reserveBody(), 503) + activation, err := producer.produceBlock(cp.Height, slot, funding) if err != nil { - return nil, err + t.Fatal(err) + } + for _, e := range engines { + if err := e.handleIncomingBlock(activation); err != nil { + t.Fatal(err) + } + } + finalizeSaleRehearsal(t, engines, keys, activation) + funding = activation + slot = 4 + httpRehearsal.enqueue() + // The authority only produces in its own scheduled rounds. Preserve the paid + // obligation through the other validator's normal intervening block. + var intervening *Engine + for _, e := range engines { + if e.proposerAt(3).Equals(e.cfg.MyKey.Public()) { + intervening = e + } + } + if intervening == nil { + t.Fatal("intervening proposer missing") + } + waiting, err := intervening.produceBlock(funding.Header.Height+1, 3, funding) + if err != nil { + t.Fatal(err) + } + for _, e := range engines { + if err := e.handleIncomingBlock(waiting); err != nil { + t.Fatal(err) + } + } + finalizeSaleRehearsal(t, engines, keys, waiting) + funding = waiting + httpRehearsal.receipt("payment_confirmed") + // Keep the real durable pending-order builder installed by NewEngine. + } else { + producer.cfg.SaleTransactions = func(h uint64, parent crypto.Hash32) ([]core.Transaction, error) { + tx, err := core.BuildSaleTransaction(core.SaleAuthorization{Genesis: cp.Genesis, Height: h, Parent: parent, + Order: crypto.HashBytes([]byte("paid offline order")), Payment: crypto.HashBytes([]byte("verified offline payment")), + Amount: 100 * lpod.Unit}, buyer.Address, cp.Network, keys[0]) + if err != nil { + return nil, err + } + return []core.Transaction{*tx}, nil } - return []core.Transaction{*tx}, nil } - block, err := producer.produceBlock(cp.Height, 2, funding) + block, err := producer.produceBlock(funding.Header.Height+1, slot, funding) if err != nil { t.Fatal(err) } @@ -303,23 +377,24 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { if !e.IsFinalizedHash(block.Header.Height, block.Hash()) { t.Fatal("buyer delivery lacks actual quorum finality") } -restoredFinality := &Engine{cfg: e.cfg, lpodFinalizedHashes: map[uint64]crypto.Hash32{ -block.Header.Height: block.Hash(), -}} -if !restoredFinality.IsSaleFinalizedHash(parent.Header.Height, parent.Hash()) { -t.Fatal("canonical sale ancestor lost finality after certificate-only restoration") -} -if restoredFinality.IsSaleFinalizedHash(block.Header.Height+1, block.Hash()) { -t.Fatal("unfinalized future sale block accepted") -} -wrongHash := parent.Hash(); wrongHash[0] ^= 1 -if restoredFinality.IsSaleFinalizedHash(parent.Header.Height, wrongHash) { -t.Fatal("noncanonical sale ancestor accepted") -} -restoredFinality.lpodFinalizedHashes[block.Header.Height] = wrongHash -if restoredFinality.IsSaleFinalizedHash(parent.Header.Height, parent.Hash()) { -t.Fatal("unbound certificate anchor accepted") -} + restoredFinality := &Engine{cfg: e.cfg, lpodFinalizedHashes: map[uint64]crypto.Hash32{ + block.Header.Height: block.Hash(), + }} + if !restoredFinality.IsSaleFinalizedHash(parent.Header.Height, parent.Hash()) { + t.Fatal("canonical sale ancestor lost finality after certificate-only restoration") + } + if restoredFinality.IsSaleFinalizedHash(block.Header.Height+1, block.Hash()) { + t.Fatal("unfinalized future sale block accepted") + } + wrongHash := parent.Hash() + wrongHash[0] ^= 1 + if restoredFinality.IsSaleFinalizedHash(parent.Header.Height, wrongHash) { + t.Fatal("noncanonical sale ancestor accepted") + } + restoredFinality.lpodFinalizedHashes[block.Header.Height] = wrongHash + if restoredFinality.IsSaleFinalizedHash(parent.Header.Height, parent.Hash()) { + t.Fatal("unbound certificate anchor accepted") + } } for _, d := range dbs { if _, err := d.LoadSaleMigrationFile(""); err == nil { @@ -332,19 +407,19 @@ t.Fatal("unbound certificate anchor accepted") if !engines[0].CanSettleSale() || engines[1].CanSettleSale() { t.Fatal("invoice admission does not match the eligible sale authority") } -rotated := &Engine{cfg: engines[0].cfg} -rotated.cfg.Registry = core.NewValidatorRegistry() -rotated.cfg.Registry.InitFromGenesis([]crypto.ValidatorPubKey{pubs[1]}, 100_000*lpod.Unit) -rotated.cfg.ValidatorAuthorityFork = &core.ValidatorAuthorityFork{ -Height: block.Header.Height+1, Identity: pubs[1], Replacement: pubs[0], -} -if !rotated.CanSettleSale() { -t.Fatal("authorized successor is not admitted under its active collateral identity") -} -rotated.cfg.ValidatorAuthorityFork.Height++ -if rotated.CanSettleSale() { -t.Fatal("future authority rule admitted an unfunded signing identity early") -} + rotated := &Engine{cfg: engines[0].cfg} + rotated.cfg.Registry = core.NewValidatorRegistry() + rotated.cfg.Registry.InitFromGenesis([]crypto.ValidatorPubKey{pubs[1]}, 100_000*lpod.Unit) + rotated.cfg.ValidatorAuthorityFork = &core.ValidatorAuthorityFork{ + Height: block.Header.Height + 1, Identity: pubs[1], Replacement: pubs[0], + } + if !rotated.CanSettleSale() { + t.Fatal("authorized successor is not admitted under its active collateral identity") + } + rotated.cfg.ValidatorAuthorityFork.Height++ + if rotated.CanSettleSale() { + t.Fatal("future authority rule admitted an unfunded signing identity early") + } engines[0].halted.Store(true) if engines[0].CanSettleSale() { t.Fatal("halted producer still admits invoices") @@ -367,55 +442,59 @@ t.Fatal("future authority rule admitted an unfunded signing identity early") if delivered != 100*lpod.Unit { t.Fatal("owner did not discover exact canonical sale output") } -if a.Remaining != cp.SaleRemaining-delivered { -t.Fatal("purchase did not debit the available allocation by its credited amount") -} -// Purchases must exist as actual active outputs in the node snapshot, not -// merely as a payment callback or an incremented display total. -verifyPurchaseSnapshot := func(snapshot core.UTXOSnapshot) { -t.Helper() -var total uint64 -seen := make(map[crypto.Hash32]bool) -for _, output := range snapshot.ActiveUTXOs { -for _, tx := range block.Txs { -if !tx.IsSale() || output.TxHash != tx.Hash() { -continue -} -authorization, err := tx.SaleAuthorization() -if err != nil || seen[output.TxHash] || output.OutputIndex != 0 || -output.BlockHeight != block.Header.Height || output.ProtocolLocked || -output.AmountCommit != tx.Outputs[0].AmountCommit { -t.Fatal("snapshot contains invalid or duplicate purchase output", err) -} -seen[output.TxHash] = true -total += authorization.Amount -} -} -if total != delivered { -t.Fatal("UTXO snapshot does not contain the exact credited purchase amount") -} -} -for _, e := range engines { -snapshot := e.utxos.TakeSnapshot() -verifyPurchaseSnapshot(snapshot) -encoded, err := json.Marshal(snapshot) -if err != nil { -t.Fatal(err) -} -var decoded core.UTXOSnapshot -if err := json.Unmarshal(encoded, &decoded); err != nil { -t.Fatal(err) -} -restored := core.NewUTXOSet() -restored.RestoreFromSnapshot(decoded) -verifyPurchaseSnapshot(restored.TakeSnapshot()) -} + if a.Remaining != cp.SaleRemaining-delivered { + 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) + } + // Purchases must exist as actual active outputs in the node snapshot, not + // merely as a payment callback or an incremented display total. + verifyPurchaseSnapshot := func(snapshot core.UTXOSnapshot) { + t.Helper() + var total uint64 + seen := make(map[crypto.Hash32]bool) + for _, output := range snapshot.ActiveUTXOs { + for _, tx := range block.Txs { + if !tx.IsSale() || output.TxHash != tx.Hash() { + continue + } + authorization, err := tx.SaleAuthorization() + if err != nil || seen[output.TxHash] || output.OutputIndex != 0 || + output.BlockHeight != block.Header.Height || output.ProtocolLocked || + output.AmountCommit != tx.Outputs[0].AmountCommit { + t.Fatal("snapshot contains invalid or duplicate purchase output", err) + } + seen[output.TxHash] = true + total += authorization.Amount + } + } + if total != delivered { + t.Fatal("UTXO snapshot does not contain the exact credited purchase amount") + } + } + for _, e := range engines { + snapshot := e.utxos.TakeSnapshot() + verifyPurchaseSnapshot(snapshot) + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + var decoded core.UTXOSnapshot + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + restored := core.NewUTXOSet() + restored.RestoreFromSnapshot(decoded) + verifyPurchaseSnapshot(restored.TakeSnapshot()) + } for _, e := range engines { tip := e.chain.Tip().Hash() if e.handleIncomingBlock(block) == nil || e.chain.Tip().Hash() != tip { t.Fatal("replayed block changed canonical state") } -verifyPurchaseSnapshot(e.utxos.TakeSnapshot()) + verifyPurchaseSnapshot(e.utxos.TakeSnapshot()) } for i, d := range dbs { if err := d.Close(); err != nil { @@ -433,5 +512,14 @@ verifyPurchaseSnapshot(e.utxos.TakeSnapshot()) if _, err := reopened.LoadSaleMigrationFile(configPath); err != nil { t.Fatal("reopened ledger rejected its signed configuration", err) } + engines[i].cfg.Store = reopened + if httpRehearsal != nil && producer == engines[i] { + httpRehearsal.server.SetStore(reopened) + 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) + } + } } } diff --git a/core/chain.go b/core/chain.go index c0091f9..331ec9a 100644 --- a/core/chain.go +++ b/core/chain.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "sort" "sync" "github.com/aperod/aperod/crypto" @@ -119,6 +120,22 @@ func (c *Chain) GetByHeight(h uint64) *Block { return c.byHeight[h] } +// BlocksNewestFirst returns a stable snapshot of the blocks currently retained +// in memory, ordered from the canonical tip backwards. Older bodies have +// already been evicted and cannot be returned by GetByHeight. +func (c *Chain) BlocksNewestFirst() []*Block { + c.mu.RLock() + defer c.mu.RUnlock() + blocks := make([]*Block, 0, len(c.byHeight)) + for _, block := range c.byHeight { + blocks = append(blocks, block) + } + sort.Slice(blocks, func(i, j int) bool { + return blocks[i].Header.Height > blocks[j].Header.Height + }) + return blocks +} + // HasBlock returns true if the block with this hash is already known. func (c *Chain) HasBlock(h crypto.Hash32) bool { c.mu.RLock() diff --git a/core/chain_test.go b/core/chain_test.go index 28f8527..0dfeadf 100644 --- a/core/chain_test.go +++ b/core/chain_test.go @@ -130,6 +130,31 @@ func TestChain_MemoryCapConfigurableWindow(t *testing.T) { } } +func TestChain_BlocksNewestFirstReturnsStableRetainedWindow(t *testing.T) { + c := core.NewChain(3) + prev := makeMinimalBlock(0, crypto.Hash32{}) + if err := c.SetGenesis(prev); err != nil { + t.Fatalf("SetGenesis: %v", err) + } + for height := uint64(1); height <= 5; height++ { + block := makeMinimalBlock(height, prev.Hash()) + if err := c.AddBlock(block); err != nil { + t.Fatalf("AddBlock(%d): %v", height, err) + } + prev = block + } + + blocks := c.BlocksNewestFirst() + if len(blocks) != 3 { + t.Fatalf("BlocksNewestFirst returned %d blocks, want 3", len(blocks)) + } + for i, wantHeight := range []uint64{5, 4, 3} { + if got := blocks[i].Header.Height; got != wantHeight { + t.Errorf("blocks[%d].Height = %d, want %d", i, got, wantHeight) + } + } +} + // TestChain_FastForwardMemoryCap (#462) — bulk-loading more blocks than the // window via FastForward (the startup / P2P catch-up path) must apply the // same eviction so memory stays bounded. diff --git a/core/ringct_v5_test.go b/core/ringct_v5_test.go index 36ce74a..1480a34 100644 --- a/core/ringct_v5_test.go +++ b/core/ringct_v5_test.go @@ -141,7 +141,7 @@ func TestRingCTV5RejectsStakedCollateralAcrossRestartAndRollback(t *testing.T) { set := core.NewUTXOSetWithDB(db) set.SetCLSAGActivationHeight(1) set.Add(&owned[0].UTXO) -const stakedCollateralActivationHeight = uint64(2_880_000) +const stakedCollateralActivationHeight = uint64(2_950_000) verifier := core.NewTxVerifier(set) verifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) diff --git a/deploy/publish_public_repo.py b/deploy/publish_public_repo.py index 40fb2a7..6ca7f07 100644 --- a/deploy/publish_public_repo.py +++ b/deploy/publish_public_repo.py @@ -37,8 +37,10 @@ build/ __pycache__/ """ -MESSAGE = ("chore: maintain verified source distribution\n\n" - "Co-authored-by: aperod-network ") +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", @@ -46,11 +48,13 @@ "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): @@ -245,18 +249,58 @@ def await_merge(pr, request, timeout=900): raise RuntimeError("Publication PR closed without merging") sha = pull["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") == 15368} + 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": - raise RuntimeError("Required publication check failed: " + name) - if all(name in trusted and trusted[name]["status"] == "completed" - and trusted[name]["conclusion"] == "success" for name in required): + 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 + ): result = request("PUT", f"{API}/pulls/{pr['number']}/merge", { "sha": sha, "merge_method": "merge", "commit_title": "chore: maintain verified source distribution", - "commit_message": "Co-authored-by: aperod-network "}) + "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"]) diff --git a/deploy/test-node-service-lifecycle.py b/deploy/test-node-service-lifecycle.py index 676e395..998e058 100644 --- a/deploy/test-node-service-lifecycle.py +++ b/deploy/test-node-service-lifecycle.py @@ -68,9 +68,9 @@ def source(name): "require github.com/syndtr/goleveldb v1.0.0\n") (path / "Makefile").write_text( "deps:\n\tgo mod download\nbuild:\n\tmkdir -p build\n" - "\tgo build -o build/aperod-node ./cmd/node\n" - "\tgo build -o build/aperod ./cmd/aperod\n" - "\tgo build -o build/store ./cmd/store\n") + "\tgo build -trimpath -o build/aperod-node ./cmd/node\n" + "\tgo build -trimpath -o build/aperod ./cmd/aperod\n" + "\tgo build -trimpath -o build/store ./cmd/store\n") (path / ".gitignore").write_text("/build/\n") run("go", "-C", path, "mod", "tidy") run("git", "init", "-qb", "main", path) @@ -93,11 +93,38 @@ def source(name): assert root_sha != pin != bad_pin node_source.write_text(node_source.read_text().replace("const unhealthy = true", "const unhealthy = false")) makefile = new / "Makefile" -makefile.write_text(makefile.read_text() + "\ttouch /fixture/build-control/build-ready\n" - "\twhile [ ! -f /fixture/build-control/build-release ]; do sleep 0.1; done\n") +makefile.write_text( + makefile.read_text() + + '\t@if [ "$(LIFECYCLE_SKIP_BUILD_BARRIER)" = "1" ]; then :; else ' + "touch /fixture/build-control/build-ready; " + "while [ ! -f /fixture/build-control/build-release ]; do sleep 0.1; done; fi\n" +) git(new, "add", ".") git(new, "commit", "-qm", "Synthetic approval-race build barrier") race_pin = git(new, "rev-parse", "HEAD") + + +def approved_candidate_hash(commit, *, skip_build_barrier=False): + checkout = ROOT / ("approved-source-" + commit[:12]) + build_env = {**env, "CGO_ENABLED": "0", "GOWORK": "off", "GOFLAGS": "", + "GOENV": "off", "GOAMD64": "v1"} + try: + run("git", "-C", new, "clone", "--no-hardlinks", "--no-checkout", new, checkout) + git(checkout, "checkout", "--detach", commit) + make_args = ["make", "-C", checkout, "CGO_ENABLED=0"] + if skip_build_barrier: + make_args.append("LIFECYCLE_SKIP_BUILD_BARRIER=1") + make_args.append("build") + run(*make_args, custom=build_env) + binary = checkout / "build/aperod-node" + info = run("go", "version", "-m", binary, custom=build_env).stdout + assert f"vcs.revision={commit}" in info, "fixture binary lost selected-commit provenance" + assert "vcs.modified=false" in info, "fixture binary was built from a modified checkout" + return digest(binary) + finally: + shutil.rmtree(checkout, ignore_errors=True) + + # Modify trusted fixture identities in a COPIED bootstrap helper only. Every # production origin/root/pin/baseline/provenance guard is otherwise executed. bootstrap = ROOT / "bootstrap" @@ -192,6 +219,12 @@ def service_proof(version): # hooks. Never use it as the build source or mutate its Git metadata. old_repo = source("old") run("go", "-C", old_repo, "build", "-o", ROOT / "old-binary", "./cmd/node") +ring_index_approved_hashes = [ + digest(ROOT / "old-binary"), + approved_candidate_hash(pin), + approved_candidate_hash(bad_pin), + approved_candidate_hash(race_pin, skip_build_barrier=True), +] run("systemctl", "stop", "aperod-node") shutil.copy2(ROOT / "old-binary", BINARY) run("systemctl", "start", "aperod-node") @@ -216,8 +249,44 @@ def service_proof(version): config = Path("/etc/aperod/node.yaml") config.write_text("data_dir: /var/lib/aperod/data\nconsensus:\n" " validator_key: /var/lib/aperod/data/validator.key\n") +ring_tool = Path("/usr/local/libexec/aperod-ring-index-maintenance") +ring_policy = Path("/etc/aperod/ring-index-maintenance/policy.json") +ring_policy.parent.mkdir(parents=True, exist_ok=True, mode=0o755) +ring_policy.parent.chmod(0o755) +unit_hash = hashlib.sha256(b"synthetic lifecycle unit fixture").hexdigest() +policy = { + "schema": 1, + "approval_id": "lifecycle-fixture", + "tool_sha256": digest(ring_tool), + "operation": "repair", + "data_dir": str(data), + "db_path": str(data / "chain.db"), + "backup_root": "/var/lib/aperod/ring-index-maintenance-backups", + "config_path": str(config), + "config_sha256": digest(config), + "node_binary": str(BINARY), + "index_aware_binaries": sorted(set(ring_index_approved_hashes)), + "clients": [{ + "name": "fixture-client.service", + "files": {"/etc/systemd/system/fixture-client.service": unit_hash}, + }], + "writers": [{ + "name": "fixture-writer.service", + "files": {"/etc/systemd/system/fixture-writer.service": unit_hash}, + }], + "legacy": { + "name": "fixture-legacy.service", + "files": {"/etc/systemd/system/fixture-legacy.service": unit_hash}, + }, + "legacy_binary": str(ROOT / "old-binary"), + "legacy_sha256": digest(ROOT / "old-binary"), + "allow_cold_prepare": False, +} +ring_policy.write_text(json.dumps(policy, separators=(",", ":")) + "\n") +ring_policy.chmod(0o644) +protected_paths = [OLD, data, config, ring_tool, ring_policy] protected = {str(p): manifest(p) if p.is_dir() else [digest(p), p.stat().st_mode, - p.stat().st_uid, p.stat().st_gid] for p in (OLD, data, config)} + p.stat().st_uid, p.stat().st_gid] for p in protected_paths} def unchanged(): diff --git a/deploy/test-node-service-lifecycle.sh b/deploy/test-node-service-lifecycle.sh index 352a45a..50fbf82 100644 --- a/deploy/test-node-service-lifecycle.sh +++ b/deploy/test-node-service-lifecycle.sh @@ -26,6 +26,11 @@ cleanup() { trap cleanup EXIT cp -R "$DIR" "$JOB/deploy" cp "$DIR/fixtures/node-lifecycle/Dockerfile" "$JOB/Dockerfile" +( + cd "$DIR/.." + CGO_ENABLED=0 GOWORK=off GOFLAGS= GOENV=off GOAMD64=v1 \ + go build -o "$JOB/aperod-ring-index-maintenance" ./cmd/ring-index-maintenance +) docker build -t "$IMAGE" "$JOB" CONTAINER="$(docker run -d --privileged --cgroupns=private \ --tmpfs /run --tmpfs /run/lock "$IMAGE")" diff --git a/deploy/test-ring-index-update-gate.sh b/deploy/test-ring-index-update-gate.sh new file mode 100644 index 0000000..8581329 --- /dev/null +++ b/deploy/test-ring-index-update-gate.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +script="$(dirname "$0")/update-node.sh" +root=$(mktemp -d) +trap 'rm -rf "$root"' EXIT +# Extract exact production functions, substituting only the independent +# executable location in this synthetic fixture; never add an env bypass. +awk '/^_ring_index_gate\(\)/{on=1} on{print} /^_ring_index_start\(\)/{start=1} start&&/^}/{exit}' "$script" | + sed "s|/usr/local/libexec/aperod-ring-index-maintenance|$root/approved-tool|" >"$root/functions.sh" +source "$root/functions.sh" +BINARY_DST="$root/node"; SERVICE_NAME=synthetic-only +calls="$root/calls"; export calls +systemctl() { printf '%s\n' "$*" >>"$calls"; } +if _ring_index_start; then echo "missing helper accepted" >&2;exit 1;fi +[[ ! -e "$calls" ]] +cat >"$root/approved-tool" <<'EOF' +#!/usr/bin/env bash +exit 42 +EOF +chmod 755 "$root/approved-tool" +if _ring_index_start; then echo "unknown guard exit accepted" >&2;exit 1;fi +[[ ! -e "$calls" ]] +cat >"$root/approved-tool" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$calls" +exit 0 +EOF +_ring_index_start +grep -q '^--phase guard-update --candidate .* --current .* --config ' "$calls" +[[ "$(tail -1 "$calls")" == "start synthetic-only" ]] +# Every start path, including backup failure and rollback, must use wrapper. +[[ "$(grep -c 'systemctl start "${SERVICE_NAME}"' "$script")" == 1 ]] +grep -q '_ring_index_gate "${BINARY_SRC}" "${BINARY_DST}" || exit 1' "$script" +echo "PASS: missing/refused guards never start; all update/rollback starts gated" diff --git a/deploy/test_publish_public_repo.py b/deploy/test_publish_public_repo.py index c283124..6314e8c 100644 --- a/deploy/test_publish_public_repo.py +++ b/deploy/test_publish_public_repo.py @@ -18,6 +18,15 @@ def fixture(root): 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) @@ -108,7 +117,8 @@ def request(method, url, data=None): return {"check_runs": [ {"name": name, "status": "completed", "conclusion": "failure" if name == failed else "success", - "app": {"id": 15368}} + "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( + name, publisher.GITHUB_ACTIONS_APP_ID)}} for name in publisher.REQUIRED_CHECKS ]} @@ -118,7 +128,7 @@ def request(method, url, data=None): def test_failed_codeql_check_never_merges(self): methods = [] - failed = "Analyze (go)" + failed = "CodeQL" def request(method, url, data=None): methods.append(method) @@ -127,7 +137,8 @@ def request(method, url, data=None): return {"check_runs": [ {"name": name, "status": "completed", "conclusion": "failure" if name == failed else "success", - "app": {"id": 15368}} + "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( + name, publisher.GITHUB_ACTIONS_APP_ID)}} for name in publisher.REQUIRED_CHECKS ]} @@ -135,15 +146,120 @@ def request(method, url, data=None): publisher.await_merge({"number": 1, "html_url": "test"}, request) 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"}} + 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) + 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"}} + 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) + 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"}} + 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) + 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"}} - return {"check_runs": [{"name": name, "status": "completed", - "conclusion": "success", "app": {"id": 999}} - for name in ("Whole-tree publication security", "go build & vet & test")]} + 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"): @@ -159,7 +275,9 @@ def request(method, url, data=None): if url.endswith("/pulls/1"): return {"state": "open", "head": {"sha": "reviewed"}} return {"check_runs": [{"name": name, "status": "completed", - "conclusion": "success", "app": {"id": 15368}} + "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) self.assertEqual(bodies[0]["sha"], "reviewed") diff --git a/deploy/update-node.sh b/deploy/update-node.sh index 928215f..b8f5f80 100755 --- a/deploy/update-node.sh +++ b/deploy/update-node.sh @@ -915,6 +915,27 @@ node_source_baseline_guard "${BINARY_DST}" || { } source "${DEPLOY_DIR}/sale-upgrade-guard.sh" sale_upgrade_guard "${BINARY_SRC}" "${APEROD_CONFIG_FILE:-/etc/aperod/node.yaml}" || exit 1 + +# Only independently installed/approved root tooling may authorize index +# compatibility. Missing policy/tool or a legacy writer is not a warning. +# No checkout helper, runtime override or source manifest substitutes for it. +_ring_index_gate() { + local candidate="$1" current="$2" + local tool="/usr/local/libexec/aperod-ring-index-maintenance" + if [[ ! -x "$tool" ]]; then + echo "✗ Independently approved ring-index maintenance tool missing; node left unchanged." >&2 + return 1 + fi + "$tool" --phase guard-update --candidate "$candidate" --current "$current" \ + --config "${APEROD_CONFIG_FILE:-/etc/aperod/node.yaml}" || return 1 +} + +_ring_index_start() { + _ring_index_gate "${BINARY_DST}" "${BINARY_DST}" || return 1 + systemctl start "${SERVICE_NAME}" +} + +_ring_index_gate "${BINARY_SRC}" "${BINARY_DST}" || exit 1 systemctl stop "${SERVICE_NAME}" || true # non-fatal if already stopped # Wait up to 120 s for the service to fully stop before we try to replace the # binary. A single "sleep 1" is not enough: on shutdown the node flushes a @@ -968,11 +989,14 @@ if [[ -f "${BINARY_DST}" ]]; then echo "" echo "✗ Could not create backup at ${BINARY_BACKUP} — aborting install to protect the running service." >&2 echo " The service was stopped; restarting it now." >&2 - systemctl start "${SERVICE_NAME}" || true + local_restart_status="Оставлен остановленным: проверка совместимости или запуск не прошли." + if _ring_index_start; then + local_restart_status="Сервис перезапущен со старым совместимым бинарником." + fi send_telegram_alert "⚠️ aperod-node: установка ПРЕРВАНА Сервер: $(hostname) Не удалось создать резервную копию ${BINARY_BACKUP} (диск заполнен или ошибка прав). -Установка прервана; сервис перезапущен со старым бинарником. +Установка прервана. ${local_restart_status} Устраните проблему и запустите update-node.sh повторно." exit 1 fi @@ -998,13 +1022,13 @@ _rollback_install() { if [[ "${_backed_up}" == "true" && -f "${BINARY_BACKUP}" ]]; then if /bin/cp "${BINARY_BACKUP}" "${BINARY_DST}" && chmod +x "${BINARY_DST}"; then _restored=true - if systemctl start "${SERVICE_NAME}" 2>/dev/null; then + if _ring_index_start 2>/dev/null; then _restarted=true echo " Rolled back to previous binary; service restarted." >&2 echo " Service state: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)" >&2 else echo " Old binary restored but 'systemctl start' failed — service may be down." >&2 - echo " Manual recovery: systemctl start ${SERVICE_NAME}" >&2 + echo " Keep stopped; complete independently approved offline index recovery before restart." >&2 fi else echo " Rollback copy also failed — service remains stopped." >&2 @@ -1040,7 +1064,7 @@ fi echo " Installed: $(${BINARY_DST} --version 2>/dev/null || ls -lh "${BINARY_DST}" | awk '{print $5, $9}')" # Start the service after the new binary is in place. -if ! systemctl start "${SERVICE_NAME}"; then +if ! _ring_index_start; then _rollback_install exit 1 fi diff --git a/p2p/gossip.go b/p2p/gossip.go index 9259db9..1221572 100644 --- a/p2p/gossip.go +++ b/p2p/gossip.go @@ -1,6 +1,7 @@ package p2p import ( + "container/list" "sync" "time" @@ -13,21 +14,23 @@ const ( gossipCacheTTL = 5 * time.Minute ) -// gossipEntry records when a hash was first seen for TTL-based eviction. +// gossipEntry records a hash and its insertion time in FIFO order. type gossipEntry struct { + hash crypto.Hash32 at time.Time } // GossipFilter is a bounded seen-set that prevents a block or transaction from // being relayed more than once. Thread-safe. type GossipFilter struct { - mu sync.Mutex - seen map[crypto.Hash32]gossipEntry + mu sync.Mutex + seen map[crypto.Hash32]*list.Element + order list.List } // NewGossipFilter creates an empty GossipFilter. func NewGossipFilter() *GossipFilter { - return &GossipFilter{seen: make(map[crypto.Hash32]gossipEntry)} + return &GossipFilter{seen: make(map[crypto.Hash32]*list.Element)} } // MarkAndCheck returns true if h was NOT seen before (new message) and marks @@ -36,14 +39,16 @@ func (gf *GossipFilter) MarkAndCheck(h crypto.Hash32) bool { gf.mu.Lock() defer gf.mu.Unlock() + now := time.Now() + gf.evictExpiredLocked(now) if _, exists := gf.seen[h]; exists { return false } - // Evict stale entries before inserting to keep memory bounded. if len(gf.seen) >= gossipCacheSize { - gf.evictLocked() + gf.evictOldestLocked() } - gf.seen[h] = gossipEntry{at: time.Now()} + element := gf.order.PushBack(gossipEntry{hash: h, at: now}) + gf.seen[h] = element return true } @@ -51,6 +56,7 @@ func (gf *GossipFilter) MarkAndCheck(h crypto.Hash32) bool { func (gf *GossipFilter) HasSeen(h crypto.Hash32) bool { gf.mu.Lock() defer gf.mu.Unlock() + gf.evictExpiredLocked(time.Now()) _, ok := gf.seen[h] return ok } @@ -62,17 +68,32 @@ func (gf *GossipFilter) Size() int { return len(gf.seen) } -// evictLocked removes all entries older than gossipCacheTTL. +// evictExpiredLocked removes expired entries from the FIFO head. // Caller must hold gf.mu. -func (gf *GossipFilter) evictLocked() { - cutoff := time.Now().Add(-gossipCacheTTL) - for h, e := range gf.seen { - if e.at.Before(cutoff) { - delete(gf.seen, h) +func (gf *GossipFilter) evictExpiredLocked(now time.Time) { + cutoff := now.Add(-gossipCacheTTL) + for front := gf.order.Front(); front != nil; front = gf.order.Front() { + entry := front.Value.(gossipEntry) + if !entry.at.Before(cutoff) { + return } + delete(gf.seen, entry.hash) + gf.order.Remove(front) } } +// evictOldestLocked removes exactly one oldest entry. +// Caller must hold gf.mu. +func (gf *GossipFilter) evictOldestLocked() { + front := gf.order.Front() + if front == nil { + return + } + entry := front.Value.(gossipEntry) + delete(gf.seen, entry.hash) + gf.order.Remove(front) +} + // ─── Gossip ─────────────────────────────────────────────────────────────────── // Gossip implements flood-fill propagation for blocks and transactions. diff --git a/p2p/gossip_filter_test.go b/p2p/gossip_filter_test.go new file mode 100644 index 0000000..53002f5 --- /dev/null +++ b/p2p/gossip_filter_test.go @@ -0,0 +1,73 @@ +package p2p + +import ( + "testing" + "time" + + "github.com/aperod/aperod/crypto" +) + +func gossipTestHash(value uint64) crypto.Hash32 { + var hash crypto.Hash32 + for i := range 8 { + hash[i] = byte(value >> (8 * i)) + } + return hash +} + +func TestGossipFilterHardBoundEvictsOldest(t *testing.T) { + filter := NewGossipFilter() + oldest := gossipTestHash(0) + + for i := uint64(0); i < gossipCacheSize+256; i++ { + if !filter.MarkAndCheck(gossipTestHash(i)) { + t.Fatalf("new hash %d was marked as a duplicate", i) + } + if got := filter.Size(); got > gossipCacheSize { + t.Fatalf("cache grew to %d entries, hard limit is %d", got, gossipCacheSize) + } + } + if got := filter.Size(); got != gossipCacheSize { + t.Fatalf("cache size = %d, want %d", got, gossipCacheSize) + } + if filter.HasSeen(oldest) { + t.Fatal("oldest entry should have been evicted") + } + if !filter.HasSeen(gossipTestHash(gossipCacheSize + 255)) { + t.Fatal("newest entry should still be retained") + } + if !filter.MarkAndCheck(oldest) { + t.Fatal("evicted hash should be accepted as new") + } + if got := filter.Size(); got != gossipCacheSize { + t.Fatalf("cache size after replacement = %d, want %d", got, gossipCacheSize) + } +} + +func TestGossipFilterExpiresEntriesFromOldestFirst(t *testing.T) { + filter := NewGossipFilter() + expired := gossipTestHash(1) + live := gossipTestHash(2) + filter.MarkAndCheck(expired) + filter.MarkAndCheck(live) + + filter.mu.Lock() + filter.order.Front().Value = gossipEntry{ + hash: expired, + at: time.Now().Add(-gossipCacheTTL - time.Second), + } + filter.mu.Unlock() + + if filter.HasSeen(expired) { + t.Fatal("expired entry should be removed when checking the filter") + } + if !filter.HasSeen(live) { + t.Fatal("live entry should remain after expiration cleanup") + } + if !filter.MarkAndCheck(expired) { + t.Fatal("expired hash should be accepted again") + } + if got := filter.Size(); got != 2 { + t.Fatalf("cache size = %d, want 2", got) + } +} diff --git a/store/leveldb.go b/store/leveldb.go index e3949cb..6ca65a9 100644 --- a/store/leveldb.go +++ b/store/leveldb.go @@ -42,6 +42,7 @@ ringMu sync.Mutex // serializes canonical rm/ and dense sampling-index writ // Open opens or creates a LevelDB database at path. func Open(path string) (*DB, error) { + if err := CheckRingMaintenanceFence(path); err != nil { return nil, err } opts := &opt.Options{ BlockCacheCapacity: 32 * opt.MiB, WriteBuffer: 16 * opt.MiB, @@ -57,6 +58,7 @@ func Open(path string) (*DB, error) { // OpenReadOnly opens an existing LevelDB without recovery, compaction or writes. // The caller must close the returned handle. func OpenReadOnly(path string) (*DB, error) { + if err := CheckRingMaintenanceFence(path); err != nil { return nil, err } opts := &opt.Options{ReadOnly: true, ErrorIfMissing: true} db, err := leveldb.OpenFile(path, opts) if err != nil { diff --git a/store/lpod_wallet_index.go b/store/lpod_wallet_index.go index de4ad69..e5ca79d 100644 --- a/store/lpod_wallet_index.go +++ b/store/lpod_wallet_index.go @@ -405,5 +405,8 @@ func (d *DB) LPoDEarningsOutputs(address crypto.Address, cursor string, limit in if !ok { next = "" } - return rows, next, iter.Error() + if err := iter.Error(); err != nil { + return nil, "", err + } + return rows, next, nil } diff --git a/store/ring_maintenance_fence.go b/store/ring_maintenance_fence.go new file mode 100644 index 0000000..9ef97d3 --- /dev/null +++ b/store/ring_maintenance_fence.go @@ -0,0 +1,480 @@ +package store + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +// RingMaintenanceStateDirectory must be independently installed root-owned, +// non-writable by services, and searchable by readers. It is outside imported +// DB generations, so an old backup cannot restore a stale readiness marker over +// the maintenance fence. This is not installed as part of a node update. +const RingMaintenanceStateDirectory = "/var/lib/aperod-ring-maintenance" + +func protectedRingMaintenanceRoot(root string, owner uint32) error { + for path := root; ; path = filepath.Dir(path) { + dir, err := os.Lstat(path) + if err != nil { + return err + } + st, ok := dir.Sys().(*syscall.Stat_t) + if !ok || !dir.IsDir() || st.Uid != owner || dir.Mode().Perm()&0022 != 0 { + return fmt.Errorf("ring maintenance: protected non-symlink state directory required") + } + if root == RingMaintenanceStateDirectory && dir.Mode().Perm()&0001 == 0 { + return fmt.Errorf("ring maintenance: state directory must remain searchable by readers") + } + // Production's fixed root has no service-owned ancestor. Private test + // directories are capability fixtures, not configurable runtime roots. + if root != RingMaintenanceStateDirectory || path == "/" { + return nil + } + } +} + +func ringMaintenanceIdentity(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + real, err := filepath.EvalSymlinks(absolute) + if errors.Is(err, os.ErrNotExist) { + // Preserve normal Open's ability to create nested directories. + parent, suffix := filepath.Dir(absolute), filepath.Base(absolute) + for { + resolved, parentErr := filepath.EvalSymlinks(parent) + if parentErr == nil { + real, err = filepath.Join(resolved, suffix), nil + break + } + if !errors.Is(parentErr, os.ErrNotExist) || parent == "/" { + return "", parentErr + } + suffix = filepath.Join(filepath.Base(parent), suffix) + parent = filepath.Dir(parent) + } + } + if err != nil { + return "", err + } + return real, nil +} + +func ringMaintenanceStatePath(path, root string) (string, error) { + identity, err := ringMaintenanceIdentity(path) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(identity)) + return filepath.Join(root, hex.EncodeToString(digest[:])+".active"), nil +} + +// RingMaintenanceControlPath returns fixed-root control identities for the +// reviewed coordinator and its independently approved systemd drop-ins. +func RingMaintenanceControlPath(path, kind string) (string, error) { + state, err := ringMaintenanceStatePath(path, RingMaintenanceStateDirectory) + if err != nil { + return "", err + } + switch kind { + case "active", "quiesced", "legacy-authorized", "journal.json", "receipt.json": + return state[:len(state)-len("active")] + kind, nil + default: + return "", fmt.Errorf("ring maintenance: unsupported control kind") + } +} + +func checkRingMaintenanceFenceAt(path, root string) error { + // Check lexical scope first. Redirecting an imported path to a different + // symlink target must not hide the original externally fenced identity. + absolute, err := filepath.Abs(path) + if err != nil { + return err + } + digest := sha256.Sum256([]byte(absolute)) + if err := checkRingScopeFiles(filepath.Join(root, hex.EncodeToString(digest[:])+".active")); err != nil { + return err + } + state, err := ringMaintenanceStatePath(path, root) + if err != nil { + return err + } + return checkRingScopeFiles(state) +} + +func checkRingScopeFiles(state string) error { + if err := checkRingStateFile(state); err != nil { + return err + } + return checkRingStateFile(state[:len(state)-len("active")] + "quiesced") +} + +func checkRingStateFile(state string) error { + _, err := os.Lstat(state) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("ring maintenance: cannot check fence: %w", err) + } + return fmt.Errorf("ring maintenance: unfinished offline operation; writer/sampling access refused") +} + +// CheckRingMaintenanceFence is a read-only startup guard. Any marker (including +// a corrupt file or symlink) blocks access. Permissions/errors fail closed. +func CheckRingMaintenanceFence(path string) error { + if _, err := os.Lstat(RingMaintenanceStateDirectory); err == nil { + if err := protectedRingMaintenanceRoot(RingMaintenanceStateDirectory, 0); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return checkRingMaintenanceFenceAt(path, RingMaintenanceStateDirectory) +} + +// RingMaintenanceLease serializes approved maintenance controllers. Closing it +// never removes the fence. Only an audited rebuild can commit it away. +// Approval, service masking/quiescence and coherent full backup remain +// mandatory responsibilities of the independently approved coordinator. +type RingMaintenanceLease struct { + path string + state string + lock *os.File + verified bool + generation string +} + +func acquireRingMaintenanceAt(path, root string, owner uint32) (*RingMaintenanceLease, error) { + identity, err := ringMaintenanceIdentity(path) + if err != nil { + return nil, err + } + absolute, err := filepath.Abs(path) + if err != nil || identity != absolute || identity == "/" { + return nil, fmt.Errorf("ring maintenance: canonical non-symlink database path required") + } + info, err := os.Lstat(identity) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("ring maintenance: existing database directory required") + } + if err := protectedRingMaintenanceRoot(root, owner); err != nil { + return nil, err + } + state, err := ringMaintenanceStatePath(identity, root) + if err != nil { + return nil, err + } + fd, err := syscall.Open(state+".lock", syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), state+".lock") + fail := func(err error) (*RingMaintenanceLease, error) { file.Close(); return nil, err } + li, err := file.Stat() + if err != nil { + return fail(err) + } + ls, ok := li.Sys().(*syscall.Stat_t) + if !ok || !li.Mode().IsRegular() || ls.Uid != owner || li.Mode().Perm()&0077 != 0 || ls.Nlink != 1 { + return fail(fmt.Errorf("ring maintenance: unsafe lease file")) + } + if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fail(fmt.Errorf("ring maintenance: another controller owns lease: %w", err)) + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return fail(err) + } + return &RingMaintenanceLease{path: identity, state: state, lock: file, generation: hex.EncodeToString(nonce[:])}, nil +} + +// RingMaintenanceWorkerReceipt is accepted only by the trusted root coordinator +// after its exact approved unprivileged worker exited successfully. The nonce +// prevents cross-controller receipt reuse; the inherited root-owned FD is the +// worker's authority. A nonce/JSON supplied by a wallet is never authority. +type RingMaintenanceWorkerReceipt struct { + Schema int `json:"schema"` + DBPath string `json:"db_path"` + Generation string `json:"generation"` + Inventory RingSamplingInventory `json:"inventory"` +} + +func (l *RingMaintenanceLease) WorkerGeneration() string { + if l == nil { + return "" + } + return l.generation +} + +func (l *RingMaintenanceLease) WorkerFile() (*os.File, error) { + if err := l.check(); err != nil { + return nil, err + } + fd, err := syscall.Dup(int(l.lock.Fd())) + if err != nil { + return nil, err + } + syscall.CloseOnExec(fd) + return os.NewFile(uintptr(fd), "ring-maintenance-lease"), nil +} + +func borrowRingMaintenanceAt(path, root, generation string, file *os.File, owner uint32) (*RingMaintenanceLease, error) { + if file == nil { + return nil, fmt.Errorf("ring maintenance: inherited authority FD required") + } + if err := protectedRingMaintenanceRoot(root, owner); err != nil { + return nil, err + } + identity, err := ringMaintenanceIdentity(path) + if err != nil { + return nil, err + } + absolute, err := filepath.Abs(path) + if err != nil || identity != absolute { + return nil, fmt.Errorf("ring maintenance: noncanonical worker scope") + } + state, err := ringMaintenanceStatePath(identity, root) + if err != nil { + return nil, err + } + actual, err := file.Stat() + if err != nil { + return nil, err + } + expected, err := os.Lstat(state + ".lock") + if err != nil { + return nil, err + } + st, ok := actual.Sys().(*syscall.Stat_t) + if !ok || st.Uid != owner || !actual.Mode().IsRegular() || actual.Mode().Perm()&0077 != 0 || st.Nlink != 1 || !os.SameFile(actual, expected) { + return nil, fmt.Errorf("ring maintenance: forged/mismatched worker authority") + } + 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 { + return nil, err + } + return &RingMaintenanceLease{path: identity, state: state, lock: file, generation: generation}, nil +} + +// BorrowRingMaintenanceLease only accepts the inherited capability for the +// fixed protected state directory. It does not accept a runtime state-root +// override. Workers run as the data owner, never as root. +func BorrowRingMaintenanceLease(path, generation string, file *os.File) (*RingMaintenanceLease, error) { + return borrowRingMaintenanceAt(path, RingMaintenanceStateDirectory, generation, file, 0) +} + +// OpenReadOnly allows an approved unprivileged worker to hold the native +// LevelDB shared LOCK while its root coordinator copies the quiesced full data +// directory. It neither repairs nor initializes a missing database. +func (l *RingMaintenanceLease) OpenReadOnly() (*DB, error) { + if err := l.check(); err != nil { + return nil, err + } + info, err := os.Lstat(l.path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("ring maintenance: database directory required") + } + for _, name := range []string{"LOCK", "CURRENT"} { + file, err := os.Lstat(filepath.Join(l.path, name)) + if err != nil { + return nil, err + } + if !file.Mode().IsRegular() { + return nil, fmt.Errorf("ring maintenance: existing regular database control files required") + } + } + raw, err := leveldb.OpenFile(l.path, &opt.Options{ReadOnly: true, ErrorIfMissing: true}) + if err != nil { + return nil, err + } + return &DB{db: raw, path: l.path}, nil +} + +func (l *RingMaintenanceLease) commitVerifiedWorkerAt(receipt RingMaintenanceWorkerReceipt, coordinatorUID int) error { + if os.Geteuid() != coordinatorUID { + return fmt.Errorf("ring maintenance: root coordinator required") + } + if err := l.check(); err != nil { + return err + } + i := receipt.Inventory + digest, err := hex.DecodeString(i.CanonicalSHA256) + if receipt.Schema != 1 || receipt.DBPath != l.path || receipt.Generation != l.generation || err != nil || len(digest) != 32 || i.Eligible > i.Canonical || i.Locked != i.Canonical-i.Eligible { + return fmt.Errorf("ring maintenance: mismatched/incomplete verified worker receipt") + } + l.verified = true + return l.Commit() +} + +// CommitVerifiedWorker is a trusted coordinator boundary, not a receipt +// verification service. Caller must have just waited for its separately pinned +// worker's success and strictly decoded that worker's sole stdout receipt. +func (l *RingMaintenanceLease) CommitVerifiedWorker(receipt RingMaintenanceWorkerReceipt) error { + return l.commitVerifiedWorkerAt(receipt, 0) +} + +// AcquireRingMaintenance requires an independently prepared trusted directory. +// Production callers must authenticate their separate tool approval first. +func AcquireRingMaintenance(path string) (*RingMaintenanceLease, error) { + return acquireRingMaintenanceAt(path, RingMaintenanceStateDirectory, 0) +} + +func (l *RingMaintenanceLease) check() error { + if l == nil || l.lock == nil { + return fmt.Errorf("ring maintenance: no active lease") + } + identity, err := ringMaintenanceIdentity(l.path) + if err != nil || identity != l.path { + return fmt.Errorf("ring maintenance: database path identity changed: %w", err) + } + return nil +} + +func syncRingStateDirectory(path string) error { + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// Fence publishes a durable external barrier BEFORE permitting a legacy writer, +// restore/import or repair. An already-present marker is a recovery case; it is +// not silently overwritten or treated as a completed operation. +func (l *RingMaintenanceLease) Fence() error { + if err := l.check(); err != nil { + return err + } + fd, err := syscall.Open(l.state, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) + if err != nil { + return fmt.Errorf("ring maintenance: fence already present or cannot create: %w", err) + } + f := os.NewFile(uintptr(fd), l.state) + _, writeErr := f.WriteString(l.path + "\n") + syncErr := f.Sync() + closeErr := f.Close() + return errors.Join(writeErr, syncErr, closeErr, syncRingStateDirectory(l.state)) +} + +func (l *RingMaintenanceLease) existingFencedDB() (*DB, error) { + if err := l.check(); err != nil { + return nil, err + } + fi, err := os.Lstat(l.state) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("ring maintenance: invalid durable fence") + } + // OpenFile(ErrorIfMissing) still creates its filesystem directory before + // detecting an absent CURRENT. Never initialize a removed import target. + fi, err = os.Lstat(l.path) + if err != nil { + return nil, err + } + if !fi.IsDir() { + return nil, fmt.Errorf("ring maintenance: database directory required") + } + db, err := leveldb.OpenFile(l.path, &opt.Options{ErrorIfMissing: true, BlockCacheCapacity: 32 * opt.MiB, WriteBuffer: 16 * opt.MiB, CompactionTableSize: 4 * opt.MiB}) + if err != nil { + return nil, err + } + return &DB{db: db, path: l.path}, nil +} + +// Invalidate fsync-deletes readiness while preserving canonical/spent records. +// The exclusive LevelDB lock also rejects a writer that was not quiesced. +func (l *RingMaintenanceLease) Invalidate() error { + if err := l.check(); err != nil { + return err + } + l.verified = false + db, err := l.existingFencedDB() + if err != nil { + return err + } + return errors.Join(db.InvalidateRingSamplingIndex(), db.Close()) +} + +// RebuildAndVerify distrusts even a ready marker imported from an old backup. +// No marker or receipt supplied by the importer skips this full streaming audit. +func (l *RingMaintenanceLease) RebuildAndVerify() (RingSamplingInventory, error) { + var out RingSamplingInventory + if err := l.check(); err != nil { + return out, err + } + l.verified = false + db, err := l.existingFencedDB() + if err != nil { + return out, err + } + err = db.InvalidateRingSamplingIndex() + if err == nil { + err = db.verifyLegacyRingBackfillPreservesCanonical() + } + if err == nil { + err = db.EnsureRingMemberIndex() + } + if err == nil { + out, err = db.VerifyRingSamplingIndex() + } + if err != nil { + err = errors.Join(err, db.InvalidateRingSamplingIndex()) + } + err = errors.Join(err, db.Close()) + if err == nil { + l.verified = true + } + return out, err +} + +// Commit is legal only after verified rebuild and clean DB close. The caller +// must keep ALL client/writer services masked through this commit; masking is +// not delegated to a database API. A failed commit leaves services quiesced. +func (l *RingMaintenanceLease) Commit() error { + if err := l.check(); err != nil { + return err + } + if !l.verified { + return fmt.Errorf("ring maintenance: verified rebuild required") + } + if err := os.Remove(l.state); err != nil { + return err + } + if err := syncRingStateDirectory(l.state); err != nil { + l.verified = false + return errors.Join(err, l.Fence()) + } + l.verified = false + return nil +} + +func (l *RingMaintenanceLease) Close() error { + if l == nil || l.lock == nil { + return nil + } + err := l.lock.Close() + l.lock = nil + l.verified = false + return err +} diff --git a/store/ring_maintenance_fence_test.go b/store/ring_maintenance_fence_test.go new file mode 100644 index 0000000..60d08d8 --- /dev/null +++ b/store/ring_maintenance_fence_test.go @@ -0,0 +1,461 @@ +package store + +import ( + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +func copyMaintenanceDB(t *testing.T, source, dest string) { + t.Helper() + if err := os.Mkdir(dest, 0700); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(source) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !entry.Type().IsRegular() { + t.Fatal("synthetic fixture expected only native DB files") + } + in, err := os.Open(filepath.Join(source, entry.Name())) + if err != nil { + t.Fatal(err) + } + out, err := os.OpenFile(filepath.Join(dest, entry.Name()), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + in.Close() + t.Fatal(err) + } + _, err = io.Copy(out, in) + in.Close() + if err != nil { + out.Close() + t.Fatal(err) + } + if err := out.Sync(); err != nil { + out.Close() + t.Fatal(err) + } + out.Close() + } +} + +func TestRingMaintenancePhysicalOlderGenerationImport(t *testing.T) { + path, state := maintenanceFixture(t) + backup := filepath.Join(t.TempDir(), "old-chain.db") + copyMaintenanceDB(t, path, backup) + l := fixtureLease(t, path, state) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err != nil { + t.Fatal(err) + } + legacyPutMember(t, path, 9, false) + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + copyMaintenanceDB(t, backup, path) // physically restores old valid ready count + legacyPutMember(t, path, 8, false) // canonical change invisible to old ordinals + l.Close() + if err := checkRingMaintenanceFenceAt(path, state); err == nil { + t.Fatal("older generation hid external fence") + } + l = fixtureLease(t, path, state) + inventory, err := l.RebuildAndVerify() + if err != nil { + t.Fatal(err) + } + if inventory.Eligible != 5 { + t.Fatal("restored marker incorrectly trusted") + } + if err := l.Commit(); err != nil { + t.Fatal(err) + } + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.VerifyRingSamplingIndex(); err != nil { + t.Fatal(err) + } +} + +func TestRingMaintenanceBorrowedWorkerAndReceiptFencing(t *testing.T) { + path, state := maintenanceFixture(t) + l := fixtureLease(t, path, state) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + fd, err := l.WorkerFile() + if err != nil { + t.Fatal(err) + } + borrowed, err := borrowRingMaintenanceAt(path, state, l.WorkerGeneration(), fd, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + if err := borrowed.Invalidate(); err != nil { + t.Fatal(err) + } + inventory, err := borrowed.RebuildAndVerify() + if err != nil { + t.Fatal(err) + } + borrowed.Close() + receipt := RingMaintenanceWorkerReceipt{Schema: 1, DBPath: path, Generation: l.WorkerGeneration(), Inventory: inventory} + stale := receipt + stale.Generation = "stale" + if err := l.commitVerifiedWorkerAt(stale, os.Geteuid()); err == nil { + t.Fatal("stale worker receipt accepted") + } + if err := checkRingMaintenanceFenceAt(path, state); err == nil { + t.Fatal("failed receipt released fence") + } + if err := l.commitVerifiedWorkerAt(receipt, os.Geteuid()); err != nil { + t.Fatal(err) + } + if os.Geteuid() != 0 && l.CommitVerifiedWorker(receipt) == nil { + t.Fatal("unprivileged worker allowed root commit") + } +} + +func TestRingMaintenanceRejectsCounterfeitLeaseDescriptor(t *testing.T) { + path, state := maintenanceFixture(t) + l := fixtureLease(t, path, state) + fake, err := os.OpenFile(filepath.Join(state, "other.lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer fake.Close() + if borrowed, err := borrowRingMaintenanceAt(path, state, l.WorkerGeneration(), fake, uint32(os.Geteuid())); err == nil { + borrowed.Close() + t.Fatal("unrelated descriptor authorized maintenance") + } + other, _ := maintenanceFixture(t) + fd, err := l.WorkerFile() + if err != nil { + t.Fatal(err) + } + defer fd.Close() + if borrowed, err := borrowRingMaintenanceAt(other, state, l.WorkerGeneration(), fd, uint32(os.Geteuid())); err == nil { + borrowed.Close() + t.Fatal("lease authorized different database") + } +} + +func TestRingMaintenanceRejectsConflictingLegacyBackfill(t *testing.T) { + path, state := maintenanceFixture(t) + l := fixtureLease(t, path, state) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err != nil { + t.Fatal(err) + } + db, err := l.existingFencedDB() + if err != nil { + t.Fatal(err) + } + if err := db.db.Delete(append(append([]byte{}, prefixMeta...), []byte("ring-member-index-v1")...), &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } + u := sparseRingOutput(1) + u.ProtocolLocked = true + raw, _ := json.Marshal(u) + if err := db.db.Put(ringMemberKey(u.OneTimePub), raw, &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } + db.Close() + if _, err := l.RebuildAndVerify(); err == nil { + t.Fatal("legacy backfill overwrote canonical repair") + } + db, err = l.existingFencedDB() + if err != nil { + t.Fatal(err) + } + defer db.Close() + got, err := db.db.Get(ringMemberKey(u.OneTimePub), nil) + if err != nil || string(got) != string(raw) { + t.Fatal("canonical repair lost on failed rebuild") + } +} + +func TestRingMaintenanceReadOnlyNeverCreatesMissingLock(t *testing.T) { + path, state := maintenanceFixture(t) + l := fixtureLease(t, path, state) + lock := filepath.Join(path, "LOCK") + if err := os.Remove(lock); err != nil { + t.Fatal(err) + } + if db, err := l.OpenReadOnly(); err == nil { + db.Close() + t.Fatal("missing native lock accepted") + } + if _, err := os.Lstat(lock); !os.IsNotExist(err) { + t.Fatal("read-only probe initialized a lock") + } +} + +func maintenanceFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + dbPath, state := filepath.Join(root, "chain.db"), filepath.Join(root, "control") + if err := os.Mkdir(state, 0700); err != nil { + t.Fatal(err) + } + db, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + for i := byte(1); i <= 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + if err := db.db.Put(append(append([]byte{}, prefixSpentUTXO...), make([]byte, 36)...), []byte{1}, &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return dbPath, state +} + +func fixtureLease(t *testing.T, path, root string) *RingMaintenanceLease { + t.Helper() + l, err := acquireRingMaintenanceAt(path, root, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + return l +} + +func legacyPutMember(t *testing.T, path string, id byte, locked bool) { + t.Helper() + db, err := leveldb.OpenFile(path, &opt.Options{ErrorIfMissing: true}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + u := sparseRingOutput(id) + u.ProtocolLocked = locked + raw, err := json.Marshal(u) + if err != nil { + t.Fatal(err) + } + if err := db.Put(ringMemberKey(u.OneTimePub), raw, &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } +} + +func TestRingMaintenanceDowngradeRepairAndRetainedSpent(t *testing.T) { + path, root := maintenanceFixture(t) + l := fixtureLease(t, path, root) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err != nil { + t.Fatal(err) + } + if err := checkRingMaintenanceFenceAt(path, root); err == nil { + t.Fatal("fence ignored") + } + if err := l.Commit(); err == nil { + t.Fatal("unaudited commit") + } + if other, err := acquireRingMaintenanceAt(path, root, uint32(os.Geteuid())); err == nil { + other.Close() + t.Fatal("mixed controllers") + } + legacyPutMember(t, path, 7, false) + legacyPutMember(t, path, 1, true) + out, err := l.RebuildAndVerify() + if err != nil || out.Canonical != 5 || out.Eligible != 4 || out.Locked != 1 { + t.Fatal(out, err) + } + db, err := l.existingFencedDB() + if err != nil { + t.Fatal(err) + } + spent := db.db.NewIterator(nil, nil) + count := 0 + for spent.Next() { + if len(spent.Key()) >= len(prefixSpentUTXO) && string(spent.Key()[:len(prefixSpentUTXO)]) == string(prefixSpentUTXO) { + count++ + } + } + if err := spent.Error(); err != nil { + t.Fatal(err) + } + spent.Release() + db.Close() + if count != 1 { + t.Fatal("spent anonymity marker lost") + } + if err := l.Commit(); err != nil { + t.Fatal(err) + } + if err := checkRingMaintenanceFenceAt(path, root); err != nil { + t.Fatal(err) + } +} + +func TestRingMaintenanceRestoreReadyMarkerAndFailureRemainFenced(t *testing.T) { + path, root := maintenanceFixture(t) + l := fixtureLease(t, path, root) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err != nil { + t.Fatal(err) + } + // Import an older ready-generation marker plus an rm/-only repair. + raw, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + if err := raw.Put(ringSamplingCountKey, ringSamplingUint(4), &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } + raw.Close() + legacyPutMember(t, path, 8, false) + if err := checkRingMaintenanceFenceAt(path, root); err == nil { + t.Fatal("imported ready marker bypassed external fence") + } + out, err := l.RebuildAndVerify() + if err != nil || out.Eligible != 5 { + t.Fatal(out, err) + } + // A corrupt canonical repair must revoke readiness and never release. + raw, err = leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + if err := raw.Put(ringMemberKey(sparseRingOutput(9).OneTimePub), []byte("{broken"), nil); err != nil { + t.Fatal(err) + } + raw.Close() + if _, err := l.RebuildAndVerify(); err == nil { + t.Fatal("bad repair accepted") + } + if err := l.Commit(); err == nil { + t.Fatal("failed repair committed") + } + if err := checkRingMaintenanceFenceAt(path, root); err == nil { + t.Fatal("failed repair unlocked") + } + raw, err = leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + if _, ready, err := ringSamplingCount(raw); err != nil || ready { + t.Fatal("failed repair left ready", err) + } + raw.Close() +} + +func TestRingMaintenanceMissingBusyAndRedirectedDatabase(t *testing.T) { + path, root := maintenanceFixture(t) + l := fixtureLease(t, path, root) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + writer, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err == nil { + t.Fatal("active legacy writer not rejected") + } + writer.Close() + moved := path + ".held" + if err := os.Rename(path, moved); err != nil { + t.Fatal(err) + } + if _, err := l.RebuildAndVerify(); err == nil { + t.Fatal("missing DB initialized") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("missing DB was created", err) + } + if err := os.Symlink(moved, path); err != nil { + t.Fatal(err) + } + if err := checkRingMaintenanceFenceAt(path, root); err == nil { + t.Fatal("symlink redirect hid fence") + } + if _, err := l.RebuildAndVerify(); err == nil { + t.Fatal("redirected database accepted") + } +} + +func TestRingMaintenanceCrashReopen(t *testing.T) { + if os.Getenv("APEROD_TEST_RING_MAINTENANCE_CRASH") == "1" { + path, root := os.Getenv("APEROD_TEST_RING_DB"), os.Getenv("APEROD_TEST_RING_STATE") + // Child is only a test executable and requires its fixture tag. + if marker, err := os.ReadFile(filepath.Join(root, "synthetic-only")); err != nil || string(marker) != "synthetic\n" { + t.Fatal("fixture tag required") + } + l := fixtureLease(t, path, root) + if err := l.Fence(); err != nil { + t.Fatal(err) + } + if err := l.Invalidate(); err != nil { + t.Fatal(err) + } + legacyPutMember(t, path, 10, false) + os.Exit(73) + } + path, root := maintenanceFixture(t) + if err := os.WriteFile(filepath.Join(root, "synthetic-only"), []byte("synthetic\n"), 0600); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestRingMaintenanceCrashReopen$") + child.Env = append(os.Environ(), "APEROD_TEST_RING_MAINTENANCE_CRASH=1", "APEROD_TEST_RING_DB="+path, "APEROD_TEST_RING_STATE="+root) + err := child.Run() + exited, ok := err.(*exec.ExitError) + if !ok || exited.ExitCode() != 73 { + t.Fatal("wrong crash outcome", err) + } + if err := checkRingMaintenanceFenceAt(path, root); err == nil { + t.Fatal("crash lost external fence") + } + l := fixtureLease(t, path, root) + if err := l.Commit(); err == nil { + t.Fatal("crash recovery trusted old marker") + } + out, err := l.RebuildAndVerify() + if err != nil || out.Eligible != 5 { + t.Fatal(out, err) + } + if err := l.Commit(); err != nil { + t.Fatal(err) + } +} + +func TestRingMaintenanceLeaseRejectsUnsafeControlRoot(t *testing.T) { + path, root := maintenanceFixture(t) + if err := os.Chmod(root, 0777); err != nil { + t.Fatal(err) + } + if l, err := acquireRingMaintenanceAt(path, root, uint32(os.Geteuid())); err == nil { + l.Close() + t.Fatal("writable control root accepted") + } + var l *RingMaintenanceLease + if err := l.Invalidate(); err == nil { + t.Fatal("nil lease accepted") + } +} diff --git a/store/ring_sampling_disk_metrics_test.go b/store/ring_sampling_disk_metrics_test.go new file mode 100644 index 0000000..8742187 --- /dev/null +++ b/store/ring_sampling_disk_metrics_test.go @@ -0,0 +1,239 @@ +//go:build linux + +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/syndtr/goleveldb/leveldb" + "golang.org/x/sys/unix" +) + +type diskResources struct { + RSSBytes uint64 `json:"rss_bytes"` + PeakRSSBytes uint64 `json:"peak_rss_bytes"` +} + +func diskResourceSnapshot() (diskResources, error) { + var r diskResources + for _, input := range []struct { + path string + dest map[string]*uint64 + }{ + {"/proc/self/status", map[string]*uint64{"VmRSS:": &r.RSSBytes, "VmHWM:": &r.PeakRSSBytes}}, + } { + raw, err := os.ReadFile(input.path) + if err != nil { + return r, err + } + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + dest := input.dest[fields[0]] + if dest == nil { + continue + } + value, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return r, err + } + if len(fields) > 2 && fields[2] == "kB" { + value *= 1024 + } + *dest = value + } + } + return r, nil +} + +type diskPhase struct { + Name string `json:"name"` + Seconds float64 `json:"seconds"` + Calls int `json:"calls,omitempty"` + P50MS float64 `json:"p50_ms,omitempty"` + P95MS float64 `json:"p95_ms,omitempty"` + P99MS float64 `json:"p99_ms,omitempty"` + MaxMS float64 `json:"max_ms,omitempty"` + Start diskResources `json:"start"` + End diskResources `json:"end"` + ObservedRSSMax uint64 `json:"observed_rss_max_bytes"` + BackendReadBytes uint64 `json:"leveldb_file_read_bytes_delta"` + BackendWriteBytes uint64 `json:"leveldb_file_write_bytes_delta"` + BackendIOAvailable bool `json:"phase_leveldb_io_available"` + SampleReadBytes uint64 `json:"sample_only_leveldb_file_read_bytes,omitempty"` + SampleReadCalls int `json:"samples_with_leveldb_file_reads,omitempty"` + ApparentBytes int64 `json:"db_apparent_bytes"` + AllocatedBytes int64 `json:"db_allocated_bytes"` +} + +func measureDiskPhase(name, dbPath string, database func() *DB, work func() ([]time.Duration, error)) (diskPhase, error) { + r := diskPhase{Name: name} + start, err := diskResourceSnapshot() + if err != nil { + return r, err + } + r.Start = start + startDB := database() + var ioStart leveldb.DBStats + if err := startDB.db.Stats(&ioStart); err != nil { + return r, err + } + var mu sync.Mutex + maxRSS := start.RSSBytes + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-stop: + return + case <-tick.C: + if sample, err := diskResourceSnapshot(); err == nil { + mu.Lock() + if sample.RSSBytes > maxRSS { + maxRSS = sample.RSSBytes + } + mu.Unlock() + } + } + } + }() + begin := time.Now() + durations, workErr := work() + r.Seconds = time.Since(begin).Seconds() + close(stop) + <-done + r.End, err = diskResourceSnapshot() + if err != nil { + return r, err + } + mu.Lock() + r.ObservedRSSMax = maxRSS + mu.Unlock() + if r.End.RSSBytes > r.ObservedRSSMax { + r.ObservedRSSMax = r.End.RSSBytes + } + if endDB := database(); endDB == startDB { + var ioEnd leveldb.DBStats + if err := endDB.db.Stats(&ioEnd); err != nil { + return r, err + } + r.BackendIOAvailable = true + r.BackendReadBytes = ioEnd.IORead - ioStart.IORead + r.BackendWriteBytes = ioEnd.IOWrite - ioStart.IOWrite + } + r.Calls = len(durations) + if len(durations) > 0 { + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + q := func(p float64) float64 { + index := int(float64(len(durations)-1)*p + 0.5) + return float64(durations[index]) / float64(time.Millisecond) + } + r.P50MS, r.P95MS, r.P99MS, r.MaxMS = q(.5), q(.95), q(.99), q(1) + } + err = filepath.Walk(dbPath, func(_ string, info os.FileInfo, e error) error { + if e != nil { + return e + } + if info.Mode().IsRegular() { + r.ApparentBytes += info.Size() + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + r.AllocatedBytes += stat.Blocks * 512 + } + } + return nil + }) + if err != nil { + return r, err + } + return r, workErr +} + +// Local, advisory eviction only. A successful syscall is NOT proof of a fully +// cold host/storage cache. The report retains LevelDB file-read byte counts. +func adviseDiskFilesCold(path string) error { + return filepath.Walk(path, func(name string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + file, err := os.Open(name) + if err != nil { + return err + } + defer file.Close() + return unix.Fadvise(int(file.Fd()), 0, 0, unix.FADV_DONTNEED) + }) +} + +func writeDiskReport(path string, value any) error { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".rehearsal-report-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(append(raw, '\n')); err != nil { + return err + } + if err := file.Sync(); err != nil { + return fmt.Errorf("sync report: %w", err) + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(file.Name(), path); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + if err := dir.Sync(); err != nil { + return fmt.Errorf("sync report directory: %w", err) + } + return nil +} + +func TestRingDiskReportAtomicCheckpoint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "report.json") + for _, passed := range []bool{false, true} { + if err := writeDiskReport(path, map[string]bool{"passed": passed}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got map[string]bool + if err := json.Unmarshal(data, &got); err != nil || got["passed"] != passed { + t.Fatal("invalid checkpoint", err) + } + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 { + t.Fatal("temporary report files leaked", err) + } +} diff --git a/store/ring_sampling_disk_rehearsal_test.go b/store/ring_sampling_disk_rehearsal_test.go new file mode 100644 index 0000000..562b531 --- /dev/null +++ b/store/ring_sampling_disk_rehearsal_test.go @@ -0,0 +1,556 @@ +//go:build linux + +package store + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// Synthetic storage identities, not wallet keys or spendable curve points. +// The sampler is being measured as a storage operation, not a signing proof. +func diskFixtureOutput(id uint64, sparse bool) *StoredUTXO { + var input [9]byte + binary.BigEndian.PutUint64(input[1:], id) + input[0] = 1 + pub := crypto.Point32(sha256.Sum256(input[:])) + binary.BigEndian.PutUint64(pub[24:], id) + input[0] = 2 + commit := crypto.Commitment(sha256.Sum256(input[:])) + var hash crypto.Hash32 + binary.BigEndian.PutUint64(hash[:8], id) + return &StoredUTXO{TxHash: hash, OneTimePub: pub, AmountCommit: commit, + BlockHeight: id, ProtocolLocked: id%100 == 0 || (sparse && id > 10)} +} + +func seedDiskFixture(db *DB, population uint64, rewriteSparse bool) error { + batch := new(leveldb.Batch) + for id := uint64(1); id <= population; id++ { + u := diskFixtureOutput(id, rewriteSparse) + data, err := json.Marshal(u) + if err != nil { + return err + } + batch.Put(utxoKey(u.TxHash, 0), data) + if rewriteSparse { + batch.Put(ringMemberKey(u.OneTimePub), data) + } + if id%2 == 0 && !rewriteSparse { + batch.Put(spentUTXOKey(u.TxHash, 0), []byte{1}) + } + if id%1000 == 0 { + if err := db.db.Write(batch, nil); err != nil { + return err + } + batch.Reset() + } + } + return db.db.Write(batch, &opt.WriteOptions{Sync: true}) +} + +func auditDiskFixture(db *DB, population uint64, sparse bool) error { + count, ready, err := ringSamplingCount(db.db) + expected := population - population/100 + if sparse { + expected = 10 + } + if err != nil || !ready || count != expected { + return fmt.Errorf("count=%d expected=%d ready=%t: %v", count, expected, ready, err) + } + iter := db.db.NewIterator(util.BytesPrefix(prefixRingMember), nil) + var canonical, eligible, spent uint64 + for iter.Next() { + u, err := decodeRingRecord(iter.Key(), iter.Value()) + if err != nil { + iter.Release() + return err + } + id := binary.BigEndian.Uint64(u.OneTimePub[24:]) + expectedU := diskFixtureOutput(id, sparse) + if id == 0 || id > population || *u != *expectedU { + iter.Release() + return fmt.Errorf("canonical fixture mismatch %d", id) + } + original, err := db.db.Get(utxoKey(u.TxHash, 0), nil) + if err != nil || !bytes.Equal(original, iter.Value()) { + iter.Release() + return fmt.Errorf("u/rm mismatch %d: %v", id, err) + } + marker, err := db.db.Get(spentUTXOKey(u.TxHash, 0), nil) + if id%2 == 0 { + if err != nil || !bytes.Equal(marker, []byte{1}) { + iter.Release() + return fmt.Errorf("spent record lost %d: %v", id, err) + } + spent++ + } else if !errors.Is(err, leveldb.ErrNotFound) { + iter.Release() + return fmt.Errorf("unexpected spent marker %d: %v", id, err) + } + reverse, err := db.db.Get(ringSamplingReverseKey(u.OneTimePub), nil) + if u.ProtocolLocked { + if !errors.Is(err, leveldb.ErrNotFound) { + iter.Release() + return fmt.Errorf("locked inverse %d: %v", id, err) + } + } else { + if err != nil || len(reverse) != 8 { + iter.Release() + return fmt.Errorf("missing inverse %d: %v", id, err) + } + ordinal := binary.BigEndian.Uint64(reverse) + slot, err := db.db.Get(ringSamplingSlotKey(ordinal), nil) + if err != nil || ordinal >= count || !bytes.Equal(slot, u.OneTimePub[:]) { + iter.Release() + return fmt.Errorf("bijection mismatch %d: %v", id, err) + } + eligible++ + } + canonical++ + } + err = iter.Error() + iter.Release() + if err != nil { + return err + } + if canonical != population || eligible != expected || spent != population/2 { + return fmt.Errorf("audit counts canonical=%d eligible=%d spent=%d", canonical, eligible, spent) + } + for _, prefix := range [][]byte{ringSamplingSlotPrefix, ringSamplingReversePrefix} { + it := db.db.NewIterator(util.BytesPrefix(prefix), nil) + var n uint64 + for it.Next() { + if bytes.Equal(prefix, ringSamplingSlotPrefix) && + !bytes.Equal(it.Key(), ringSamplingSlotKey(n)) { + it.Release() + return fmt.Errorf("non-dense slot %d", n) + } + n++ + } + err := it.Error() + it.Release() + if err != nil || n != expected { + return fmt.Errorf("derived namespace count %d expected %d: %v", n, expected, err) + } + } + return nil +} + +func diskSampleCalls(db *DB, calls, count, expected int, pubs map[crypto.Point32]bool, + outpoints map[core.UTXOKey]bool, sparse bool) ([]time.Duration, error) { + latencies := make([]time.Duration, 0, calls) + for i := 0; i < calls; i++ { + begin := time.Now() + sample, err := db.SampleRingMembersExcluding(count, pubs, outpoints) + latencies = append(latencies, time.Since(begin)) + if err != nil || len(sample) != expected { + return latencies, fmt.Errorf("sample size %d expected %d: %v", len(sample), expected, err) + } + seen := make(map[crypto.Point32]bool, len(sample)) + for _, pair := range sample { + id := binary.BigEndian.Uint64(pair.OneTimePub[24:]) + u := diskFixtureOutput(id, sparse) + if seen[pair.OneTimePub] || u.ProtocolLocked || pair.OneTimePub != u.OneTimePub || + pair.AmountCommit != u.AmountCommit || pubs[pair.OneTimePub] || + outpoints[core.UTXOKey{TxHash: u.TxHash, OutputIndex: 0}] { + return latencies, fmt.Errorf("noncanonical/duplicate/excluded sample %d", id) + } + seen[pair.OneTimePub] = true + } + } + return latencies, nil +} + +func crashDuringDiskBackfill(path, proof string, oldTail, crashAt uint64) error { + db, err := Open(path) + if err != nil { + return err + } + if oldTail <= crashAt { + return fmt.Errorf("crash threshold must precede old tail") + } + if _, err := db.db.Get(ringSamplingSlotKey(oldTail), nil); err != nil { + return fmt.Errorf("initial old tail: %w", err) + } + go func() { + cleanupObserved := false + for { + // A count invalidation leaves OLD slots present. Do not mistake + // them for a new partial build: first witness cleanup of its tail. + if !cleanupObserved { + if _, err := db.db.Get(ringSamplingSlotKey(oldTail), nil); errors.Is(err, leveldb.ErrNotFound) { + cleanupObserved = true + } + time.Sleep(time.Millisecond) + continue + } + if data, err := db.db.Get(ringSamplingSlotKey(crashAt), nil); err == nil && len(data) == 32 { + if _, ready, err := ringSamplingCount(db.db); err != nil || ready { + os.Exit(74) + } + // This isolated-fixture metadata write fsyncs the journal, + // ensuring preceding non-Sync index batches survive exit. + if err := db.db.Put([]byte("m/rehearsal-partial-fsync"), []byte{1}, &opt.WriteOptions{Sync: true}); err != nil { + os.Exit(76) + } + resources, err := diskResourceSnapshot() + evidence := struct { + Resources diskResources `json:"resources"` + OldTail uint64 `json:"old_tail"` + CrashAt uint64 `json:"new_partial_slot"` + CleanupObserved bool `json:"old_tail_cleanup_observed"` + JournalSynced bool `json:"partial_journal_synced"` + }{resources, oldTail, crashAt, cleanupObserved, true} + if err != nil || writeDiskReport(proof, evidence) != nil { + os.Exit(75) + } + os.Exit(73) + } + time.Sleep(time.Millisecond) + } + }() + if err := db.EnsureRingMemberIndex(); err != nil { + return err + } + return fmt.Errorf("partial-build crash point was missed") +} + +func TestRingSamplingDiskRehearsal(t *testing.T) { + if os.Getenv("APEROD_RING_DISK_REHEARSAL") != "1" { + t.Skip("opt-in physical multi-million LevelDB rehearsal") + } + root := os.Getenv("APEROD_RING_DISK_ROOT") + if root == "" || !filepath.IsAbs(root) { + t.Fatal("explicit isolated output directory required") + } + path := filepath.Join(root, "synthetic.leveldb") + reportRoot := os.Getenv("APEROD_RING_DISK_REPORT_ROOT") + if reportRoot == "" { + reportRoot = root + } + if !filepath.IsAbs(reportRoot) { + t.Fatal("report directory must be absolute") + } + if os.Getenv("APEROD_RING_DISK_PHASE") == "crash" { + for _, dir := range []string{root, reportRoot} { + marker, err := os.ReadFile(filepath.Join(dir, "synthetic-only")) + if err != nil || string(marker) != "ring-disk-rehearsal-synthetic-only\n" { + t.Fatal("crash worker refuses a directory without synthetic fixture marker") + } + } + tail, tailErr := strconv.ParseUint(os.Getenv("APEROD_RING_DISK_OLD_TAIL"), 10, 64) + crashAt := uint64(100_000) + if raw := os.Getenv("APEROD_RING_DISK_CRASH_AT"); raw != "" { + var err error + crashAt, err = strconv.ParseUint(raw, 10, 64) + if err != nil { + t.Fatal(err) + } + } + if tailErr != nil { + t.Fatal("crash worker needs explicit old tail", tailErr) + } + if err := crashDuringDiskBackfill(path, filepath.Join(reportRoot, "crash-proof.json"), tail, crashAt); err != nil { + t.Fatal(err) + } + return + } + population := uint64(2_000_000) + if raw := os.Getenv("APEROD_RING_DISK_POPULATION"); raw != "" { + var err error + population, err = strconv.ParseUint(raw, 10, 64) + if err != nil || population < 1_000_000 || population > 10_000_000 { + t.Fatal("population must be 1M..10M") + } + } + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal("refusing existing/nonisolated rehearsal directory:", err) + } + if err := os.WriteFile(filepath.Join(root, "synthetic-only"), []byte("ring-disk-rehearsal-synthetic-only\n"), 0600); err != nil { + t.Fatal(err) + } + if reportRoot != root { + if err := os.Mkdir(reportRoot, 0700); err != nil { + t.Fatal("refusing existing report directory:", err) + } + if err := os.WriteFile(filepath.Join(reportRoot, "synthetic-only"), []byte("ring-disk-rehearsal-synthetic-only\n"), 0600); err != nil { + t.Fatal(err) + } + } + report := struct { + Population uint64 `json:"population"` + Go string `json:"go_version"` + CPU int `json:"logical_cpu"` + GoMaxProcs int `json:"gomaxprocs"` + GoMemLimit string `json:"gomemlimit"` + Cache string `json:"backend_cache"` + ColdMethod string `json:"cold_method"` + IOMethod string `json:"io_method"` + Scope string `json:"scope"` + Phases []diskPhase `json:"phases"` + Passed bool `json:"passed"` + Failure string `json:"failure,omitempty"` + }{Population: population, Go: runtime.Version(), CPU: runtime.NumCPU(), + GoMaxProcs: runtime.GOMAXPROCS(0), GoMemLimit: os.Getenv("GOMEMLIMIT"), + Cache: "store.Open: 32MiB LevelDB block cache, 16MiB write buffer; no population memory backend", + ColdMethod: "close, FADV_DONTNEED on isolated DB files, reopen; sample timing excludes reopen; advisory eviction, not guaranteed globally cold", + IOMethod: "LevelDB DBStats IORead/IOWrite file-storage bytes, not physical-media IO. /proc/self/io unavailable. Phase IO excludes crashed child; cold phase reopens reset counters, so sample-only bytes are reported separately.", + Scope: "synthetic storage-only identities; no real keys, funds, production changes or host E2E"} + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { + if db != nil { + db.Close() + } + }() + defer func() { + if err := writeDiskReport(filepath.Join(reportRoot, "report.json"), report); err != nil { + t.Error(err) + } + }() + phase := func(name string, work func() ([]time.Duration, error)) { + t.Helper() + t.Log("phase:", name) + result, err := measureDiskPhase(name, path, func() *DB { return db }, work) + report.Phases = append(report.Phases, result) + if err != nil { + report.Failure = err.Error() + } + if saveErr := writeDiskReport(filepath.Join(reportRoot, "report.json"), report); saveErr != nil { + t.Fatal("save phase checkpoint:", saveErr) + } + t.Logf("%s: %.3fs p50=%.3f p95=%.3f p99=%.3fms RSS=%d", name, result.Seconds, result.P50MS, result.P95MS, result.P99MS, result.ObservedRSSMax) + if err != nil { + t.Fatal(name, err) + } + } + phase("seed_legacy_u_only", func() ([]time.Duration, error) { return nil, seedDiskFixture(db, population, false) }) + phase("streaming_legacy_u_and_ordinal_backfill", func() ([]time.Duration, error) { return nil, db.EnsureRingMemberIndex() }) + phase("full_canonical_bijection_and_spent_audit", func() ([]time.Duration, error) { return nil, auditDiskFixture(db, population, false) }) + if err := db.InvalidateRingSamplingIndex(); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + db = nil + cmd := exec.Command(os.Args[0], "-test.run=^TestRingSamplingDiskRehearsal$", "-test.v") + cmd.Env = append(os.Environ(), "APEROD_RING_DISK_PHASE=crash", + fmt.Sprintf("APEROD_RING_DISK_OLD_TAIL=%d", population-population/100-1), + "APEROD_RING_DISK_CRASH_AT=100000") + crashOutput, crashErr := cmd.CombinedOutput() + t.Logf("partial-build child: %s", crashOutput) + var exited *exec.ExitError + if !errors.As(crashErr, &exited) || exited.ExitCode() != 73 { + t.Fatalf("expected abrupt partial build exit 73: %v", crashErr) + } + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + if _, ready, err := ringSamplingCount(db.db); err != nil || ready { + t.Fatal("partial build published readiness", err) + } + if data, err := db.db.Get(ringSamplingSlotKey(100_000), nil); err != nil || len(data) != 32 { + t.Fatal("partial persisted batch missing", err) + } + if sample, err := db.SampleRingMembers(15, nil); err == nil || sample != nil { + t.Fatal("partial index did not fail closed") + } + phase("restart_rm_backfill_with_partial_cleanup", func() ([]time.Duration, error) { return nil, db.EnsureRingMemberIndex() }) + phase("full_post_restart_audit", func() ([]time.Duration, error) { return nil, auditDiskFixture(db, population, false) }) + phase("compact_before_latency", func() ([]time.Duration, error) { return nil, db.db.CompactRange(util.Range{}) }) + phase("warm_15_members_1000_calls", func() ([]time.Duration, error) { return diskSampleCalls(db, 1000, 15, 15, nil, nil, false) }) + var coldSampleReadBytes uint64 + var coldPhysicalCalls int + phase("advisory_cold_15_members_200_reopens", func() ([]time.Duration, error) { + latencies := make([]time.Duration, 0, 200) + for i := 0; i < 200; i++ { + if err := db.Close(); err != nil { + return latencies, err + } + db = nil + if err := adviseDiskFilesCold(path); err != nil { + return latencies, err + } + db, err = Open(path) + if err != nil { + return latencies, err + } + var before leveldb.DBStats + if err := db.db.Stats(&before); err != nil { + return latencies, err + } + next, err := diskSampleCalls(db, 1, 15, 15, nil, nil, false) + if err != nil { + return latencies, err + } + var after leveldb.DBStats + if err := db.db.Stats(&after); err != nil { + return latencies, err + } + delta := after.IORead - before.IORead + coldSampleReadBytes += delta + if delta > 0 { + coldPhysicalCalls++ + } + latencies = append(latencies, next...) + } + return latencies, nil + }) + report.Phases[len(report.Phases)-1].SampleReadBytes = coldSampleReadBytes + report.Phases[len(report.Phases)-1].SampleReadCalls = coldPhysicalCalls + if err := writeDiskReport(filepath.Join(reportRoot, "report.json"), report); err != nil { + t.Fatal(err) + } + pubs := make(map[crypto.Point32]bool, 4096) + outpoints := make(map[core.UTXOKey]bool, 100_000) + for i := uint64(1); i <= 100_000; i++ { + u := diskFixtureOutput(i, false) + outpoints[core.UTXOKey{TxHash: u.TxHash, OutputIndex: 0}] = true + if i <= 4096 { + pubs[u.OneTimePub] = true + } + } + phase("excluded_4096_keys_100k_outpoints_1000_calls", func() ([]time.Duration, error) { + return diskSampleCalls(db, 1000, 15, 15, pubs, outpoints, false) + }) + phase("max_request_4096_members_30_calls", func() ([]time.Duration, error) { + return diskSampleCalls(db, 30, 4096, 4096, pubs, outpoints, false) + }) + phase("half_population_outpoint_exclusions_up_to_1m_1000_calls", func() ([]time.Duration, error) { + size := population / 2 + if size > 1_000_000 { + size = 1_000_000 + } + large := make(map[core.UTXOKey]bool, int(size)) + for id := uint64(1); id <= size; id++ { + u := diskFixtureOutput(id, false) + large[core.UTXOKey{TxHash: u.TxHash, OutputIndex: 0}] = true + } + return diskSampleCalls(db, 1000, 15, 15, nil, large, false) + }) + runtime.GC() + phase("1000_lock_and_unlock_transitions", func() ([]time.Duration, error) { + times := make([]time.Duration, 0, 2000) + for _, locked := range []bool{true, false} { + for id := uint64(1); id <= 1010; id++ { + if id%100 == 0 { + continue + } + u := diskFixtureOutput(id, false) + u.ProtocolLocked = locked + begin := time.Now() + err := db.PutUTXO(u.TxHash, 0, u) + times = append(times, time.Since(begin)) + if err != nil { + return times, err + } + if locked { + if _, err := db.db.Get(ringSamplingReverseKey(u.OneTimePub), nil); !errors.Is(err, leveldb.ErrNotFound) { + return times, fmt.Errorf("locked output indexed: %v", err) + } + } + } + } + return times, nil + }) + if err := db.InvalidateRingSamplingIndex(); err != nil { + t.Fatal(err) + } + phase("rewrite_sparse_locked_fixture", func() ([]time.Duration, error) { return nil, seedDiskFixture(db, population, true) }) + phase("sparse_locked_streaming_backfill", func() ([]time.Duration, error) { return nil, db.EnsureRingMemberIndex() }) + phase("shortfall_15_requested_10_eligible_1000_calls", func() ([]time.Duration, error) { + return diskSampleCalls(db, 1000, 15, 10, nil, nil, true) + }) + phase("full_sparse_locked_spent_retention_audit", func() ([]time.Duration, error) { return nil, auditDiskFixture(db, population, true) }) + if err := db.Close(); err != nil { + t.Fatal(err) + } + db = nil + if err := os.RemoveAll(path); err != nil { + t.Fatal("clean synthetic DB:", err) + } + report.Passed = true +} + +func TestRingDiskCrashDurablyBuildsNewNotOldSlots(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "synthetic-only"), []byte("ring-disk-rehearsal-synthetic-only\n"), 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "synthetic.leveldb") + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { + if db != nil { + db.Close() + } + }() + const population = uint64(20_000) + if err := seedDiskFixture(db, population, false); err != nil { + t.Fatal(err) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + if err := db.InvalidateRingSamplingIndex(); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + db = nil + cmd := exec.Command(os.Args[0], "-test.run=^TestRingSamplingDiskRehearsal$") + cmd.Env = append(os.Environ(), "APEROD_RING_DISK_REHEARSAL=1", "APEROD_RING_DISK_PHASE=crash", + "APEROD_RING_DISK_ROOT="+root, "APEROD_RING_DISK_REPORT_ROOT="+root, + fmt.Sprintf("APEROD_RING_DISK_OLD_TAIL=%d", population-population/100-1), "APEROD_RING_DISK_CRASH_AT=1000") + output, err := cmd.CombinedOutput() + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 73 { + t.Fatalf("partial child: %s %v", output, err) + } + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + if _, ready, err := ringSamplingCount(db.db); err != nil || ready { + t.Fatal("crash published readiness", err) + } + if value, err := db.db.Get(ringSamplingSlotKey(1000), nil); err != nil || len(value) != 32 { + t.Fatal("new durable partial slot missing", err) + } + if value, err := db.db.Get([]byte("m/rehearsal-partial-fsync"), nil); err != nil || !bytes.Equal(value, []byte{1}) { + t.Fatal("journal sync marker missing", err) + } + if _, err := db.SampleRingMembers(15, nil); err == nil { + t.Fatal("partial index accepted") + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + if err := auditDiskFixture(db, population, false); err != nil { + t.Fatal(err) + } +} diff --git a/store/ring_sampling_verify.go b/store/ring_sampling_verify.go new file mode 100644 index 0000000..4ed55bb --- /dev/null +++ b/store/ring_sampling_verify.go @@ -0,0 +1,183 @@ +package store + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// RingSamplingInventory is an offline local integrity receipt, not a consensus +// or historical-chain proof. The digest binds every canonical rm/ key/payload. +type RingSamplingInventory struct { + Canonical uint64 `json:"canonical"` + Eligible uint64 `json:"eligible"` + Locked uint64 `json:"locked"` + CanonicalSHA256 string `json:"canonical_sha256"` +} + +// Legacy preparation can resume from a partial u/→rm/ copy, but must not +// overwrite an independently repaired canonical rm/ record with stale u/. +// This is a maintenance precondition, not a change to sampling or startup's +// established migration contract. Ambiguous legacy layouts stay fenced. +func (d *DB) verifyLegacyRingBackfillPreservesCanonical() error { + marker, err := d.GetMeta("ring-member-index-v1") + if err != nil || marker != nil { + return err + } + iter := d.db.NewIterator(util.BytesPrefix(prefixRingMember), nil) + defer iter.Release() + for iter.Next() { + u, err := decodeRingRecord(iter.Key(), iter.Value()) + if err != nil { + return err + } + old, err := d.db.Get(utxoKey(u.TxHash, u.OutputIndex), nil) + if err != nil { + return fmt.Errorf("ring maintenance: unprepared canonical record has no matching legacy output: %w", err) + } + if !bytes.Equal(old, iter.Value()) { + return fmt.Errorf("ring maintenance: unprepared legacy backfill would overwrite canonical rm; explicit reconciliation required") + } + } + return iter.Error() +} + +// VerifyRingSamplingIndex performs a streaming complete bijection audit. A +// ready marker alone cannot prove that a legacy writer did not change rm/. +// Caller must quiesce clients and all writers before using this to release a +// maintenance fence. The snapshot makes the audit coherent, not a writer fence. +func (d *DB) VerifyRingSamplingIndex() (RingSamplingInventory, error) { + var out RingSamplingInventory + d.ringMu.Lock() + defer d.ringMu.Unlock() + snap, err := d.db.GetSnapshot() + if err != nil { + return out, err + } + defer snap.Release() + count, ready, err := ringSamplingCount(snap) + if err != nil { + return out, err + } + if !ready { + return out, fmt.Errorf("ring audit: readiness absent") + } + hash := sha256.New() + iter := snap.NewIterator(util.BytesPrefix(prefixRingMember), nil) + for iter.Next() { + key, value := iter.Key(), iter.Value() + u, err := decodeRingRecord(key, value) + if err != nil { + iter.Release() + return out, err + } + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + hash.Write(key) + hash.Write(size[:]) + hash.Write(value) + out.Canonical++ + reverse, lookupErr := snap.Get(ringSamplingReverseKey(u.OneTimePub), nil) + if u.ProtocolLocked { + if !errors.Is(lookupErr, leveldb.ErrNotFound) { + iter.Release() + if lookupErr != nil { + return out, lookupErr + } + return out, fmt.Errorf("ring audit: locked member indexed") + } + out.Locked++ + continue + } + if lookupErr != nil { + iter.Release() + return out, lookupErr + } + if len(reverse) != 8 { + iter.Release() + return out, fmt.Errorf("ring audit: malformed inverse") + } + index := binary.BigEndian.Uint64(reverse) + member, err := readRingSamplingMember(snap, index) + if err != nil { + iter.Release() + return out, err + } + if index >= count || member.OneTimePub != u.OneTimePub { + iter.Release() + return out, fmt.Errorf("ring audit: canonical/inverse/slot mismatch") + } + out.Eligible++ + } + err = iter.Error() + iter.Release() + if err != nil { + return out, err + } + if out.Eligible != count { + return out, fmt.Errorf("ring audit: canonical/count mismatch") + } + slots := uint64(0) + iter = snap.NewIterator(util.BytesPrefix(ringSamplingSlotPrefix), nil) + for iter.Next() { + key := iter.Key() + if len(key) != len(ringSamplingSlotPrefix)+8 || + binary.BigEndian.Uint64(key[len(ringSamplingSlotPrefix):]) != slots || + slots >= count { + iter.Release() + return out, fmt.Errorf("ring audit: non-dense or excess slot") + } + if _, err := readRingSamplingMember(snap, slots); err != nil { + iter.Release() + return out, err + } + slots++ + } + err = iter.Error() + iter.Release() + if err != nil { + return out, err + } + if slots != count { + return out, fmt.Errorf("ring audit: missing slots") + } + reverses := uint64(0) + iter = snap.NewIterator(util.BytesPrefix(ringSamplingReversePrefix), nil) + for iter.Next() { + key, value := iter.Key(), iter.Value() + if len(key) != len(ringSamplingReversePrefix)+32 || len(value) != 8 { + iter.Release() + return out, fmt.Errorf("ring audit: malformed inverse entry") + } + var pub crypto.Point32 + copy(pub[:], key[len(ringSamplingReversePrefix):]) + index := binary.BigEndian.Uint64(value) + member, err := readRingSamplingMember(snap, index) + if err != nil { + iter.Release() + return out, err + } + if index >= count || !bytes.Equal(member.OneTimePub[:], pub[:]) { + iter.Release() + return out, fmt.Errorf("ring audit: orphan inverse") + } + reverses++ + } + err = iter.Error() + iter.Release() + if err != nil { + return out, err + } + if reverses != count { + return out, fmt.Errorf("ring audit: inverse/count mismatch") + } + out.CanonicalSHA256 = hex.EncodeToString(hash.Sum(nil)) + return out, nil +} diff --git a/store/ring_sampling_verify_test.go b/store/ring_sampling_verify_test.go new file mode 100644 index 0000000..4e7e405 --- /dev/null +++ b/store/ring_sampling_verify_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/syndtr/goleveldb/leveldb/opt" +) + +func TestRingSamplingVerifyDetectsUnfencedLegacyWriter(t *testing.T) { + db := samplingDB(t) + for i := byte(1); i <= 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + before, err := db.VerifyRingSamplingIndex() + if err != nil || before.Canonical != 4 || before.Eligible != 4 { + t.Fatal(before, err) + } + // Emulate an older binary: mutate only canonical rm/, leaving a valid + // count/slot namespace. A marker-only check incorrectly accepts this. + newMember := sparseRingOutput(7) + raw, err := json.Marshal(newMember) + if err != nil { + t.Fatal(err) + } + if err := db.db.Put(ringMemberKey(newMember.OneTimePub), raw, &opt.WriteOptions{Sync: true}); err != nil { + t.Fatal(err) + } + if _, err := db.VerifyRingSamplingIndex(); err == nil { + t.Fatal("stale ready marker passed full audit") + } + if err := db.InvalidateRingSamplingIndex(); err != nil { + t.Fatal(err) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + after, err := db.VerifyRingSamplingIndex() + if err != nil || after.Canonical != 5 || after.Eligible != 5 || after.CanonicalSHA256 == before.CanonicalSHA256 { + t.Fatal(after, err) + } +} + +func TestRingSamplingVerifyCorruptionAndClosedBackend(t *testing.T) { + for _, mutate := range []func(*DB) error{ + func(d *DB) error { return d.InvalidateRingSamplingIndex() }, + func(d *DB) error { return d.db.Put(ringSamplingSlotKey(1), make([]byte, 32), nil) }, + func(d *DB) error { return d.db.Put(ringSamplingSlotKey(4), sparseRingOutput(1).OneTimePub[:], nil) }, + func(d *DB) error { + return d.db.Put(append(append([]byte{}, ringSamplingReversePrefix...), make([]byte, 32)...), ringSamplingUint(0), nil) + }, + func(d *DB) error { + return d.db.Put(append(append([]byte{}, ringSamplingSlotPrefix...), 1), []byte{1}, nil) + }, + func(d *DB) error { return d.Close() }, + } { + d := samplingDB(t) + for i := byte(1); i <= 4; i++ { + putSamplingOutput(t, d, sparseRingOutput(i)) + } + if err := mutate(d); err != nil { + t.Fatal(err) + } + if _, err := d.VerifyRingSamplingIndex(); err == nil { + t.Fatal("invalid or closed backend accepted") + } + } +} diff --git a/store/wallet_read_snapshot.go b/store/wallet_read_snapshot.go index 92b353a..bafac58 100644 --- a/store/wallet_read_snapshot.go +++ b/store/wallet_read_snapshot.go @@ -64,6 +64,26 @@ func (s *WalletReadSnapshot) get(key []byte) ([]byte, error) { return value, err } +// LookupRingMember reads the exact pair through the wallet snapshot rather +// than mixing the captured page with a newer point-in-time ring index. +func (s *WalletReadSnapshot) LookupRingMember(pub crypto.Point32) (*core.UTXO, error) { + if s == nil || s.snapshot == nil { + return nil, fmt.Errorf("wallet snapshot unavailable") + } + data, err := s.get(ringMemberKey(pub)) + if err != nil || data == nil { + return nil, err + } + member, err := decodeRingRecord(ringMemberKey(pub), data) + if err != nil { + return nil, err + } + if member.OneTimePub != pub { + return nil, fmt.Errorf("pinned ring member identity mismatch") + } + return storedToCoreUTXO(member), nil +} + // GetTip returns the tip metadata captured by this snapshot. func (s *WalletReadSnapshot) GetTip() (hash crypto.Hash32, height uint64, err error) { hashBytes, err := s.get(append(append([]byte{}, prefixMeta...), []byte("tip/hash")...)) @@ -206,6 +226,64 @@ func (s *WalletReadSnapshot) LPoDWalletIndexReady(funding crypto.Hash32) (bool, return string(value) == string(funding[:]), err } +// LPoDEarningsIndexReady reports whether the earnings index was complete at +// this snapshot's exact finalized checkpoint. +func (s *WalletReadSnapshot) LPoDEarningsIndexReady( + funding, tip crypto.Hash32, + height uint64, +) (bool, error) { + value, err := s.get(lpodEarningsReadyKey) + return bytes.Equal(value, earningsReadyValue(funding, tip, height)), err +} + +// LPoDEarningsOutputs returns a bounded page from the earnings index captured +// by this snapshot. Unlike wallet outputs, historical spent rewards remain in +// this metadata index and are filtered by the caller's spendability-checked +// wallet page. +func (s *WalletReadSnapshot) LPoDEarningsOutputs( + address crypto.Address, + cursor string, + limit int, +) ([]StoredUTXO, string, error) { + if limit < 1 || limit > 128 { + return nil, "", fmt.Errorf("invalid page limit") + } + prefix := lpodEarningsPrefix(address) + iter := s.snapshot.NewIterator(util.BytesPrefix(prefix), nil) + defer iter.Release() + ok := iter.First() + if cursor != "" { + key, err := hex.DecodeString(cursor) + if err != nil || !bytes.HasPrefix(key, prefix) || len(key) != len(prefix)+76 { + return nil, "", fmt.Errorf("invalid cursor") + } + ok = iter.Seek(key) + if ok && bytes.Equal(iter.Key(), key) { + ok = iter.Next() + } + } + rows := []StoredUTXO{} + next := "" + examined := 0 + for ok && examined < limit { + examined++ + var output StoredUTXO + if err := json.Unmarshal(iter.Value(), &output); err != nil { + return nil, "", err + } + rows = append(rows, output) + next = hex.EncodeToString(iter.Key()) + ok = iter.Next() + } + if !ok { + next = "" + } + if err := iter.Error(); err != nil { + return nil, "", err + } + return rows, next, nil +} + // LPoDWalletOutputs returns one bounded page from the captured wallet index, // filtering spend markers from the same LevelDB snapshot. func (s *WalletReadSnapshot) LPoDWalletOutputs( @@ -286,6 +364,35 @@ func (s *WalletReadSnapshot) LPoDWalletOutputsAfterHeight( resumeCursor string, throughHeight uint64, limit int, +) ([]StoredUTXO, string, string, error) { + return s.lpodWalletOutputsAfterHeight( + address, afterHeight, hasAfterHeight, resumeCursor, throughHeight, limit, false, + ) +} + +// LPoDWalletOutputsAfterHeightWithEarnings enriches only unspent wallet-index +// outputs with an exact matching row from the snapshot-pinned earnings index. +func (s *WalletReadSnapshot) LPoDWalletOutputsAfterHeightWithEarnings( + address crypto.Address, + afterHeight uint64, + hasAfterHeight bool, + resumeCursor string, + throughHeight uint64, + limit int, +) ([]StoredUTXO, string, string, error) { + return s.lpodWalletOutputsAfterHeight( + address, afterHeight, hasAfterHeight, resumeCursor, throughHeight, limit, true, + ) +} + +func (s *WalletReadSnapshot) lpodWalletOutputsAfterHeight( + address crypto.Address, + afterHeight uint64, + hasAfterHeight bool, + resumeCursor string, + throughHeight uint64, + limit int, + includeEarnings bool, ) ([]StoredUTXO, string, string, error) { if limit < 1 || limit > 128 { return nil, "", "", fmt.Errorf("invalid page limit") @@ -355,6 +462,11 @@ func (s *WalletReadSnapshot) LPoDWalletOutputsAfterHeight( return nil, "", "", err } if spent == nil { + if includeEarnings { + if err := s.attachLPoDEarningsAmount(key, &output); err != nil { + return nil, "", "", err + } + } rows = append(rows, output) } ok = iter.Next() @@ -372,6 +484,57 @@ func (s *WalletReadSnapshot) LPoDWalletOutputsAfterHeight( return rows, next, lastExamined, iter.Error() } +func lpodEarningsKeyForWalletIndexKey(walletKey []byte) ([]byte, error) { + const walletPrefixText = "lpod/wallet/v1/" + const earningsPrefixText = "lpod/earnings-output/v2/" + walletPrefix := []byte(walletPrefixText) + if !bytes.HasPrefix(walletKey, walletPrefix) || + len(walletKey) != len(walletPrefix)+32+76 { + return nil, fmt.Errorf("malformed native wallet index key") + } + key := make([]byte, 0, len(earningsPrefixText)+len(walletKey)-len(walletPrefix)) + key = append(key, earningsPrefixText...) + key = append(key, walletKey[len(walletPrefix):]...) + return key, nil +} + +func (s *WalletReadSnapshot) attachLPoDEarningsAmount(walletKey []byte, output *StoredUTXO) error { + key, err := lpodEarningsKeyForWalletIndexKey(walletKey) + if err != nil { + return err + } + const walletPrefixText = "lpod/wallet/v1/" + suffix := walletKey[len(walletPrefixText)+32:] + if binary.BigEndian.Uint64(suffix[:8]) != output.BlockHeight || + binary.BigEndian.Uint32(suffix[8:12]) != output.OutputIndex || + !bytes.Equal(suffix[44:76], output.TxHash[:]) { + return fmt.Errorf("wallet index key conflicts with its output metadata") + } + raw, err := s.get(key) + if err != nil || raw == nil { + return err + } + var reward StoredUTXO + if err := json.Unmarshal(raw, &reward); err != nil { + return fmt.Errorf("decode pinned LPoD earnings output: %w", err) + } + if reward.TxHash != output.TxHash || + reward.OutputIndex != output.OutputIndex || + reward.BlockHeight != output.BlockHeight || + reward.OneTimePub != output.OneTimePub || + reward.TxPubKey != output.TxPubKey || + reward.AmountCommit != output.AmountCommit || + reward.EncAmount != output.EncAmount || + reward.AmountNAPRO == 0 { + return fmt.Errorf("pinned LPoD earnings metadata conflicts with wallet output") + } + if output.AmountNAPRO != 0 && output.AmountNAPRO != reward.AmountNAPRO { + return fmt.Errorf("pinned LPoD earnings amount conflicts with wallet output") + } + output.AmountNAPRO = reward.AmountNAPRO + return nil +} + // GetUTXO retrieves an output from the captured active-output namespace. func (s *WalletReadSnapshot) GetUTXO(txHash crypto.Hash32, outIdx uint32) (*StoredUTXO, error) { data, err := s.get(utxoKey(txHash, outIdx)) diff --git a/store/wallet_read_snapshot_test.go b/store/wallet_read_snapshot_test.go index 2151d51..61274ef 100644 --- a/store/wallet_read_snapshot_test.go +++ b/store/wallet_read_snapshot_test.go @@ -7,6 +7,7 @@ import ( "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" ) func TestWalletReadSnapshotPinsWalletReadsAndReleases(t *testing.T) { @@ -111,6 +112,354 @@ func TestWalletReadSnapshotPinsWalletReadsAndReleases(t *testing.T) { read.Release() } +func TestWalletReadSnapshotPinsLPoDEarningsPagesAndReadiness(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + address := crypto.Address("earnings-snapshot-address") + funding := crypto.Hash32{1} + firstBlock := crypto.Hash32{2} + secondBlock := crypto.Hash32{3} + oldTip := secondBlock + putEarning := func(blockHash, txHash crypto.Hash32, height uint64, amount uint64) { + t.Helper() + output := StoredUTXO{ + TxHash: txHash, OutputIndex: 0, BlockHeight: height, AmountNAPRO: amount, + } + raw, err := json.Marshal(output) + if err != nil { + t.Fatal(err) + } + key := append([]byte{}, lpodEarningsPrefix(address)...) + var suffix [76]byte + binary.BigEndian.PutUint64(suffix[:8], height) + copy(suffix[12:44], blockHash[:]) + copy(suffix[44:], txHash[:]) + key = append(key, suffix[:]...) + if err := db.put(key, raw); err != nil { + t.Fatal(err) + } + } + putEarning(firstBlock, crypto.Hash32{4}, 10, 100) + putEarning(secondBlock, crypto.Hash32{5}, 11, 200) + if err := db.put(lpodEarningsReadyKey, earningsReadyValue(funding, oldTip, 11)); err != nil { + t.Fatal(err) + } + + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + defer read.Release() + + newTip := crypto.Hash32{6} + putEarning(newTip, crypto.Hash32{7}, 12, 300) + if err := db.put(lpodEarningsReadyKey, earningsReadyValue(funding, newTip, 12)); err != nil { + t.Fatal(err) + } + + ready, err := read.LPoDEarningsIndexReady(funding, oldTip, 11) + if err != nil || !ready { + t.Fatalf("pinned earnings readiness=(%v,%v), want ready at captured tip", ready, err) + } + ready, err = read.LPoDEarningsIndexReady(funding, newTip, 12) + if err != nil || ready { + t.Fatalf("pinned earnings readiness=(%v,%v), want false for live tip", ready, err) + } + + firstPage, cursor, err := read.LPoDEarningsOutputs(address, "", 1) + if err != nil || len(firstPage) != 1 || firstPage[0].TxHash != (crypto.Hash32{4}) || cursor == "" { + t.Fatalf("first pinned earnings page=%+v cursor=%q err=%v", firstPage, cursor, err) + } + secondPage, next, err := read.LPoDEarningsOutputs(address, cursor, 1) + if err != nil || len(secondPage) != 1 || secondPage[0].TxHash != (crypto.Hash32{5}) || next != "" { + t.Fatalf("second pinned earnings page=%+v next=%q err=%v", secondPage, next, err) + } + liveRows, _, err := db.LPoDEarningsOutputs(address, "", 128) + if err != nil || len(liveRows) != 3 { + t.Fatalf("live earnings index rows=%d err=%v, want newly-added third row", len(liveRows), err) + } +} + +func TestWalletReadSnapshotLPoDEarningsJoinUsesOnlyExactUnspentOutputs(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + address := crypto.Address("earnings-join-address") + blockHash := crypto.Hash32{9} + reward := StoredUTXO{ + TxHash: crypto.Hash32{1}, OutputIndex: 0, BlockHeight: 7, + OneTimePub: crypto.Point32{1}, TxPubKey: crypto.Point32{2}, + AmountCommit: crypto.Commitment{3}, EncAmount: [8]byte{4}, + } + spentReward := StoredUTXO{ + TxHash: crypto.Hash32{5}, OutputIndex: 1, BlockHeight: 7, + OneTimePub: crypto.Point32{6}, TxPubKey: crypto.Point32{7}, + AmountCommit: crypto.Commitment{8}, EncAmount: [8]byte{9}, + } + ordinary := StoredUTXO{ + TxHash: crypto.Hash32{10}, OutputIndex: 2, BlockHeight: 7, + OneTimePub: crypto.Point32{11}, TxPubKey: crypto.Point32{12}, + AmountCommit: crypto.Commitment{13}, EncAmount: [8]byte{14}, + } + batch := new(leveldb.Batch) + addWalletRow := func(output StoredUTXO, earningsAmount uint64, spent bool) { + t.Helper() + key := append([]byte{}, lpodWalletPrefix(address)...) + var suffix [76]byte + binary.BigEndian.PutUint64(suffix[:8], output.BlockHeight) + binary.BigEndian.PutUint32(suffix[8:12], output.OutputIndex) + copy(suffix[12:44], blockHash[:]) + copy(suffix[44:], output.TxHash[:]) + key = append(key, suffix[:]...) + raw, err := json.Marshal(output) + if err != nil { + t.Fatal(err) + } + batch.Put(key, raw) + batch.Put(heightKey(output.BlockHeight), blockHash[:]) + if earningsAmount > 0 { + earning := output + earning.AmountNAPRO = earningsAmount + earningKey, err := lpodEarningsKeyForWalletIndexKey(key) + if err != nil { + t.Fatal(err) + } + earningRaw, err := json.Marshal(earning) + if err != nil { + t.Fatal(err) + } + batch.Put(earningKey, earningRaw) + } + if spent { + batch.Put(spentUTXOKey(output.TxHash, output.OutputIndex), []byte{1}) + } + } + addWalletRow(reward, 45000000, false) + addWalletRow(spentReward, 36000000, true) + addWalletRow(ordinary, 0, false) + if err := db.db.Write(batch, nil); err != nil { + t.Fatal(err) + } + + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + defer read.Release() + + rows, next, _, err := read.LPoDWalletOutputsAfterHeightWithEarnings( + address, 0, false, "", 7, 128, + ) + if err != nil || next != "" || len(rows) != 2 { + t.Fatalf("snapshot earnings join rows=%d next=%q err=%v; want two unspent outputs", len(rows), next, err) + } + got := make(map[crypto.Hash32]uint64, len(rows)) + for _, row := range rows { + got[row.TxHash] = row.AmountNAPRO + } + if got[reward.TxHash] != 45000000 { + t.Fatalf("exact reward amount=%d, want 45000000", got[reward.TxHash]) + } + if got[ordinary.TxHash] != 0 { + t.Fatalf("ordinary wallet output was classified as reward: %d", got[ordinary.TxHash]) + } + if _, found := got[spentReward.TxHash]; found { + t.Fatal("spent reward was returned or enriched") + } +} + +func TestWalletReadSnapshotLPoDEarningsJoinRejectsConflictingIndexRow(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + address := crypto.Address("earnings-join-conflict-address") + blockHash := crypto.Hash32{3} + output := StoredUTXO{ + TxHash: crypto.Hash32{4}, OutputIndex: 0, BlockHeight: 2, + OneTimePub: crypto.Point32{5}, TxPubKey: crypto.Point32{6}, + AmountCommit: crypto.Commitment{7}, EncAmount: [8]byte{8}, + } + key := append([]byte{}, lpodWalletPrefix(address)...) + var suffix [76]byte + binary.BigEndian.PutUint64(suffix[:8], output.BlockHeight) + copy(suffix[12:44], blockHash[:]) + copy(suffix[44:], output.TxHash[:]) + key = append(key, suffix[:]...) + raw, err := json.Marshal(output) + if err != nil { + t.Fatal(err) + } + if err := db.put(key, raw); err != nil { + t.Fatal(err) + } + if err := db.put(heightKey(output.BlockHeight), blockHash[:]); err != nil { + t.Fatal(err) + } + conflicting := output + conflicting.OneTimePub = crypto.Point32{99} + conflicting.AmountNAPRO = 45000000 + earningKey, err := lpodEarningsKeyForWalletIndexKey(key) + if err != nil { + t.Fatal(err) + } + raw, err = json.Marshal(conflicting) + if err != nil { + t.Fatal(err) + } + if err := db.put(earningKey, raw); err != nil { + t.Fatal(err) + } + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + defer read.Release() + + if _, _, _, err := read.LPoDWalletOutputsAfterHeightWithEarnings( + address, 0, false, "", 2, 128, + ); err == nil { + t.Fatal("conflicting reward-index output was accepted") + } +} + +func BenchmarkLPoDWalletOutputRewardJoin30kSpentHistory(b *testing.B) { + const total = 30000 + const active = 10 + const pageSize = 128 + + db, err := Open(b.TempDir()) + if err != nil { + b.Fatal(err) + } + defer db.Close() + address := crypto.Address("benchmark-large-reward-wallet") + walletPrefix := lpodWalletPrefix(address) + earningsPrefix := lpodEarningsPrefix(address) + batch := new(leveldb.Batch) + for i := 1; i <= total; i++ { + height := uint64(i) + var blockHash, txHash crypto.Hash32 + binary.BigEndian.PutUint64(blockHash[:8], height) + binary.BigEndian.PutUint64(txHash[:8], height) + txHash[31] = 1 + output := StoredUTXO{ + TxHash: txHash, OutputIndex: 0, BlockHeight: height, + OneTimePub: crypto.Point32{1}, TxPubKey: crypto.Point32{2}, + AmountCommit: crypto.Commitment{3}, EncAmount: [8]byte{4}, + } + var suffix [76]byte + binary.BigEndian.PutUint64(suffix[:8], height) + copy(suffix[12:44], blockHash[:]) + copy(suffix[44:], txHash[:]) + walletKey := append(append([]byte{}, walletPrefix...), suffix[:]...) + walletRaw, err := json.Marshal(output) + if err != nil { + b.Fatal(err) + } + batch.Put(walletKey, walletRaw) + earning := output + earning.AmountNAPRO = 1 + earningKey := append(append([]byte{}, earningsPrefix...), suffix[:]...) + earningRaw, err := json.Marshal(earning) + if err != nil { + b.Fatal(err) + } + batch.Put(earningKey, earningRaw) + batch.Put(heightKey(height), blockHash[:]) + if i <= total-active { + batch.Put(spentUTXOKey(txHash, 0), []byte{1}) + } + } + if err := db.db.Write(batch, nil); err != nil { + b.Fatal(err) + } + + walletPages := (total + pageSize - 1) / pageSize + earningsPages := (total + pageSize - 1) / pageSize + b.Run("legacy-sequential-index-scan", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + b.ReportMetric(float64(walletPages), "wallet_pages/op") + b.ReportMetric(float64(earningsPages), "earnings_pages/op") + for i := 0; i < b.N; i++ { + read, err := db.NewWalletReadSnapshot() + if err != nil { + b.Fatal(err) + } + cursor := "" + for { + _, next, _, err := read.LPoDWalletOutputsAfterHeight(address, 0, false, cursor, total, pageSize) + if err != nil { + b.Fatal(err) + } + if next == "" { + break + } + cursor = next + } + cursor = "" + for { + _, next, err := read.LPoDEarningsOutputs(address, cursor, pageSize) + if err != nil { + b.Fatal(err) + } + if next == "" { + break + } + cursor = next + } + read.Release() + } + }) + b.Run("inline-active-output-join", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + b.ReportMetric(float64(walletPages), "wallet_pages/op") + b.ReportMetric(float64(active), "reward_gets/op") + b.ReportMetric(0, "earnings_pages/op") + for i := 0; i < b.N; i++ { + read, err := db.NewWalletReadSnapshot() + if err != nil { + b.Fatal(err) + } + cursor := "" + var returned int + for { + rows, next, _, err := read.LPoDWalletOutputsAfterHeightWithEarnings( + address, 0, false, cursor, total, pageSize, + ) + if err != nil { + b.Fatal(err) + } + returned += len(rows) + for _, row := range rows { + if row.AmountNAPRO != 1 { + b.Fatalf("inline reward amount=%d, want 1", row.AmountNAPRO) + } + } + if next == "" { + break + } + cursor = next + } + if returned != active { + b.Fatalf("inline wallet output count=%d, want %d", returned, active) + } + read.Release() + } + }) +} + func TestReadCanonicalBlockBoundedRejectsMissingAndMalformedBodies(t *testing.T) { db, err := Open(t.TempDir()) if err != nil { diff --git a/wallet/hd.go b/wallet/hd.go index b4d6c89..57b47d9 100644 --- a/wallet/hd.go +++ b/wallet/hd.go @@ -78,6 +78,13 @@ type DerivedKeys struct { // DeriveKeys derives Aperod spend+view keys from a 64-byte BIP39 seed. // BIP44 path: m/44'/7777'/account'/0'/index' func DeriveKeys(seed []byte, account, index uint32) (*DerivedKeys, error) { + // The hardened bit is part of the encoded child index, not part of the + // caller's index. Reject it up front so distinct uint32 inputs cannot + // collapse to the same child when the path is constructed below. + if account >= hardenedOffset || index >= hardenedOffset { + return nil, fmt.Errorf("hd: account and index must be less than %d", hardenedOffset) + } + master, err := masterFromSeed(seed) if err != nil { return nil, err diff --git a/wallet/keystore.go b/wallet/keystore.go index 157903d..570d171 100644 --- a/wallet/keystore.go +++ b/wallet/keystore.go @@ -7,8 +7,10 @@ package wallet import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -24,12 +26,15 @@ const ( keyLen = 32 // AES-256 ) -// KeystoreV1 is the JSON-serialisable encrypted wallet record. +// KeystoreV1 is the JSON-serialisable encrypted wallet record. Version 1 is +// the legacy format; version 2 authenticates the HD derivation metadata. type KeystoreV1 struct { - Version int `json:"version"` - Address string `json:"address"` - CreatedAt string `json:"created_at"` - Crypto keystoreCrypto `json:"crypto"` + Version int `json:"version"` + Address string `json:"address"` + AccountIndex *uint32 `json:"account_index,omitempty"` + AddressIndex *uint32 `json:"address_index,omitempty"` + CreatedAt string `json:"created_at"` + Crypto keystoreCrypto `json:"crypto"` } type keystoreCrypto struct { @@ -38,7 +43,7 @@ type keystoreCrypto struct { Nonce string `json:"nonce"` // hex (12 bytes) KDF string `json:"kdf"` // "pbkdf2-sha256" KDFParams pbkdf2Params `json:"kdfparams"` - MAC string `json:"mac"` // hex SHA256(aes-key || ciphertext) + MAC string `json:"mac"` // hex authentication MAC } type pbkdf2Params struct { @@ -49,6 +54,20 @@ type pbkdf2Params struct { // EncryptMnemonic encrypts the mnemonic phrase with password and returns a // KeystoreV1 struct that can be marshalled to JSON for persistent storage. func EncryptMnemonic(mnemonic, password, address string) (*KeystoreV1, error) { + return encryptMnemonic(mnemonic, password, address, nil, nil) +} + +// EncryptMnemonicWithDerivation creates a version 2 keystore that authenticates +// the exact HD indices and address. EncryptMnemonic remains available for +// reading/writing legacy version 1 keystores without derivation metadata. +func EncryptMnemonicWithDerivation(mnemonic, password, address string, account, index uint32) (*KeystoreV1, error) { + if account >= hardenedOffset || index >= hardenedOffset { + return nil, fmt.Errorf("keystore: account and index must be less than %d", hardenedOffset) + } + return encryptMnemonic(mnemonic, password, address, &account, &index) +} + +func encryptMnemonic(mnemonic, password, address string, account, index *uint32) (*KeystoreV1, error) { if mnemonic == "" { return nil, errors.New("keystore: mnemonic is empty") } @@ -80,13 +99,24 @@ func EncryptMnemonic(mnemonic, password, address string) (*KeystoreV1, error) { } ciphertext := gcm.Seal(nil, nonce, []byte(mnemonic), nil) - // MAC = SHA256(aes_key || ciphertext) for tamper detection + // Preserve the legacy MAC for keystores without derivation metadata. + // New metadata-bearing keystores authenticate the address and indices + // so an edited path cannot silently relabel the wallet. mac := computeMAC(aesKey, ciphertext) + if account != nil && index != nil { + mac = computeMACWithDerivation(aesKey, ciphertext, address, *account, *index) + } + version := 1 + if account != nil && index != nil { + version = 2 + } return &KeystoreV1{ - Version: 1, - Address: address, - CreatedAt: time.Now().UTC().Format(time.RFC3339), + Version: version, + Address: address, + AccountIndex: account, + AddressIndex: index, + CreatedAt: time.Now().UTC().Format(time.RFC3339), Crypto: keystoreCrypto{ Cipher: "aes-256-gcm", CipherText: hex.EncodeToString(ciphertext), @@ -101,10 +131,46 @@ func EncryptMnemonic(mnemonic, password, address string) (*KeystoreV1, error) { }, nil } +// VerifyDerivation ensures a requested derivation matches the authenticated +// metadata in version 2 keystores and the stored address in legacy records. +func (ks *KeystoreV1) VerifyDerivation(account, index uint32, address string) error { + if ks == nil { + return errors.New("keystore: missing keystore") + } + if account >= hardenedOffset || index >= hardenedOffset { + return fmt.Errorf("keystore: account and index must be less than %d", hardenedOffset) + } + switch ks.Version { + case 1: + if ks.AccountIndex != nil || ks.AddressIndex != nil { + return errors.New("keystore: version 1 cannot contain derivation metadata") + } + case 2: + if ks.AccountIndex == nil || ks.AddressIndex == nil { + return errors.New("keystore: version 2 requires derivation metadata") + } + default: + return fmt.Errorf("keystore: unsupported version %d", ks.Version) + } + if ks.AccountIndex != nil && *ks.AccountIndex != account { + return errors.New("keystore: account index does not match") + } + if ks.AddressIndex != nil && *ks.AddressIndex != index { + return errors.New("keystore: address index does not match") + } + if ks.Address == "" || address == "" || ks.Address != address { + return errors.New("keystore: derived address does not match stored address") + } + return nil +} + // DecryptMnemonic decrypts a KeystoreV1 record using the provided password. // Returns the plaintext mnemonic or an error if the password is wrong. func DecryptMnemonic(ks *KeystoreV1, password string) (string, error) { - if ks.Version != 1 { + if ks == nil { + return "", errors.New("keystore: missing keystore") + } + if ks.Version != 1 && ks.Version != 2 { return "", fmt.Errorf("keystore: unsupported version %d", ks.Version) } if ks.Crypto.Cipher != "aes-256-gcm" { @@ -135,8 +201,20 @@ func DecryptMnemonic(ks *KeystoreV1, password string) (string, error) { } aesKey := pbkdf2SHA256([]byte(password), salt, iter, keyLen) - // Verify MAC before decryption to detect wrong password early - gotMAC := computeMAC(aesKey, ciphertext) + // Verify the legacy or derivation-bound MAC before decryption. + var gotMAC []byte + switch ks.Version { + case 1: + if ks.AccountIndex != nil || ks.AddressIndex != nil { + return "", errors.New("keystore: version 1 cannot contain derivation metadata") + } + gotMAC = computeMAC(aesKey, ciphertext) + case 2: + if ks.AccountIndex == nil || ks.AddressIndex == nil { + return "", errors.New("keystore: version 2 requires derivation metadata") + } + gotMAC = computeMACWithDerivation(aesKey, ciphertext, ks.Address, *ks.AccountIndex, *ks.AddressIndex) + } if !macEqual(gotMAC, expectedMAC) { return "", errors.New("keystore: wrong password or corrupted keystore") } @@ -180,6 +258,21 @@ func computeMAC(key, ciphertext []byte) []byte { return h.Sum(nil) } +func computeMACWithDerivation(key, ciphertext []byte, address string, account, index uint32) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte("aperod/keystore-v1/hd-derivation\x00")) + h.Write(ciphertext) + var addressLength [4]byte + binary.BigEndian.PutUint32(addressLength[:], uint32(len(address))) + h.Write(addressLength[:]) + h.Write([]byte(address)) + var indices [8]byte + binary.BigEndian.PutUint32(indices[:4], account) + binary.BigEndian.PutUint32(indices[4:], index) + h.Write(indices[:]) + return h.Sum(nil) +} + func macEqual(a, b []byte) bool { if len(a) != len(b) { return false diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index d2f6483..0c1d54b 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -238,6 +238,27 @@ func TestDeriveKeysAccountIndex(t *testing.T) { } } +func TestDeriveKeysRejectsIndicesWithHardenedBitSet(t *testing.T) { + seed := make([]byte, 64) + for _, tc := range []struct { + name string + account uint32 + index uint32 + }{ + {name: "account hardened bit", account: hardenedOffset, index: 0}, + {name: "account max uint32", account: ^uint32(0), index: 0}, + {name: "address hardened bit", account: 0, index: hardenedOffset}, + {name: "address max uint32", account: 0, index: ^uint32(0)}, + } { + t.Run(tc.name, func(t *testing.T) { + if derived, err := DeriveKeys(seed, tc.account, tc.index); err == nil || derived != nil { + t.Fatalf("DeriveKeys(%d, %d) = (%v, %v), want an error and no keys", + tc.account, tc.index, derived, err) + } + }) + } +} + // ─── Keystore encrypt / decrypt round-trip ──────────────────────────────── func TestKeystoreRoundTrip(t *testing.T) { @@ -296,6 +317,110 @@ func TestKeystoreMarshalUnmarshal(t *testing.T) { } } +func TestKeystoreDerivationMetadataRoundTripAndVerification(t *testing.T) { + const ( + account = uint32(17) + index = uint32(4) + address = "apro-test-derived-address" + ) + ks, err := EncryptMnemonicWithDerivation("test mnemonic", "pass", address, account, index) + if err != nil { + t.Fatalf("EncryptMnemonicWithDerivation: %v", err) + } + if ks.Version != 2 { + t.Fatalf("version = %d, want 2 for authenticated derivation metadata", ks.Version) + } + data, err := ks.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + decoded, err := UnmarshalKeystore(data) + if err != nil { + t.Fatalf("UnmarshalKeystore: %v", err) + } + if decoded.AccountIndex == nil || *decoded.AccountIndex != account { + t.Fatalf("account_index = %v, want %d", decoded.AccountIndex, account) + } + if decoded.AddressIndex == nil || *decoded.AddressIndex != index { + t.Fatalf("address_index = %v, want %d", decoded.AddressIndex, index) + } + if err := decoded.VerifyDerivation(account, index, address); err != nil { + t.Fatalf("VerifyDerivation matching path: %v", err) + } + if err := decoded.VerifyDerivation(account+1, index, address); err == nil { + t.Fatal("VerifyDerivation accepted a different account") + } + if err := decoded.VerifyDerivation(account, index+1, address); err == nil { + t.Fatal("VerifyDerivation accepted a different address index") + } + if err := decoded.VerifyDerivation(account, index, "different-address"); err == nil { + t.Fatal("VerifyDerivation accepted an address mismatch") + } + if _, err := DecryptMnemonic(decoded, "pass"); err != nil { + t.Fatalf("DecryptMnemonic with intact metadata: %v", err) + } + + tamperedIndex := *decoded + otherAccount := account + 1 + tamperedIndex.AccountIndex = &otherAccount + if _, err := DecryptMnemonic(&tamperedIndex, "pass"); err == nil { + t.Fatal("DecryptMnemonic accepted modified derivation metadata") + } + tamperedAddress := *decoded + tamperedAddress.Address = "modified-address" + if _, err := DecryptMnemonic(&tamperedAddress, "pass"); err == nil { + t.Fatal("DecryptMnemonic accepted a modified stored address") + } + strippedMetadata := *decoded + strippedMetadata.AccountIndex = nil + strippedMetadata.AddressIndex = nil + if _, err := DecryptMnemonic(&strippedMetadata, "pass"); err == nil { + t.Fatal("DecryptMnemonic accepted derivation metadata stripped from a new keystore") + } + if err := strippedMetadata.VerifyDerivation(account, index, address); err == nil { + t.Fatal("VerifyDerivation accepted derivation metadata stripped from a version 2 keystore") + } + downgraded := strippedMetadata + downgraded.Version = 1 + if _, err := DecryptMnemonic(&downgraded, "pass"); err == nil { + t.Fatal("DecryptMnemonic accepted a version downgrade with stripped metadata") + } +} + +func TestLegacyKeystoreWithoutDerivationMetadataRemainsReadable(t *testing.T) { + ks, err := EncryptMnemonic("legacy mnemonic", "pass", "legacy-address") + if err != nil { + t.Fatalf("EncryptMnemonic: %v", err) + } + if ks.Version != 1 { + t.Fatalf("legacy version = %d, want 1", ks.Version) + } + if ks.AccountIndex != nil || ks.AddressIndex != nil { + t.Fatal("legacy EncryptMnemonic unexpectedly added derivation metadata") + } + data, err := ks.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + decoded, err := UnmarshalKeystore(data) + if err != nil { + t.Fatalf("UnmarshalKeystore: %v", err) + } + phrase, err := DecryptMnemonic(decoded, "pass") + if err != nil { + t.Fatalf("DecryptMnemonic: %v", err) + } + if phrase != "legacy mnemonic" { + t.Fatalf("decrypted phrase = %q, want legacy mnemonic", phrase) + } + if err := decoded.VerifyDerivation(0, 0, "legacy-address"); err != nil { + t.Fatalf("legacy address verification: %v", err) + } + if err := decoded.VerifyDerivation(1, 0, "different-address"); err == nil { + t.Fatal("legacy keystore accepted an address inconsistent with the selected account") + } +} + // ─── wallet.Scanner (task 3.1.4) ────────────────────────────────────────── // buildTestBlock creates a block where tx outputs are sent to recipient keys.