diff --git a/README.md b/README.md index d8ddacc..4e82827 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RingCT transaction privacy  ·  CLSAG v5 active  ·  Dynamic base-fee burn  ·  Permissionless BFT-PoS -[![Go](https://img.shields.io/badge/Go-1.26.9-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) +[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.25) [![License](https://img.shields.io/badge/License-Mixed-blue.svg)](#-license) [![Network](https://img.shields.io/badge/Network-Mainnet-brightgreen)](https://aperod.com) [![Explorer](https://img.shields.io/badge/Explorer-explorer.aperod.com-informational)](https://explorer.aperod.com) @@ -447,7 +447,7 @@ aperod-node/ ### Prerequisites -- **Go 1.26.9+** — [install](https://go.dev/doc/install) +- **Go 1.25+** — [install](https://go.dev/doc/install) - `make`, `git` ### Clone & build @@ -653,7 +653,3 @@ under their own terms. Email - -## Reviewed source updates - -After the history transition, use the complete deployment-tools package and an explicitly reviewed revision. Do not force-reset old node clones. See [installation and update requirements](deploy/NODE-SOURCE-UPDATE.md). Clean source provenance is not proof of live chain compatibility. diff --git a/api/audit_authority.go b/api/audit_authority.go new file mode 100644 index 0000000..58578d4 --- /dev/null +++ b/api/audit_authority.go @@ -0,0 +1,81 @@ +package api + +import ( + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// SetAuditValidatorAuthorityFork accepts only the config-pinned rule loaded by +// node startup. It is not exposed through HTTP and never accepts a request key. +func (s *Server) SetAuditValidatorAuthorityFork(f *core.ValidatorAuthorityFork) error { + if f == nil { + s.validatorAuthorityFork = nil + return nil + } + if err := f.Validate(); err != nil { + return err + } + if s.blockStore == nil { + return fmt.Errorf("audit authority requires canonical storage") + } + genesis, found, err := s.blockStore.GetCanonicalHash(0) + if err != nil || !found || genesis != f.Genesis { + return fmt.Errorf("audit authority genesis binding mismatch") + } + _, height, err := s.blockStore.GetTip() + if err != nil { + return err + } + if height >= f.Height-1 { + parent, found, err := s.blockStore.GetCanonicalHash(f.Height-1) + if err != nil || !found || parent != f.Parent { + return fmt.Errorf("audit authority activation-parent binding mismatch") + } + } + s.validatorAuthorityFork = f.Clone() + return nil +} + +// Resolve collateral identity separately from the loaded signing key. A retired +// key must fail even when its public bytes still identify the local collateral. +func (s *Server) localValidatorIdentity(next bool) (crypto.ValidatorPubKey, error) { + if s.myKey == nil { + return nil, fmt.Errorf("local validator key unavailable") + } + signing := s.myKey.Public() + if s.validatorAuthorityFork == nil { + return signing, nil + } + var height uint64 + if s.blockStore != nil { + _, h, err := s.blockStore.GetTip() + if err != nil { return nil, err } + height = h + } else if s.chain != nil && s.chain.Tip() != nil { + height = s.chain.Tip().Header.Height + } else { + return nil, fmt.Errorf("canonical validator authority height unavailable") + } + if next { + if height == ^uint64(0) { return nil, fmt.Errorf("validator authority height overflow") } + height++ + } + identity := s.validatorAuthorityFork.LocalIdentity(signing, height) + if !s.validatorAuthorityFork.Authority(identity, height).Equals(signing) { + return nil, fmt.Errorf("loaded key is not the authorized collateral signer at this height") + } + return identity, nil +} + +func (s *Server) verifyAuditHeader(header *core.BlockHeader) bool { + copy := *header + if s.validatorAuthorityFork == nil { + return copy.VerifySignature() + } + if err := s.validatorAuthorityFork.CheckParent(copy); err != nil { + return false + } + return copy.VerifySignature(s.validatorAuthorityFork.Authority(copy.ValidatorPub, copy.Height)) +} \ No newline at end of file diff --git a/api/audit_authority_test.go b/api/audit_authority_test.go new file mode 100644 index 0000000..c2dd729 --- /dev/null +++ b/api/audit_authority_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestAuditHeaderAuthorityEraAndRetiredKeyRejection(t *testing.T) { + old, oldPub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + next, nextPub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + rule := &core.ValidatorAuthorityFork{ + Version: 1, Height: 5, Identity: oldPub, Replacement: nextPub, + Genesis: crypto.HashBytes([]byte("genesis")), Parent: crypto.HashBytes([]byte("parent")), + RegistrySHA256: crypto.HashBytes([]byte("registry")), + } + rule.Possession, err = next.Sign(rule.Message()) + if err != nil { t.Fatal(err) } + if err := rule.Validate(); err != nil { t.Fatal(err) } + s := &Server{validatorAuthorityFork: rule.Clone()} + for _, height := range []uint64{4, 5, 6} { + for _, signing := range []struct{ key crypto.ValidatorPrivKey; replacement bool }{ + {old, false}, {next, true}, + } { + header := core.BlockHeader{Height: height, ValidatorPub: oldPub, PrevHash: rule.Parent} + header.Signature, err = signing.key.Sign(header.Hash()) + if err != nil { t.Fatal(err) } + want := signing.replacement == (height >= rule.Height) + if got := s.verifyAuditHeader(&header); got != want { + t.Fatalf("height=%d replacement=%v got=%v want=%v", height, signing.replacement, got, want) + } + if height == rule.Height && want { + header.PrevHash = crypto.HashBytes([]byte("wrong-parent")) + header.Signature, _ = next.Sign(header.Hash()) + if s.verifyAuditHeader(&header) { t.Fatal("accepted wrong activation parent") } + } + } + } + if err := s.SetAuditValidatorAuthorityFork(&core.ValidatorAuthorityFork{}); err == nil { + t.Fatal("accepted unvalidated authority rule") + } +} \ No newline at end of file diff --git a/api/authority_staking_integration_test.go b/api/authority_staking_integration_test.go new file mode 100644 index 0000000..a7d5b5d --- /dev/null +++ b/api/authority_staking_integration_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T) { + var retired crypto.ValidatorPrivKey + db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired) + defer db.Close() + rec := httptest.NewRecorder() + s.restLPoDAuditDaily(rec, httptest.NewRequest(http.MethodGet, "/?date=2024-01-02", nil)) + var result struct { + Status string `json:"status"` + Reason string `json:"reason"` + Coverage struct { Blocks int `json:"blocks"` } `json:"coverage"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } + if result.Status != "complete" || result.Coverage.Blocks != 8 { + t.Fatalf("full rotated daily audit: %s", rec.Body.String()) + } + hash, found, err := db.GetCanonicalHash(3) + if err != nil || !found { t.Fatal("missing stake-operation block") } + raw, err := db.GetRawBlock(hash) + if err != nil { t.Fatal(err) } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { t.Fatal(err) } + record, err := db.LoadLPoDAuditAtBounded(hash, dailyJobMaxBytes) + if err != nil { t.Fatal(err) } + // The signer must be checked, not merely accepted because the identity + // owns collateral. A different/retired authority must fail replay. + stakeCount := 0 + for i := range block.Txs { + if !block.Txs[i].IsStake() { continue } + stakeCount++ + action, pub, amount, _, err := core.DecodeStakeExtra(block.Txs[i].Extra) + if err != nil { t.Fatal(err) } + sig, err := retired.Sign(core.StakeSignMsg(action, pub, amount)) + if err != nil { t.Fatal(err) } + block.Txs[i].Extra, err = core.EncodeStakeExtra(action, pub, amount, sig) + if err != nil { t.Fatal(err) } + } + if stakeCount != 2 { t.Fatalf("fixture must exercise deposit and withdrawal, got %d", stakeCount) } + if _, _, err := s.verifyDailyRegistryEvidence(context.Background(), &block, record, nil); err == nil || !strings.Contains(err.Error(), "replay canonical stake transactions") { + t.Fatalf("retired signature must fail stake replay, got %v", err) + } +} + +func TestRotatedLocalStakingUsesCollateralIdentity(t *testing.T) { + var retired crypto.ValidatorPrivKey + db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired) + defer db.Close() + identity := s.validatorAuthorityFork.Identity + got, err := s.localValidatorIdentity(true) + if err != nil || !got.Equals(identity) { t.Fatalf("local collateral: %x, %v", got, err) } + rec := httptest.NewRecorder() + s.restMyValidator(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + var result struct { PubKey string `json:"pub_key"` } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } + if rec.Code != 200 || result.PubKey != identity.Hex() { t.Fatalf("identity response: %s", rec.Body.String()) } + // Cross-identity checks precede the UTXO preflight. A retained identity + // reaches preflight, while a signing-key-as-identity request is forbidden. + registry := core.NewValidatorRegistry() + s.SetRegistry(registry) + for _, tc := range []struct{ pub crypto.ValidatorPubKey; forbidden bool }{ + {identity, false}, {s.myKey.Public(), true}, + } { + body := `{"pub_key":"`+tc.pub.Hex()+`","amount_napr":1,"utxo_txhash":"invalid","output_index":0}` + rec := httptest.NewRecorder() + s.restAdminStakeDeposit(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if (rec.Code == http.StatusForbidden) != tc.forbidden { t.Fatalf("target=%x status=%d body=%s", tc.pub, rec.Code, rec.Body.String()) } + } + retiredKey, err := crypto.NewLockedValidatorKey(retired.Bytes(), nil) + if err != nil { t.Fatal(err) } + defer retiredKey.Destroy() + s.SetValidatorKey(retiredKey) + if _, err := s.localValidatorIdentity(true); err == nil { t.Fatal("retired collateral signer accepted") } + rec = httptest.NewRecorder() + s.restMyValidator(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusForbidden { t.Fatal("retired local key advertised as active") } +} \ No newline at end of file diff --git a/api/genesis_vesting.go b/api/genesis_vesting.go new file mode 100644 index 0000000..7c3f88f --- /dev/null +++ b/api/genesis_vesting.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package api + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func (s *Server) SetGenesisVestingConfig(height uint64) { s.genesisVestingHeight = height } + +// Public execution evidence comes from the committed reserve ledger, never +// from editable SQL transaction hashes or the visitor's wall-clock countdown. +func (s *Server) restGenesisVesting(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only"); return } + out := map[string]interface{}{ + "state": "disabled", "activation_height": s.genesisVestingHeight, + "recipient": store.GenesisVestingRecipient, + "total_napro": strconv.FormatUint(store.GenesisVestingReserve, 10), + "funding_basis": store.GenesisVestingBasis, + "historical_issuance_proven": false, + "remaining_napro": nil, "paid_napro": nil, + } + if s.genesisVestingHeight > 0 { out["state"] = "pending_activation" } + var v *store.GenesisVestingState + var settledHeight uint64 + if s.blockStore != nil { + checkpoint, err := s.blockStore.LoadLPoDCheckpoint() + if err != nil { writeJSONError(w, 503, "canonical reserve unavailable"); return } + if checkpoint != nil && checkpoint.GenesisVesting != nil { + v = checkpoint.GenesisVesting + settledHeight = checkpoint.State.LastHeight + out["state"] = "active" + out["activation_height"] = v.FundingHeight + out["remaining_napro"] = strconv.FormatUint(v.Remaining, 10) + out["paid_napro"] = strconv.FormatUint(v.Paid, 10) + out["reserve"] = v +if v.LiquidityPaid > 0 { +hash, ok, err := s.blockStore.GetCanonicalHash(v.FundingHeight) +if err != nil || !ok { writeJSONError(w, 503, "liquidity funding unavailable"); return } +raw, err := s.blockStore.GetRawBlockByHeight(v.FundingHeight) +var funding core.Block +if err != nil || json.Unmarshal(raw, &funding) != nil || funding.Hash() != hash || len(funding.Txs) < 2 { +writeJSONError(w, 503, "liquidity funding body unavailable"); return +} +liquidityTxHash := funding.Txs[0].Hash() +out["liquidity"] = map[string]interface{}{ +"address": v.LiquidityRecipient, "funded_napro": strconv.FormatUint(v.LiquidityPaid, 10), +"funding_height": v.FundingHeight, "block_hash": hex.EncodeToString(hash[:]), +"tx_hash": hex.EncodeToString(liquidityTxHash[:]), +"finalized": s.lpodFinalized != nil && s.lpodFinalized(v.FundingHeight, hash), +"custody": "operator_treasury_wallet", +} +} + } + } + rows := make([]map[string]interface{}, 0, 10) + for i := 0; i < 10; i++ { + deadline, amount, _ := store.GenesisVestingTranche(i) + row := map[string]interface{}{ + "year": 2027+i, "unlock_date_utc": time.Unix(0, deadline).UTC().Format("2006-01-02"), + "amount_napro": strconv.FormatUint(amount, 10), "address": store.GenesisVestingRecipient, + "paid_height": nil, "block_hash": nil, "tx_hash": nil, "finalized": false, + } + if v != nil && v.PaidHeights[i] > 0 { + h := v.PaidHeights[i] + raw, err := s.blockStore.GetRawBlockByHeight(h) + var b core.Block + if err != nil || json.Unmarshal(raw, &b) != nil || b.Header.Height != h || + len(b.Txs) < 2 || !b.Txs[0].IsLPoDPayout() { + writeJSONError(w, 503, "canonical vesting payment body unavailable"); return + } + hash, ok, err := s.blockStore.GetCanonicalHash(h) + if err != nil || !ok || hash != b.Hash() { + writeJSONError(w, 503, "vesting payment is not canonical"); return + } + txHash := b.Txs[0].Hash() + row["paid_height"], row["block_hash"], row["tx_hash"] = h, hex.EncodeToString(hash[:]), hex.EncodeToString(txHash[:]) + if s.lpodFinalized != nil { + row["finalized"] = s.lpodFinalized(h, hash) + // A finalized descendant's signed ledger also commits these + // payments; old height-specific in-memory caches may expire. + if settledHeight > h { + tip, tipHeight, err := s.blockStore.GetTip() + if err == nil && tipHeight == settledHeight && s.lpodFinalized(tipHeight, tip) { + row["finalized"] = true + } + } + } + } + rows = append(rows, row) + } + out["schedule"] = rows + writeJSON(w, http.StatusOK, out) +} \ No newline at end of file diff --git a/api/genesis_vesting_test.go b/api/genesis_vesting_test.go new file mode 100644 index 0000000..f2c9563 --- /dev/null +++ b/api/genesis_vesting_test.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package api_test + +import ( + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) { + path := t.TempDir() + db, migration, leader, signer := syntheticLPoDV3FundingAt(t, path, true) + activation := migration.Height+1 +treasury, err := crypto.GenerateWalletKeys() +if err != nil { t.Fatal(err) } +treasuryAddress := crypto.AddressFromKeys(crypto.MainnetByte, treasury) +proof := &store.GenesisVestingFunding{Version: 2, Genesis: migration.Genesis, Snapshot: migration.SnapshotRoot, +Height: activation, Recipient: store.GenesisVestingRecipient, Reserve: store.GenesisVestingReserve, +OtherAllocations: store.GenesisNominalTotal-store.GenesisVestingReserve-store.GenesisLiquidityAmount, Trust: store.GenesisVestingTrust, +LiquidityRecipient: treasuryAddress, LiquidityAmount: store.GenesisLiquidityAmount, LiquidityTrust: store.GenesisLiquidityTrust} + signature, err := signer.Sign(proof.Message()) +if err != nil { t.Fatal(err) } +proof.Attestations = []store.LPoDAttestation{{Validator: signer.Public(), Signature: signature}} + commit := func(timestamp int64, tamper bool) *core.Block { + t.Helper() + parent, height, err := db.GetTip() + if err != nil { t.Fatal(err) } + r := &store.LPoDSettlement{Parent: parent, PositionProtocol: true, Timestamp: timestamp, + Proposer: signer.Public().Hex(), Leader: leader, GenesisVestingActivationHeight: activation, +GenesisVestingFunding: proof, +Stake: map[string]store.LPoDValidatorStake{signer.Public().Hex(): {Amount: 100_000*100_000_000, Active: true}}} + next, payments, err := db.PreviewLPoD(height+1, r) + if err != nil { t.Fatal(err) } + payout, err := db.PayoutLPoD(height+1, r, next, payments) + if err != nil { t.Fatal(err) } + if tamper { payout.Outputs[0].EncAmount[0] ^= 1 } + b := &core.Block{Header: core.BlockHeader{Height: height+1, PrevHash: parent, + Timestamp: timestamp, ValidatorPub: signer.Public()}, + Txs: []core.Transaction{payout, core.LPoDCheckpointTx(next.Digest())}} + b.Header.MerkleRoot = core.MerkleRoot(b.Txs) + if err := b.Header.Sign(signer); err != nil { t.Fatal(err) } + raw, _ := json.Marshal(b) + err = db.CommitRawBlockWithAVM(b.Hash(), height+1, raw, nil, crypto.Hash32{}, r) + if tamper { + if err == nil { t.Fatal("re-signed mutated payment accepted") } + tip, _, _ := db.GetTip() + if tip != parent { t.Fatal("failed payment changed canonical tip") } + return b + } + if err != nil { t.Fatal(err) } + return b + } + deadline, amount, _ := store.GenesisVestingTranche(0) + funding := commit(deadline-1, false) +matchedLiquidity := 0 +for _, output := range funding.Txs[0].Outputs { +hs, err := crypto.ScanForOutput(treasury.View.Private, treasury.Spend.Public, output.TxPubKey, output.OneTimePub) +if err == nil && hs != nil { +if core.DecryptAmount(output.EncAmount, hs) != store.GenesisLiquidityAmount { t.Fatal("treasury amount is not exactly 300M") } +matchedLiquidity++ +} +} +if matchedLiquidity != 1 { t.Fatal("treasury did not receive one canonical spendable output") } + c, err := db.LoadLPoDCheckpoint() + if err != nil || c.GenesisVesting == nil || c.GenesisVesting.Paid != 0 || + c.Allocation.Remaining != migration.NominalEligible-c.State.FundingDebit { + t.Fatalf("canonical earmark failed: %v", err) + } + commit(deadline, true) + payment := commit(deadline, false) + c, err = db.LoadLPoDCheckpoint() + if err != nil || c.GenesisVesting.Paid != amount { t.Fatalf("canonical payment failed: %v", err) } + // Reconstruct the standard wallet-scannable output from public protocol + // evidence; neither a fake tx hash nor a changed amount is acceptable. + expected, err := core.BuildLPoDPayoutOutput(store.GenesisVestingRecipient, amount, + payment.Header.Height, payment.Header.PrevHash, c.Digest()) +matches := 0 +for _, output := range payment.Txs[0].Outputs { if output == expected { matches++ } } +if err != nil || matches != 1 { t.Fatal("recipient or exact amount mismatch") } + if err := db.Close(); err != nil { t.Fatal(err) } + db, err = store.Open(path) + if err != nil { t.Fatal(err) } + t.Cleanup(func() { db.Close() }) + commit(deadline+1, false) + c, err = db.LoadLPoDCheckpoint() + if err != nil || c.GenesisVesting.Paid != amount { t.Fatalf("restart paid twice: %v", err) } +if c.GenesisVesting.LiquidityPaid != store.GenesisLiquidityAmount { t.Fatal("restart duplicated liquidity release") } + + server := api.NewServer(":0", core.NewChain(), core.NewMempool(core.DefaultMempoolConfig()), + core.NewUTXOSet(), slog.New(slog.NewTextHandler(io.Discard, nil))) + server.SetStore(db) + server.SetLPoDConfig(migration, func(uint64, crypto.Hash32) bool { return true }) + server.SetGenesisVestingConfig(activation) + server.SetReady() + server.SetUTXOReady() + rec := httptest.NewRecorder() + server.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/genesis-vesting", nil)) + var response struct { State string; Liquidity struct { TxHash string `json:"tx_hash"`; Address string `json:"address"`; Funded string `json:"funded_napro"` }; Schedule []struct { + TxHash string `json:"tx_hash"`; Finalized bool `json:"finalized"`; Amount string `json:"amount_napro"` + } } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || rec.Code != 200 || + response.State != "active" || response.Schedule[0].TxHash == "" || !response.Schedule[0].Finalized || + response.Schedule[1].TxHash != "" { t.Fatalf("canonical schedule evidence %d %s %v", rec.Code, rec.Body, err) } +if response.Liquidity.TxHash == "" || response.Liquidity.Address != string(treasuryAddress) || response.Liquidity.Funded != "30000000000000000" { +t.Fatal("public liquidity receipt missing or incorrect") +} + var cursor [8]byte + binary.LittleEndian.PutUint64(cursor[:], migration.Height) + if err := db.PutMeta("prune_cursor", cursor[:]); err != nil { t.Fatal(err) } + if _, err := db.PruneBlocksOlderThan(payment.Header.Height+1); err != nil { t.Fatal(err) } + for _, b := range []*core.Block{funding, payment} { + raw, err := db.GetRawBlockByHeight(b.Header.Height) + var saved core.Block + if err != nil || json.Unmarshal(raw, &saved) != nil || len(saved.Txs) != 2 { + t.Fatal("annual funding/payment evidence was pruned") + } + } + // Canonical tip selection after a reorg selects its reserve checkpoint, + // not the highest/orphaned payment flag. + if err := db.PutTip(funding.Hash(), funding.Header.Height); err != nil { t.Fatal(err) } + c, err = db.LoadLPoDCheckpoint() + if err != nil || c.GenesisVesting.Paid != 0 { t.Fatalf("orphan payment flag survived: %v", err) } +} \ No newline at end of file diff --git a/api/genesis_vesting_wallet_test.go b/api/genesis_vesting_wallet_test.go new file mode 100644 index 0000000..28b60c8 --- /dev/null +++ b/api/genesis_vesting_wallet_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package api_test + +import ( + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// A controlled-key positive control proves that the same native output format +// supports receipt and an actual CLSAG spend of the FULL annual-size value. +// This is not a claim to possess or test the production recipient's keys. +func TestGenesisVestingAnnualSizeNativeReceiptAndSpend(t *testing.T) { + _, amount, _ := store.GenesisVestingTranche(0) + testNativeReserveReceiptAndSpend(t, amount) +} + +func TestGenesisVestingLiquiditySizeNativeReceiptAndSpend(t *testing.T) { + testNativeReserveReceiptAndSpend(t, store.GenesisLiquidityAmount) +} + +func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) { + owner, err := crypto.GenerateWalletKeys() + if err != nil { t.Fatal(err) } + address := crypto.AddressFromKeys(crypto.MainnetByte, owner) + parent := crypto.HashBytes([]byte("annual-output-control-parent")) + digest := crypto.HashBytes([]byte("annual-output-control-checkpoint")) + utxos := core.NewUTXOSet() + var controlled core.OwnedUTXO + for i := uint64(1); i <= 20; i++ { + to := address + if i > 1 { + other, err := crypto.GenerateWalletKeys() + if err != nil { t.Fatal(err) } + to = crypto.AddressFromKeys(crypto.MainnetByte, other) + } + out, err := core.BuildLPoDPayoutOutput(to, amount, i, parent, digest) + if err != nil { t.Fatal(err) } + tx := core.Transaction{Version: core.TxVersionLPoDPayout} + tx.Outputs = append(tx.Outputs, out) + b := &core.Block{Header: core.BlockHeader{Height: i}, Txs: []core.Transaction{tx}} + utxos.ApplyBlock(b) + if i == 1 { + u := utxos.Get(tx.Hash(), 0) + hs, err := crypto.ScanForOutput(owner.View.Private, owner.Spend.Public, out.TxPubKey, out.OneTimePub) + if err != nil || hs == nil || u == nil || u.ProtocolLocked || core.DecryptAmount(out.EncAmount, hs) != amount { + t.Fatal("annual-size output is not owned, exact and spendable") + } + blind, err := crypto.DeterministicPaymentBlind(*hs, amount) + if err != nil { t.Fatal(err) } + controlled = core.OwnedUTXO{UTXO: *u, Amount: amount, Blind: blind, HsScalar: *hs} + } + } + recipient, err := crypto.GenerateWalletKeys() + if err != nil { t.Fatal(err) } + spend, err := core.NewTxBuilder(owner.Spend.Private, owner.View.Private, owner.Spend.Public, + []core.OwnedUTXO{controlled}, core.InitialBaseFeePerByte). + WithVersion(core.TxVersionCLSAG).WithDecoySet(utxos). + Build(100_000_000, crypto.AddressFromKeys(crypto.MainnetByte, recipient), address) + if err != nil { t.Fatal(err) } + if err := core.NewTxVerifier(utxos).VerifyTx(&spend.Tx); err != nil { + t.Fatalf("annual-size native output cannot be spent: %v", err) + } +} \ No newline at end of file diff --git a/api/lpod_audit_daily.go b/api/lpod_audit_daily.go index 9edc9ab..a426061 100644 --- a/api/lpod_audit_daily.go +++ b/api/lpod_audit_daily.go @@ -202,7 +202,7 @@ if len(raw) > dailyJobMaxBytes { return nil, crypto.Hash32{}, fmt.Errorf("block return nil, crypto.Hash32{}, fmt.Errorf("invalid block body at height %d", height) } if block.Header.Height != height || block.Hash() != hash || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) || - !block.Header.VerifySignature() { +!s.verifyAuditHeader(&block.Header) { return nil, crypto.Hash32{}, fmt.Errorf("canonical block identity or Merkle proof invalid at height %d", height) } return &block, hash, nil @@ -220,7 +220,7 @@ if len(raw) > dailyJobMaxBytes { return 0, fmt.Errorf("timestamp probe exceeds d var block core.Block if err := json.Unmarshal(raw, &block); err == nil && block.Header.Height == height && block.Hash() == hash && - block.Header.MerkleRoot == core.MerkleRoot(block.Txs) && block.Header.VerifySignature() { +block.Header.MerkleRoot == core.MerkleRoot(block.Txs) && s.verifyAuditHeader(&block.Header) { return block.Header.Timestamp, nil } // Pruned StoredBlock metadata is unsigned and is only a binary-search @@ -427,6 +427,7 @@ AnchorHeight: anchorHeight, WitnessHeight: witnessHeight, Marker: markerDigest, checkpointCache := make(map[crypto.Hash32]*store.LPoDCheckpoint, 2) vaults := make(map[string]*lpodDailyVaultSum) positions := make(map[string]*lpodDailyPositionSum) +rewards := newDailyRewardProgress() var burnBase, burnIntentional, burnAVM, burnTotal uint64 var included uint64 var lastAuditDigest crypto.Hash32 @@ -447,6 +448,7 @@ fail("saved progress predecessor is no longer canonical") return } nextHeight, prevHash, priorTS = progress.Next, progress.Previous, progress.PriorTS +rewards = progress.Rewards.clone() // Old authenticated progress cannot invent new report evidence. previousBlock, _, err = readBlock(nextHeight-1) if err != nil { fail("saved predecessor body unavailable"); return } for id, v := range progress.Vaults { @@ -465,6 +467,7 @@ progress.Certificate, progress.Anchor, progress.Witness = cert, anchorHash, witn progress.AnchorHeight, progress.WitnessHeight, progress.Marker = anchorHeight, witnessHeight, markerDigest progress.Prune = append([]byte(nil), pruneCursorRaw...) progress.Next, progress.Previous, progress.PriorTS = next, prevHash, priorTS +progress.Rewards = rewards.clone() progress.Vaults = make(map[string][4]uint64, len(vaults)) for id, v := range vaults { progress.Vaults[id] = [4]uint64{v.accrued, v.leader, v.guardian, v.unfunded} } progress.Positions = make(map[string]dailyPositionProgress, len(positions)) @@ -569,6 +572,9 @@ fail("block audit entry count exceeds bound"); return return } lastAuditDigest, haveLastAuditDigest = record.AfterCheckpointDigest, true +if rewards != nil { +if err := rewards.accumulate(block, record); err != nil { fail(err.Error()); return } +} if len(vaults)+len(positions)+len(record.Vaults)+len(record.Positions) > lpodDailyMaxEntries { fail("response entry count exceeds bound") return @@ -695,6 +701,13 @@ positionList = append(positionList, dailyPositionOutput(p)) sort.Slice(positionList, func(i, j int) bool { return positionList[i].ID < positionList[j].ID }) out["vaults"], out["positions"] = vaultList, positionList out["beneficiary_version"] = 1 +if rewards != nil { +openingCheckpoint, openingErr := s.blockStore.LoadLPoDCheckpointAtBounded(anchorHash, dailyJobMaxBytes) +if openingErr == nil { +summary, summaryErr := rewards.result(openingCheckpoint, lastAuditCheckpoint) +if summaryErr == nil { out["daily_rewards"] = summary } else { out["daily_rewards_unavailable"] = summaryErr.Error() } +} else { out["daily_rewards_unavailable"] = "opening reward funds unavailable" } +} out["burns_napro"] = map[string]string{ "protocol_base_fee": strconv.FormatUint(burnBase, 10), "signed_intentional": strconv.FormatUint(burnIntentional, 10), "avm_gas": strconv.FormatUint(burnAVM, 10), "total": strconv.FormatUint(burnTotal, 10), @@ -873,6 +886,9 @@ func (s *Server) verifyDailyRegistryEvidence( } replay := core.NewValidatorRegistry() replay.RestoreFromSnapshot(replayStart) +if err := replay.ConfigureSignatureAuthority(s.validatorAuthorityFork, block.Header.Height); err != nil { +return fail(fmt.Errorf("configure pinned signature authority replay: %w", err)) +} if withdrawalV2 { if err := replay.ConfigureStakeWithdrawalV2(withdrawalGenesis, withdrawalActivation); err != nil { return fail(fmt.Errorf("configure v2 withdrawal replay: %w", err)) @@ -1172,7 +1188,7 @@ func (s *Server) verifyDailyPositionRowsWithParent( parentRaw, readErr := s.blockStore.GetRawBlock(parentHash) var decoded core.Block if readErr != nil || len(parentRaw) == 0 || json.Unmarshal(parentRaw, &decoded) != nil || - decoded.Hash() != parentHash || !decoded.Header.VerifySignature() || +decoded.Hash() != parentHash || !s.verifyAuditHeader(&decoded.Header) || decoded.Header.MerkleRoot != core.MerkleRoot(decoded.Txs) { return fail(fmt.Errorf("position accrual parent body or timestamp unavailable")) } diff --git a/api/lpod_audit_daily_benchmark_test.go b/api/lpod_audit_daily_benchmark_test.go index 680df3c..2facb65 100644 --- a/api/lpod_audit_daily_benchmark_test.go +++ b/api/lpod_audit_daily_benchmark_test.go @@ -398,6 +398,16 @@ func makeDailyAuditBenchmarkFixtureWithTail( includedBlocks, fillerCount, extraBytes, vaultCount int, preludeBlocks, positionCount, tailBlocks int, misorderFirstIncluded ...bool, +) (*store.DB, *Server, string, int64) { +misorder := len(misorderFirstIncluded) > 0 && misorderFirstIncluded[0] +return makeDailyAuditBenchmarkFixtureWithOptions(b, includedBlocks, fillerCount, extraBytes, vaultCount, +preludeBlocks, positionCount, tailBlocks, misorder, false) +} + +func makeDailyAuditBenchmarkFixtureWithOptions( +b testing.TB, includedBlocks, fillerCount, extraBytes, vaultCount int, +preludeBlocks, positionCount, tailBlocks int, misorderFirstIncluded, rotateAuthority bool, +retiredOutput ...*crypto.ValidatorPrivKey, ) (*store.DB, *Server, string, int64) { b.Helper() if preludeBlocks < 0 || tailBlocks < 0 { @@ -424,12 +434,19 @@ func makeDailyAuditBenchmarkFixtureWithTail( if err != nil { b.Fatal(err) } +if len(retiredOutput) > 0 { *retiredOutput[0] = priv } walletKeys, err := crypto.GenerateWalletKeys() if err != nil { rawDB.Close() b.Fatal(err) } leader := crypto.AddressFromKeys(crypto.MainnetByte, walletKeys) +var fork *core.ValidatorAuthorityFork +var successor crypto.ValidatorPrivKey +if rotateAuthority { +successor, _, err = crypto.GenerateValidatorKey() +if err != nil { b.Fatal(err) } +} location, err := time.LoadLocation("Europe/Moscow") if err != nil { b.Fatal(err) @@ -447,7 +464,8 @@ func makeDailyAuditBenchmarkFixtureWithTail( registryAfter := core.RegistrySnapshot{Validators: make(map[string]*core.ValidatorEntry, vaultCount)} previousStake := make(map[string]store.LPoDValidatorStake, vaultCount) stake := make(map[string]store.LPoDValidatorStake, vaultCount) - const validatorStake = uint64(100_000_000) * lpod.Unit +validatorStake := uint64(100_000_000) * lpod.Unit +if rotateAuthority { validatorStake = uint64(99_000_000) * lpod.Unit } const guardianStake = uint64(0) totalStake := validatorStake + guardianStake if vaultCount > 0 { @@ -544,7 +562,7 @@ func makeDailyAuditBenchmarkFixtureWithTail( // common benchmark scenario. timestamp = start.Add(-3 * time.Second) } - if len(misorderFirstIncluded) > 0 && misorderFirstIncluded[0] { +if misorderFirstIncluded { if height == firstIncludedHeight { timestamp = start.Add(2 * time.Hour) } else if height == firstIncludedHeight+1 { @@ -638,6 +656,12 @@ func makeDailyAuditBenchmarkFixtureWithTail( } } checkpointDigest := checkpoint.Digest() +if rotateAuthority && height == 2 { +fork = &core.ValidatorAuthorityFork{Version: 1, Height: height, Identity: pub, Replacement: successor.Public(), +Genesis: genesisHash, Parent: parentHash, RegistrySHA256: crypto.HashBytes([]byte("operator-attested-fixture"))} +fork.Possession, err = successor.Sign(fork.Message()) +if err != nil { b.Fatal(err) } +} txs := make([]core.Transaction, 0, 2+fillerCount+len(positionTxs)) if height > 0 { // An empty-output payout remains a valid zero-settlement commitment. @@ -650,6 +674,15 @@ func makeDailyAuditBenchmarkFixtureWithTail( core.Transaction{Version: core.TxVersionLPoDPayout, Extra: auth}, core.LPoDCheckpointTx(checkpointDigest)) txs = append(txs, positionTxs...) +if rotateAuthority && height == 3 { +for _, action := range []core.StakeAction{core.StakeDeposit, core.StakePartialWithdraw} { +signature, signErr := successor.Sign(core.StakeSignMsg(action, pub, core.MinStakeNAPR)) +if signErr != nil { b.Fatal(signErr) } +extra, encodeErr := core.EncodeStakeExtra(action, pub, core.MinStakeNAPR, signature) +if encodeErr != nil { b.Fatal(encodeErr) } +txs = append(txs, core.Transaction{Version: core.TxVersionStake, Extra: extra}) +} +} for n := 0; n < fillerCount; n++ { txs = append(txs, core.Transaction{Version: core.TxVersionBase, Extra: make([]byte, extraBytes)}) } @@ -658,7 +691,9 @@ func makeDailyAuditBenchmarkFixtureWithTail( Height: height, PrevHash: parentHash, Timestamp: timestamp.UnixNano(), ValidatorPub: pub, MerkleRoot: core.MerkleRoot(txs), }, Txs: txs} - if err := block.Header.Sign(priv); err != nil { +signer := priv +if fork != nil && height >= fork.Height { signer = successor } +if err := block.Header.Sign(signer); err != nil { rawDB.Close() b.Fatal(err) } @@ -689,8 +724,28 @@ func makeDailyAuditBenchmarkFixtureWithTail( } batch.Put(append([]byte("lpod/checkpoint/v1/"), hash[:]...), checkpointRaw) if height >= firstIncludedHeight && height < closingHeight { +if rotateAuthority { +beforeRaw, _ := json.Marshal(registryAfter) +registryBefore = core.RegistrySnapshot{} +if err := json.Unmarshal(beforeRaw, ®istryBefore); err != nil { b.Fatal(err) } +replay := core.NewValidatorRegistry() +var replayStart core.RegistrySnapshot +if err := json.Unmarshal(beforeRaw, &replayStart); err != nil { b.Fatal(err) } +replay.RestoreFromSnapshot(replayStart) +if err := replay.ConfigureSignatureAuthority(fork, height); err != nil { b.Fatal(err) } +if err := replay.ReplayBlockStakeTxs(txs, height); err != nil { b.Fatal(err) } +registryAfter = replay.TakeSnapshot() +previousStake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} +stake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} +if height == 3 { stake[pub.Hex()] = store.LPoDValidatorStake{Amount: validatorStake-core.MinStakeNAPR, Active: true} } +} recordVaults := append([]store.LPoDAuditVault(nil), vaultRows...) for i := range recordVaults { +if rotateAuthority && height == 3 && recordVaults[i].ID == pub.Hex() { +recordVaults[i].ValidatorStake -= core.MinStakeNAPR +recordVaults[i].TotalStake -= core.MinStakeNAPR +recordVaults[i].TierStake -= core.MinStakeNAPR +} recordVaults[i].Accrued = blockAccruedByVault[recordVaults[i].ID] recordVaults[i].Unfunded = recordVaults[i].Accrued } @@ -796,6 +851,13 @@ func makeDailyAuditBenchmarkFixtureWithTail( server.SetAPIKey(dailyAuditBenchmarkAPIKey) server.SetLPoDConfig(nil, func(uint64, crypto.Hash32) bool { return true }) benchmarkDailySigningKey(b, server) +if rotateAuthority { +key, keyErr := crypto.NewLockedValidatorKey(successor.Bytes(), nil) +if keyErr != nil { b.Fatal(keyErr) } +b.Cleanup(key.Destroy) +server.SetValidatorKey(key) +if err := server.SetAuditValidatorAuthorityFork(fork); err != nil { b.Fatal(err) } +} return db, server, path, blockBodyBytes } diff --git a/api/lpod_daily_jobs.go b/api/lpod_daily_jobs.go index 7eed6cb..ff4725d 100644 --- a/api/lpod_daily_jobs.go +++ b/api/lpod_daily_jobs.go @@ -18,7 +18,9 @@ import ( ) const dailyJobKey = "lpod/daily-job/v1" -const dailyBatchBlocks = 32 +// A full day must fit between light-pruning epochs. Keep the 250ms cooperative +// CPU budget and single worker; a block-count cap must not force a 15min scan. +const dailyBatchBlocks = 256 const dailyBatchTime = 250 * time.Millisecond const dailyJobMaxBytes = 16 << 20 @@ -39,6 +41,7 @@ type dailyProgress struct { PriorTS int64 Vaults map[string][4]uint64 Positions map[string]dailyPositionProgress +Rewards *dailyRewardProgress `json:",omitempty"` Burns [4]uint64 Included, First, Last uint64 FirstTS, LastTS int64 @@ -46,6 +49,13 @@ type dailyProgress struct { HaveDigest bool Registry *core.RegistrySnapshot } + +// Older authenticated jobs contain payout aggregates but no recipient/tier +// accumulator. Replay their bounded read-only audit instead of preserving an +// incomplete financial report forever. Never create or replay a payout here. +func dailyRewardsNeedReplay(p *dailyProgress) bool { + return p != nil && p.Next > 0 && p.Rewards == nil +} type dailyJob struct { ID int64 Updated int64 @@ -211,12 +221,13 @@ writeJSON(w, http.StatusOK, dailyPending(date, err.Error())) return } if j != nil && j.Date == date { - if (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) || +missingRewards := dailyRewardsNeedReplay(j.Progress) +if missingRewards || (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) || (j.State == "complete" && (!s.dailyBindingValid(j.Progress) || !s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash))) { // Re-attest an older complete report by verifying only its unprocessed // descendant tail. Invalidated/failed progress is never reused. - if j.State == "failed" || !s.dailyBindingValid(j.Progress) { +if missingRewards || j.State == "failed" || !s.dailyBindingValid(j.Progress) { j.Progress = &dailyProgress{} } j.ID, j.State, j.Result = time.Now().UnixNano(), "queued", nil diff --git a/api/lpod_daily_jobs_test.go b/api/lpod_daily_jobs_test.go index 33b9b7e..8c0b6f0 100644 --- a/api/lpod_daily_jobs_test.go +++ b/api/lpod_daily_jobs_test.go @@ -31,7 +31,7 @@ func enqueueDailyTest(t *testing.T, s *Server) *dailyJob { } func TestDailyJobRestartBudgetAndQueue(t *testing.T) { - db, s, path, _ := makeDailyAuditBenchmarkFixture(t, 70, 1, 128, 4, 0, 8) + db, s, path, _ := makeDailyAuditBenchmarkFixture(t, dailyBatchBlocks+70, 1, 128, 4, 0, 8) enqueueDailyTest(t, s) other := authenticatedDailyAuditRequest() other.URL.RawQuery = "date=2024-01-03" @@ -104,7 +104,7 @@ restarted.SetValidatorKey(reloadedKey) func TestDailyJobReorgAndFinalityFailClosed(t *testing.T) { for _, change := range []string{"predecessor", "certificate", "finality", "prune"} { t.Run(change, func(t *testing.T) { - db, s, path, _ := makeDailyAuditBenchmarkFixture(t, 40, 0, 0, 1, 0, 1) + db, s, path, _ := makeDailyAuditBenchmarkFixture(t, dailyBatchBlocks+40, 0, 0, 1, 0, 1) enqueueDailyTest(t, s) s.runDailyJobBatch(context.Background()) j, err := s.loadDailyJob() @@ -155,7 +155,7 @@ func TestDailyJobReorgAndFinalityFailClosed(t *testing.T) { } func TestDailyJobCancellationAndShutdown(t *testing.T) { - db, s, _, _ := makeDailyAuditBenchmarkFixture(t, 40, 0, 0, 1, 0, 0) + db, s, _, _ := makeDailyAuditBenchmarkFixture(t, dailyBatchBlocks+40, 0, 0, 1, 0, 0) defer db.Close() enqueueDailyTest(t, s) s.runDailyJobBatch(context.Background()) @@ -217,7 +217,8 @@ if err != nil { t.Fatal(err) } // retain only the latest one. Require forward progress when EVERY previously // saved certificate disappears from the oracle, not merely after 256 heights. func TestDailyJobAdvancingCertificateOracleDoesNotResetCursor(t *testing.T) { -db, s, _, _ := makeDailyAuditBenchmarkFixtureWithTail(t, 512, 1, 128, 4, 0, 4, 512) +const intervalBlocks = 4 * dailyBatchBlocks +db, s, _, _ := makeDailyAuditBenchmarkFixtureWithTail(t, intervalBlocks, 1, 128, 4, 0, 4, intervalBlocks) defer db.Close() certificate, err := db.LoadFinalityCertificate() if err != nil { t.Fatal(err) } @@ -232,7 +233,7 @@ return height == oracleHeight && hash == oracleHash enqueueDailyTest(t, s) var priorNext uint64 for batch := 0; batch < 100; batch++ { -height := initialHeight + uint64(batch)*32 +height := initialHeight + uint64(batch)*uint64(dailyBatchBlocks/2) if height > tipHeight { height = tipHeight } hash, found, err := db.GetCanonicalHash(height) if err != nil || !found { t.Fatal("missing tail certificate hash") } @@ -264,7 +265,7 @@ Status string `json:"status"` Coverage struct { Blocks int `json:"blocks"`; Positions int `json:"positions"` } `json:"coverage"` } if err := json.Unmarshal(j.Result, &result); err != nil { t.Fatal(err) } -if result.Status != "complete" || result.Coverage.Blocks != 512 || result.Coverage.Positions != 4 { +if result.Status != "complete" || result.Coverage.Blocks != intervalBlocks || result.Coverage.Positions != 4 { t.Fatalf("unexpected report after advancing certificates: %s", j.Result) } t.Logf("completed after %d batches; certificate advanced %d heights; cursor=%d; only latest certificate accepted by oracle", batch+1, oracleHeight-initialHeight, priorNext) diff --git a/api/lpod_daily_rewards.go b/api/lpod_daily_rewards.go new file mode 100644 index 0000000..77be90f --- /dev/null +++ b/api/lpod_daily_rewards.go @@ -0,0 +1,140 @@ +package api + +import ( + "fmt" + "sort" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +// Auxiliary report data, accumulated only after the existing block, payout, +// checkpoint and historical registry verification has succeeded. +type dailyRewardRecipient struct { + Address string `json:"address"` + Amount uint64 `json:"amount_napro,string"` + First uint64 `json:"first_height"` + Last uint64 `json:"last_height"` + Blocks uint64 `json:"payout_blocks"` +} + +type dailyGuardianTier struct { + Tier int `json:"tier"` + APR uint64 `json:"apr_percent"` + Amount uint64 `json:"amount_napro,string"` +} + +type dailyRewardProgress struct { + Validators map[string]dailyRewardRecipient + Tiers [9]dailyGuardianTier + People [9]map[string]bool +} + +var dailyTierThresholds = [...]uint64{100000, 500000, 1000000, 5000000, 10000000, 30000000, 50000000, 80000000, 100000000} +var dailyTierAPRs = [...]uint64{3, 5, 6, 8, 9, 9, 9, 9, 10} + +func newDailyRewardProgress() *dailyRewardProgress { + p := &dailyRewardProgress{Validators: make(map[string]dailyRewardRecipient)} + for i := range p.Tiers { + p.Tiers[i] = dailyGuardianTier{Tier: i + 1, APR: dailyTierAPRs[i]} + p.People[i] = make(map[string]bool) + } + return p +} + +func (p *dailyRewardProgress) clone() *dailyRewardProgress { + if p == nil { + return nil + } + q := newDailyRewardProgress() + q.Tiers = p.Tiers + for address, row := range p.Validators { + q.Validators[address] = row + } + for i, people := range p.People { + for address, present := range people { + q.People[i][address] = present + } + } + return q +} + +func (p *dailyRewardProgress) accumulate(block *core.Block, record *store.LPoDBlockAudit) error { + var leader uint64 + tiers := make(map[string]int, len(record.Vaults)) + for _, v := range record.Vaults { + if !sumDaily(&leader, v.ActualLeaderPaid) { + return fmt.Errorf("validator reward overflow") + } + tier := -1 + if v.TierStake == 0 && v.GuardianPaid == 0 { + continue + } + for i, threshold := range dailyTierThresholds { + if v.TierStake >= threshold*100000000 { + tier = i + } + } + if tier < 0 || v.TierStake > 100000000*100000000 || v.APRPercent != dailyTierAPRs[tier] { + return fmt.Errorf("guardian tier differs from verified stake") + } + tiers[v.ID] = tier + } + if leader > 0 { + auth, err := block.Txs[0].LPoDPayoutAuthorization() + if err != nil { + return err + } + address := string(auth.Leader) + row := p.Validators[address] + if row.Blocks == 0 { + row.Address, row.First = address, record.Height + } + if !sumDaily(&row.Amount, leader) { + return fmt.Errorf("validator daily reward overflow") + } + row.Last, row.Blocks = record.Height, row.Blocks+1 + p.Validators[address] = row + } + for _, position := range record.Positions { + if position.Accrued == 0 && position.Paid == 0 { + continue + } + tier, ok := tiers[position.EffectiveVault] + if !ok || position.Beneficiary == "" { + return fmt.Errorf("guardian reward tier or beneficiary missing") + } + if !sumDaily(&p.Tiers[tier].Amount, position.Paid) { + return fmt.Errorf("guardian daily reward overflow") + } + p.People[tier][position.Beneficiary] = true + } + return nil +} + +func (p *dailyRewardProgress) result(opening, closing *store.LPoDCheckpoint) (map[string]interface{}, error) { + if opening == nil || closing == nil || opening.Allocation == nil || closing.Allocation == nil || + closing.State.RewardInflow < opening.State.RewardInflow { + return nil, fmt.Errorf("daily opening/closing funds unavailable") + } + validators := make([]dailyRewardRecipient, 0, len(p.Validators)) + for _, row := range p.Validators { + validators = append(validators, row) + } + sort.Slice(validators, func(i, j int) bool { return validators[i].Address < validators[j].Address }) + tiers := make([]map[string]interface{}, 0, 9) + for i, row := range p.Tiers { + tiers = append(tiers, map[string]interface{}{ + "tier": i + 1, "apr_percent": row.APR, "people": len(p.People[i]), + "amount_napro": strconv.FormatUint(row.Amount, 10), + }) + } + return map[string]interface{}{ + "version": 1, "validators": validators, "guardian_tiers": tiers, + "validator_to_guardian_napro": strconv.FormatUint(closing.State.RewardInflow-opening.State.RewardInflow, 10), + "validator_remaining_napro": strconv.FormatUint(closing.Allocation.ValidatorRemaining, 10), + "guardian_reward_balance_napro": strconv.FormatUint(closing.State.Balance, 10), + "guardian_principal_napro": strconv.FormatUint(closing.PrincipalLocked, 10), + }, nil +} diff --git a/api/lpod_daily_rewards_test.go b/api/lpod_daily_rewards_test.go new file mode 100644 index 0000000..c875865 --- /dev/null +++ b/api/lpod_daily_rewards_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestDailyRewardsUniquePeopleAndHistoricalTier(t *testing.T) { + p := newDailyRewardProgress() + block := &core.Block{} + record := &store.LPoDBlockAudit{Height: 10, + Vaults: []store.LPoDAuditVault{{ID: "vault", TierStake: 6000000 * 100000000, APRPercent: 8, GuardianPaid: 3}}, + Positions: []store.LPoDAuditPosition{ + {Beneficiary: "same-owner", EffectiveVault: "vault", Paid: 1}, + {Beneficiary: "same-owner", EffectiveVault: "vault", Paid: 2}, + }} + if err := p.accumulate(block, record); err != nil { + t.Fatal(err) + } + if err := p.accumulate(block, record); err != nil { + t.Fatal(err) + } + if len(p.People[3]) != 1 || p.Tiers[3].Amount != 6 { + t.Fatal("position count substituted for people or paid sum wrong") + } + record.Vaults[0].TierStake, record.Vaults[0].APRPercent = 1000000*100000000, 6 + if err := p.accumulate(block, record); err != nil { + t.Fatal(err) + } + if len(p.People[2]) != 1 || p.Tiers[2].Amount != 3 { + t.Fatal("historical tier change omitted") + } +} + +func TestDailyRewardsValidatorRecipientAndHeight(t *testing.T) { + address := crypto.Address("aproegAWbHEE8wunGatJwGLM68f3hskmbQmT8HJik9s827DymS2jScRb2hxqAgD34qfXwfQKKDCoj2okTWeVwsi2CXtA9Ybr76") + extra, _ := json.Marshal(core.LPoDPayoutAuthorization{Leader: address}) + block := &core.Block{Txs: []core.Transaction{{Version: core.TxVersionLPoDPayout, Extra: extra}}} + p := newDailyRewardProgress() + record := &store.LPoDBlockAudit{Height: 10, Vaults: []store.LPoDAuditVault{{ID: "vault", ActualLeaderPaid: 45}}} + if err := p.accumulate(block, record); err != nil { + t.Fatal(err) + } + record.Height = 12 + if err := p.accumulate(block, record); err != nil { + t.Fatal(err) + } + r := p.Validators[string(address)] + if r.Amount != 90 || r.First != 10 || r.Last != 12 || r.Blocks != 2 { + t.Fatalf("wrong recipient aggregate: %+v", r) + } +} + +func TestDailyRewardsCheckpointCopiesDoNotLeakUncommittedChanges(t *testing.T) { + p := newDailyRewardProgress() + p.People[0]["owner"] = true + saved := p.clone() + p.People[0]["new-owner"] = true + p.Tiers[0].Amount = 100 + if len(saved.People[0]) != 1 || saved.Tiers[0].Amount != 0 { + t.Fatal("uncommitted progress leaked into saved checkpoint") + } + var absent *dailyRewardProgress + if absent.clone() != nil { + t.Fatal("old progress synthesized new monetary evidence") + } +} diff --git a/api/lpod_daily_rewards_upgrade_test.go b/api/lpod_daily_rewards_upgrade_test.go new file mode 100644 index 0000000..fe45bc6 --- /dev/null +++ b/api/lpod_daily_rewards_upgrade_test.go @@ -0,0 +1,22 @@ +package api + +import "testing" + +func TestDailyRewardsUpgradeReplaysOnlyLegacyProgress(t *testing.T) { + for _, tc := range []struct { + name string + p *dailyProgress + want bool + }{ + {"absent", nil, false}, + {"new", &dailyProgress{}, false}, + {"legacy", &dailyProgress{Next: 42}, true}, + {"detailed", &dailyProgress{Next: 42, Rewards: newDailyRewardProgress()}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := dailyRewardsNeedReplay(tc.p); got != tc.want { + t.Fatalf("needs replay = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/api/lpod_earnings_outputs_test.go b/api/lpod_earnings_outputs_test.go index b072634..ae5ff11 100644 --- a/api/lpod_earnings_outputs_test.go +++ b/api/lpod_earnings_outputs_test.go @@ -69,8 +69,17 @@ func TestLPoDEarningsOutputsServesOnlyIndexedPureRewards(t *testing.T) { } func syntheticLPoDV3Funding(t *testing.T) (*store.DB, *store.LPoDMigration, crypto.Address) { +db, m, address, _ := syntheticLPoDV3FundingWithSigner(t) +return db, m, address +} + +func syntheticLPoDV3FundingWithSigner(t *testing.T) (*store.DB, *store.LPoDMigration, crypto.Address, crypto.ValidatorPrivKey) { +return syntheticLPoDV3FundingAt(t, t.TempDir()) +} + +func syntheticLPoDV3FundingAt(t *testing.T, path string, withLiquidity ...bool) (*store.DB, *store.LPoDMigration, crypto.Address, crypto.ValidatorPrivKey) { t.Helper() - db, err := store.Open(t.TempDir()) + db, err := store.Open(path) if err != nil { t.Fatal(err) } @@ -135,6 +144,9 @@ func syntheticLPoDV3Funding(t *testing.T) (*store.DB, *store.LPoDMigration, cryp ValidatorRemaining: migration.ValidatorRemaining, } migration.NominalSnapshot = &snapshot +if len(withLiquidity) > 0 && withLiquidity[0] { +snapshot.NominalCirculatingBefore += store.GenesisLiquidityAmount +} migration.SnapshotRoot = snapshot.Root() migration.ReconciliationRoot = migration.Root() signature, err := validator.Sign(migration.AttestationMessage()) @@ -177,5 +189,5 @@ func syntheticLPoDV3Funding(t *testing.T) (*store.DB, *store.LPoDMigration, cryp if err := db.CommitRawBlockWithAVM(block.Hash(), block.Header.Height, raw, nil, crypto.Hash32{}, request); err != nil { t.Fatalf("commit synthetic v3 funding: %v", err) } - return db, migration, leader +return db, migration, leader, validator } diff --git a/api/lpod_wallet_outputs.go b/api/lpod_wallet_outputs.go index c528ffe..986b4ee 100644 --- a/api/lpod_wallet_outputs.go +++ b/api/lpod_wallet_outputs.go @@ -164,6 +164,7 @@ func (s *Server) restWalletKeyImages(w http.ResponseWriter, r *http.Request) { } var req struct { Images []string `json:"key_images"` +IncludeHistory bool `json:"include_history,omitempty"` SnapshotID string `json:"snapshot_id,omitempty"` Address string `json:"address,omitempty"` Refs []struct { @@ -176,7 +177,7 @@ func (s *Server) restWalletKeyImages(w http.ResponseWriter, r *http.Request) { return } if req.SnapshotID != "" { - s.restWalletKeyImagesSnapshot(w, req.Images, req.Refs, req.SnapshotID, req.Address) +s.restWalletKeyImagesSnapshot(w, req.Images, req.Refs, req.SnapshotID, req.Address, req.IncludeHistory) return } if s.blockStore == nil { diff --git a/api/rest.go b/api/rest.go index 5ac5a81..9200d22 100644 --- a/api/rest.go +++ b/api/rest.go @@ -46,6 +46,13 @@ func (s *Server) registerRESTRoutes() { s.mux.HandleFunc("/api/v1/validators", s.restValidators) s.mux.HandleFunc("/api/v1/validators/", s.restValidatorUnbonding) s.mux.HandleFunc("/api/v1/admin/mint", s.localOnly(s.requireAPIKey(s.restAdminMint))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/status", s.localOnly(s.requireAPIKey(s.restSaleStatus))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/readiness", s.localOnly(s.requireAPIKey(s.restSaleReadiness))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/reserve", s.localOnly(s.requireAPIKey(s.restSaleReserve))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/paid", s.localOnly(s.requireAPIKey(s.restSalePaid))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/invoice", s.localOnly(s.requireAPIKey(s.restSaleInvoice))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/receipt", s.localOnly(s.requireAPIKey(s.restSaleReceipt))) + s.mux.HandleFunc("/api/v1/admin/protocol-sales/recover", s.localOnly(s.requireAPIKey(s.restSaleRecover))) s.mux.HandleFunc("/api/v1/admin/partial-unstake", s.localOnly(s.requireAPIKey(s.restAdminPartialUnstake))) s.mux.HandleFunc("/api/v1/admin/full-unstake", s.localOnly(s.requireAPIKey(s.restAdminFullUnstake))) s.mux.HandleFunc("/api/v1/admin/stake-deposit", s.localOnly(s.requireAPIKey(s.restAdminStakeDeposit))) @@ -72,6 +79,7 @@ func (s *Server) registerRESTRoutes() { s.mux.HandleFunc("/api/v1/status", s.restStatus) s.mux.HandleFunc("/api/v1/guardian-fund", s.restGuardianFund) s.mux.HandleFunc("/api/v1/lpod-pool", s.restLPoDPool) + s.mux.HandleFunc("/api/v1/genesis-vesting", s.restGenesisVesting) s.mux.HandleFunc("/api/v1/lpod/positions", s.restLPoDPositions) s.mux.HandleFunc("/api/v1/lpod/wallet-outputs", s.restLPoDWalletOutputs) s.mux.HandleFunc("/api/v1/lpod/earnings-outputs", s.restLPoDEarningsOutputs) @@ -79,6 +87,7 @@ func (s *Server) registerRESTRoutes() { // full block bodies and requires both loopback access and the configured API key. s.mux.HandleFunc("/api/v1/lpod/audit/daily", s.localOnly(s.requireAPIKey(s.restLPoDDailyJob))) s.mux.HandleFunc("/api/v1/wallet/key-images", s.restWalletKeyImages) + s.mux.HandleFunc("/api/v1/wallet/history-transactions", s.restWalletHistoryTransactions) s.mux.HandleFunc("/api/v1/avm/status", s.restAVMStatus) s.mux.HandleFunc("/api/v1/avm/contracts/", s.restAVMContract) s.mux.HandleFunc("/api/v1/avm/receipts/", s.restAVMReceipt) @@ -243,32 +252,32 @@ func (s *Server) restBlocks(w http.ResponseWriter, r *http.Request) { total := tip.Header.Height + 1 // heights 0..tip // Return blocks from offset upward (most-recent-first when no offset given) -if offset > tip.Header.Height { + if offset > tip.Header.Height { + writeJSON(w, http.StatusOK, map[string]interface{}{"blocks": []interface{}{}, "total": total}) + return + } + startHeight := tip.Header.Height - offset + if startHeight > uint64(1<<63-1) { writeJSON(w, http.StatusOK, map[string]interface{}{"blocks": []interface{}{}, "total": total}) return } -startHeight := tip.Header.Height - offset -if startHeight > uint64(1<<63-1) { -writeJSON(w, http.StatusOK, map[string]interface{}{"blocks": []interface{}{}, "total": total}) -return -} blocks := make([]BlockResponse, 0, limit) -for h := startHeight; len(blocks) < limit; { -b := s.chain.GetByHeight(h) + for h := startHeight; len(blocks) < limit; { + b := s.chain.GetByHeight(h) if b != nil { response, err := blockToResponse(b, s.avmGasBurnActivationHeight) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } -blocks = append(blocks, response) -} -if h == 0 { -break -} -h-- -} + blocks = append(blocks, response) + } + if h == 0 { + break + } + h-- + } writeJSON(w, http.StatusOK, map[string]interface{}{ "blocks": blocks, "total": total, @@ -1921,11 +1930,14 @@ func (s *Server) restAdminPartialUnstake(w http.ResponseWriter, r *http.Request) } targetPub := crypto.ValidatorPubKey(pubBytes) - // Security: this endpoint can only unstake the LOCAL node's own validator. - // StakePartialWithdraw requires pub.Verify(sig) — only the key holder can - // authorize their own withdrawal. Cross-validator admin unstake is not + // Security: this endpoint can only unstake the local collateral identity, + // using its height-authorized signer. Cross-validator admin unstake is not // supported via REST; validators must submit their own signed tx. - myPub := s.myKey.Public() + myPub, identityErr := s.localValidatorIdentity(true) + if identityErr != nil { + writeJSONError(w, http.StatusForbidden, identityErr.Error()) + return + } if !myPub.Equals(targetPub) { writeJSONError(w, http.StatusForbidden, "this endpoint can only unstake the local node's own validator; connect to the target validator's node to initiate its withdrawal") @@ -1953,9 +1965,8 @@ func (s *Server) restAdminPartialUnstake(w http.ResponseWriter, r *http.Request) return } - // Sign with the node's own key (which IS the target validator's key). - // pub.Verify(StakeSignMsg(StakePartialWithdraw, pub, amount), sig) will pass - // because myKey.Public() == targetPub. + // Sign the collateral-identity payload with its authorized local key. + // After rotation, the signing public key differs from targetPub. msg := core.StakeSignMsg(core.StakePartialWithdraw, targetPub, req.AmountNAPR) sig, err := s.myKey.Sign(msg) if err != nil { @@ -2025,13 +2036,17 @@ func (s *Server) restAdminFullUnstake(w http.ResponseWriter, r *http.Request) { } // Accept an optional JSON body with pub_key for consistency with the - // partial-unstake endpoint, but it must equal the local node's key. + // partial-unstake endpoint, but it must equal the local collateral identity. var req struct { PubKey string `json:"pub_key"` } _ = json.NewDecoder(r.Body).Decode(&req) // body is optional - myPub := s.myKey.Public() + myPub, identityErr := s.localValidatorIdentity(true) + if identityErr != nil { + writeJSONError(w, http.StatusForbidden, identityErr.Error()) + return + } myPubHex := fmt.Sprintf("%x", []byte(myPub)) // If the caller supplied a pub_key, verify it matches this node. @@ -2108,7 +2123,11 @@ func (s *Server) restMyValidator(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"pub_key": nil}) return } - pub := s.myKey.Public() + pub, identityErr := s.localValidatorIdentity(false) + if identityErr != nil { + writeJSONError(w, http.StatusForbidden, identityErr.Error()) + return + } writeJSON(w, http.StatusOK, map[string]interface{}{ "pub_key": pub.Hex(), }) @@ -2465,9 +2484,13 @@ func (s *Server) restAdminStakeDeposit(w http.ResponseWriter, r *http.Request) { } targetPub := crypto.ValidatorPubKey(pubBytes) - // Only the local node's own key can be used — the signature is over (pub, amount, utxo) - // and must verify against pub itself. - myPub := s.myKey.Public() + // Only the local collateral identity can be used. Its height-authorized key + // signs the identity-bound (pub, amount, utxo) payload. + myPub, identityErr := s.localValidatorIdentity(true) + if identityErr != nil { + writeJSONError(w, http.StatusForbidden, identityErr.Error()) + return + } if !myPub.Equals(targetPub) { writeJSONError(w, http.StatusForbidden, "pub_key must match this node's configured validator key") diff --git a/api/rest_test.go b/api/rest_test.go index 9a0377f..3821a0d 100644 --- a/api/rest_test.go +++ b/api/rest_test.go @@ -128,21 +128,6 @@ func TestREST_Blocks_OffsetPastEnd(t *testing.T) { } } -func TestREST_Blocks_RejectsOffsetThatWouldOverflowSignedHeight(t *testing.T) { -srv, _ := buildChainServer(t, 0) -code, resp := restGet(t, srv, "/api/v1/blocks?offset=9223372036854775809") -if code != http.StatusOK { -t.Errorf("status = %d, want 200", code) -} -blocks, ok := resp["blocks"].([]interface{}) -if !ok { -t.Fatalf("blocks not an array: %T", resp["blocks"]) -} -if len(blocks) != 0 { -t.Errorf("expected empty blocks slice, got %d", len(blocks)) -} -} - func TestREST_Blocks_MethodNotAllowed(t *testing.T) { srv, _ := buildChainServer(t, 0) req := httptest.NewRequest(http.MethodPost, "/api/v1/blocks", nil) diff --git a/api/rpc_wallet.go b/api/rpc_wallet.go index b6e22d9..72afb22 100644 --- a/api/rpc_wallet.go +++ b/api/rpc_wallet.go @@ -173,14 +173,15 @@ func (s *Server) aprWalletSend(ctx context.Context, rawParams json.RawMessage) ( // ── 6. Cryptographic verification ──────────────────────────────────────── // Phase 2: all ring members are real on-chain UTXOs — enable strict C-0 check. verifier := core.NewTxVerifier(s.utxos) - verifyErr := verifier.VerifyTx(&result.Tx) +verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) +verifyErr := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) if isKeyImageConflictErr(verifyErr) { repaired, repairErr := s.repairPhantomKeyImages(result) if repairErr != nil { return nil, fmt.Errorf("verify: %w", repairErr) } if repaired { - verifyErr = verifier.VerifyTx(&result.Tx) +verifyErr = verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) } } if verifyErr != nil { diff --git a/api/rpc_wallet_batch.go b/api/rpc_wallet_batch.go index 166e36e..d3a14b7 100644 --- a/api/rpc_wallet_batch.go +++ b/api/rpc_wallet_batch.go @@ -290,7 +290,8 @@ func (s *Server) aprWalletBatchSend(rawParams json.RawMessage) (interface{}, err // ── 5. Cryptographic verification ──────────────────────────────────────── verifier := core.NewTxVerifier(s.utxos) - if err := verifier.VerifyTx(&result.Tx); err != nil { +verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) +if err := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()); err != nil { return nil, fmt.Errorf("verify: %w", err) } diff --git a/api/sale.go b/api/sale.go new file mode 100644 index 0000000..58b2f6f --- /dev/null +++ b/api/sale.go @@ -0,0 +1,365 @@ +package api + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" +) + +// SetSaleConfig is local operator configuration, never a payment API input. +// Wire before serving requests. Nil leaves every settlement endpoint disabled. +func (s *Server) SetSaleConfig(m *store.SaleMigration, ready func() bool, finalized ...func(uint64, crypto.Hash32) bool) { + s.saleMigration = m + s.saleProducerReady = ready +if len(finalized) == 1 { s.saleFinalized = finalized[0] } +} + +func (s *Server) isSaleFinalized(height uint64, hash crypto.Hash32) bool { +f := s.saleFinalized +if f == nil { f = s.lpodFinalized } +return f != nil && f(height, hash) +} + +func saleHash(text string) (crypto.Hash32, error) { + var result crypto.Hash32 + if len(text) != 64 { + return result, fmt.Errorf("invalid hash") + } + raw, err := hex.DecodeString(text) + if err != nil { + return result, err + } + copy(result[:], raw) + if result == (crypto.Hash32{}) { + return result, fmt.Errorf("zero hash") + } + return result, nil +} + +func saleHex(hash crypto.Hash32) string { return hex.EncodeToString(hash[:]) } + +func decodeSaleActivation(raw []byte) (*core.Block, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("activation body unavailable") + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} + +func (s *Server) activeSale(requireProducer bool) (*sale.State, error) { + m := s.saleMigration +if m == nil || s.blockStore == nil || s.myKey == nil || (s.saleFinalized == nil && s.lpodFinalized == nil) || + (requireProducer && (s.saleProducerReady == nil || !s.saleProducerReady())) || + !s.myKey.Public().Equals(m.Checkpoint.SaleAuthority) { + return nil, fmt.Errorf("authorized sale authority unavailable") + } + cp := m.Checkpoint + if _, err := sale.Begin(cp, cp.Genesis, cp.Parent, cp.Height, m.Trusted); err != nil { + return nil, err + } + state, err := s.blockStore.LoadSaleState() + if err != nil { + return nil, err + } + if state == nil || state.Checkpoint != cp.Message() { + return nil, fmt.Errorf("sale checkpoint inactive") + } + hash, height, err := s.blockStore.GetTip() + if err != nil { + return nil, err + } + if state.Hash != hash || state.Height != height { + return nil, fmt.Errorf("sale state is not canonical tip") + } + raw, err := s.blockStore.GetRawBlockByHeight(cp.Height) + if err != nil { + return nil, err + } + activation, err := decodeSaleActivation(raw) +if err != nil || activation.Header.PrevHash != cp.Parent || !s.isSaleFinalized(cp.Height, activation.Hash()) { + return nil, fmt.Errorf("sale activation not finalized") + } + return state, nil +} + +func saleJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(value) +} + +func saleBody(r *http.Request, value any) error { + raw, err := io.ReadAll(io.LimitReader(r.Body, 16385)) + if err != nil || len(raw) > 16384 { + return fmt.Errorf("invalid request size") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return fmt.Errorf("trailing request data") + } + return nil +} + +func (s *Server) saleReady(w http.ResponseWriter, r *http.Request, method string, requireProducer bool) bool { + if r.Method != method { + saleJSON(w, 405, map[string]string{"error": "method not allowed"}) + return false + } + if _, err := s.activeSale(requireProducer); err != nil { + saleJSON(w, 503, map[string]string{"error": "AUTHORIZED_APRO_SETTLEMENT_UNAVAILABLE"}) + return false + } + return true +} + +func (s *Server) restSaleStatus(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodGet, false) { + return + } + state, err := s.activeSale(false) + if err != nil { + saleJSON(w, 503, map[string]string{"error": "sale state changed"}) + return + } + reserved, err := s.blockStore.ReservedSaleOrders() + if err != nil || reserved > state.Remaining { + saleJSON(w, 503, map[string]string{"error": "sale obligations inconsistent"}) + return + } + cp := s.saleMigration.Checkpoint + saleJSON(w, 200, map[string]any{"ready": s.saleProducerReady != nil && s.saleProducerReady(), "checkpoint": saleHex(cp.Message()), + "genesis": saleHex(cp.Genesis), "activation_height": cp.Height, "tip_hash": saleHex(state.Hash), + "tip_height": state.Height, "remaining_napro": strconv.FormatUint(state.Remaining, 10), + "reserved_napro": strconv.FormatUint(reserved, 10), "available_napro": strconv.FormatUint(state.Remaining-reserved, 10), + "allocation_basis": saleAllocationBasis(s.saleMigration.Reconciliation), "historical_supply_verified": false, + "sold_napro": strconv.FormatUint(state.Sold, 10)}) +} + +// Private, authenticated diagnostics: closed codes only, never keys, endpoint +// addresses, checkpoint hashes, wallet data or a claim of settled payment. +func (s *Server) restSaleReadiness(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { saleJSON(w, 405, map[string]string{"error":"method not allowed"}); return } + code := "READY" + switch { + case s.saleMigration == nil: + code = "SALE_NOT_ACTIVATED" + case s.blockStore == nil: + code = "NODE_UNAVAILABLE" + case s.myKey == nil || !s.myKey.Public().Equals(s.saleMigration.Checkpoint.SaleAuthority): + code = "NO_READY_PRODUCER" + default: + if _, err := s.activeSale(false); err != nil { + code = "CHECKPOINT_LEDGER_MISMATCH" + if err.Error() == "sale activation not finalized" || err.Error() == "authorized sale authority unavailable" { + code = "NO_READY_PRODUCER" + } + } else if s.saleProducerReady == nil || !s.saleProducerReady() { + code = "NO_READY_PRODUCER" + } + } + saleJSON(w, 200, map[string]any{"code":code, "ready":code=="READY"}) +} + +func saleAllocationBasis(r sale.Reconciliation) string { + if r.AllocationEpoch != nil { + return "operator_attested_lpod_epoch" + } + if r.NetAllocation != nil { + return "authority_attested_net_holdings" + } + return "legacy_nominal_declaration" +} + +func (s *Server) restSaleReserve(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodPost, true) { + return + } + var body struct { + Order string `json:"order"` + Checkpoint string `json:"checkpoint"` + Address string `json:"address"` + Amount string `json:"amount_napro"` + QuoteUSD string `json:"quote_usd"` + } + if saleBody(r, &body) != nil { + saleJSON(w, 400, map[string]string{"error": "invalid request"}) + return + } + order, err := saleHash(body.Order) + cp, err2 := saleHash(body.Checkpoint) + amount, err3 := strconv.ParseUint(body.Amount, 10, 64) + if err != nil || err2 != nil || err3 != nil || amount == 0 || strconv.FormatUint(amount, 10) != body.Amount { + saleJSON(w, 400, map[string]string{"error": "invalid exact intent"}) + return + } + err = s.blockStore.ReserveSaleOrder(store.SaleOrder{Order: order, Checkpoint: cp, Recipient: crypto.Address(body.Address), Amount: amount, QuoteUSD: body.QuoteUSD}, s.saleMigration.Checkpoint.Network) + if err != nil { + saleJSON(w, 409, map[string]string{"error": "reservation refused"}) + return + } + saleJSON(w, 200, map[string]string{"order": body.Order, "status": "reserved"}) +} + +func (s *Server) restSalePaid(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodPost, true) { + return + } + var body struct { + Order string `json:"order"` + Payment string `json:"payment"` + } + if saleBody(r, &body) != nil { + saleJSON(w, 400, map[string]string{"error": "invalid request"}) + return + } + order, err := saleHash(body.Order) + payment, err2 := saleHash(body.Payment) + if err != nil || err2 != nil { + saleJSON(w, 400, map[string]string{"error": "invalid payment identity"}) + return + } + reserved, readErr := s.blockStore.SaleOrder(order) + if readErr != nil || reserved == nil || reserved.Invoice == nil || !reserved.Invoice.Valid() || + store.SaleProviderPayment(reserved.Invoice.ProviderID) != payment { + saleJSON(w, 409, map[string]string{"error": "payment does not match immutable invoice"}) + return + } + if err := s.blockStore.MarkSaleOrderPaid(order, payment); err != nil { + saleJSON(w, 409, map[string]string{"error": "payment obligation refused"}) + return + } + saleJSON(w, 200, map[string]string{"order": body.Order, "status": "payment_confirmed"}) +} + +func (s *Server) restSaleReceipt(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodGet, false) { + return + } + id, err := saleHash(r.URL.Query().Get("order")) + if err != nil { + saleJSON(w, 400, map[string]string{"error": "invalid order"}) + return + } + order, err := s.blockStore.SaleOrder(id) + if err != nil { + saleJSON(w, 503, map[string]string{"error": "order storage unavailable"}) + return + } + if order == nil { + saleJSON(w, 404, map[string]string{"error": "order not found"}) + return + } + delivery, err := s.blockStore.SaleOrderDelivery(id) + if err != nil { + saleJSON(w, 503, map[string]string{"error": "canonical delivery unavailable"}) + return + } + result := map[string]any{"order": saleHex(id), "payment": saleHex(order.Payment), + "checkpoint": saleHex(order.Checkpoint), "amount_napro": strconv.FormatUint(order.Amount, 10), "status": "reserved"} + if order.Invoice != nil { + result["invoice"] = order.Invoice + } + if order.QuoteUSD != "" { + result["quote_usd"] = order.QuoteUSD + } + if order.Payment != (crypto.Hash32{}) { + result["status"] = "payment_confirmed" + } + if delivery != nil { + result["status"] = "included" + result["tx_hash"] = saleHex(delivery.Transaction) + result["block_hash"] = saleHex(delivery.Block) + result["block_height"] = delivery.Height +if s.isSaleFinalized(delivery.Height, delivery.Block) { + result["status"] = "finalized" + result["owner_challenge"] = store.SaleRecoveryChallenge(s.saleMigration.Checkpoint.Genesis, order, delivery) + recovered, recoveryErr := s.blockStore.SaleOrderRecovery(id) + if recoveryErr != nil { + saleJSON(w, 503, map[string]string{"error": "owner recovery unavailable"}) + return + } + if recovered != nil && recovered.Block == delivery.Block && recovered.Transaction == delivery.Transaction { + result["status"] = "completed" + delete(result, "owner_challenge") + } + } + } + // Finality alone is not completion; an owner-signed recovery is required. + saleJSON(w, 200, result) +} + +func (s *Server) restSaleRecover(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodPost, false) { + return + } + var body struct { + Order string `json:"order"` + Block string `json:"block_hash"` + Transaction string `json:"tx_hash"` + Signature string `json:"signature"` + } + if saleBody(r, &body) != nil { + saleJSON(w, 400, map[string]string{"error": "invalid owner receipt"}) + return + } + id, err1 := saleHash(body.Order) + block, err2 := saleHash(body.Block) + tx, err3 := saleHash(body.Transaction) + if err1 != nil || err2 != nil || err3 != nil { + saleJSON(w, 400, map[string]string{"error": "invalid canonical hashes"}) + return + } + delivery, err := s.blockStore.SaleOrderDelivery(id) + if err != nil || delivery == nil || delivery.Block != block || delivery.Transaction != tx || +!s.isSaleFinalized(delivery.Height, delivery.Block) { + saleJSON(w, 409, map[string]string{"error": "canonical finalized delivery required"}) + return + } + if err := s.blockStore.RecordSaleRecovery(s.saleMigration.Checkpoint.Genesis, id, block, tx, body.Signature); err != nil { + saleJSON(w, 409, map[string]string{"error": "owner recovery proof rejected"}) + return + } + saleJSON(w, 200, map[string]string{"order": body.Order, "status": "owner_recovered"}) +} + +func (s *Server) restSaleInvoice(w http.ResponseWriter, r *http.Request) { + if !s.saleReady(w, r, http.MethodPost, true) { + return + } + var body struct { + Order string `json:"order"` + Invoice store.SaleInvoice `json:"invoice"` + } + if saleBody(r, &body) != nil { + saleJSON(w, 400, map[string]string{"error": "invalid invoice"}) + return + } + order, err := saleHash(body.Order) + if err != nil { + saleJSON(w, 400, map[string]string{"error": "invalid order"}) + return + } + if err := s.blockStore.BindSaleInvoice(order, body.Invoice); err != nil { + saleJSON(w, 409, map[string]string{"error": "invoice binding refused"}) + return + } + saleJSON(w, 200, map[string]string{"order": body.Order, "status": "invoice_bound"}) +} diff --git a/api/sale_test.go b/api/sale_test.go new file mode 100644 index 0000000..9fa537a --- /dev/null +++ b/api/sale_test.go @@ -0,0 +1,67 @@ +package api + +import ( +"encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSaleReadinessIsPrivateAndClosed(t *testing.T) { +s := &Server{} +s.mux = http.NewServeMux() +s.registerRESTRoutes() +path := "/api/v1/admin/protocol-sales/readiness" +for _, tc := range []struct{ remote, host string; status int }{ +{"203.0.113.9:1234", "localhost", http.StatusForbidden}, +{"127.0.0.1:1234", "localhost", http.StatusUnauthorized}, +} { +req := httptest.NewRequest(http.MethodGet, "http://"+tc.host+path, nil) +req.RemoteAddr = tc.remote +w := httptest.NewRecorder() +s.mux.ServeHTTP(w, req) +if w.Code != tc.status { t.Fatalf("private readiness status %d, expected %d",w.Code,tc.status) } +} +w := httptest.NewRecorder() +s.restSaleReadiness(w,httptest.NewRequest(http.MethodGet,path,nil)) +var result map[string]any +if err := json.Unmarshal(w.Body.Bytes(),&result); err != nil { t.Fatal(err) } +if len(result)!=2 || result["code"]!="SALE_NOT_ACTIVATED" || result["ready"]!=false { t.Fatal(result) } +} + +func TestSaleEndpointsFailClosedBeforeConfiguration(t *testing.T) { + s := &Server{} + for _, tc := range []struct { + method, path string + handler http.HandlerFunc + }{ + {"GET", "/api/v1/admin/protocol-sales/status", s.restSaleStatus}, + {"POST", "/api/v1/admin/protocol-sales/reserve", s.restSaleReserve}, + {"POST", "/api/v1/admin/protocol-sales/paid", s.restSalePaid}, + {"POST", "/api/v1/admin/protocol-sales/invoice", s.restSaleInvoice}, + {"POST", "/api/v1/admin/protocol-sales/recover", s.restSaleRecover}, + {"GET", "/api/v1/admin/protocol-sales/receipt?order=" + strings.Repeat("a", 64), s.restSaleReceipt}, + } { + r := httptest.NewRequest(tc.method, tc.path, strings.NewReader("{}")) + w := httptest.NewRecorder() + tc.handler(w, r) + if w.Code != 503 { + t.Fatalf("%s gave %d before configuration", tc.path, w.Code) + } + if !strings.Contains(w.Body.String(), "AUTHORIZED_APRO_SETTLEMENT_UNAVAILABLE") { + t.Fatal("silent fallback") + } + } +} + +func TestSaleExactRequestDecoderRejectsNumbersAndTrailingData(t *testing.T) { + for _, raw := range []string{`{"amount_napro":1}`, `{"amount_napro":"1"} {}`, `{"amount_napro":"1","unknown":true}`} { + var input struct { + Amount string `json:"amount_napro"` + } + if saleBody(httptest.NewRequest("POST", "/", strings.NewReader(raw)), &input) == nil { + t.Fatalf("ambiguous request accepted: %s", raw) + } + } +} diff --git a/api/server.go b/api/server.go index b9e9f8c..fbbc4c3 100644 --- a/api/server.go +++ b/api/server.go @@ -43,21 +43,26 @@ const utxoCacheTTL = 90 * time.Second // Server is the JSON-RPC 2.0 HTTP server. type Server struct { - lpodMigration *store.LPoDMigration - lpodFinalized func(uint64, crypto.Hash32) bool - addr string - chain *core.Chain - mempool *core.Mempool - utxos *core.UTXOSet - registry *core.ValidatorRegistry // live PoS validator registry (optional) - myKey *crypto.LockedValidatorKey // node's own validator key for admin stake ops (optional) - blockStore *store.DB // optional: LevelDB store for pruned-block fallback - log *slog.Logger - mux *http.ServeMux - hub *Hub - apiKey string // empty fails closed for privileged operations - corsOrigins []string // empty = do not grant cross-origin browser access - rateLimiter *RateLimiter + saleMigration *store.SaleMigration + saleProducerReady func() bool +saleFinalized func(uint64, crypto.Hash32) bool + lpodMigration *store.LPoDMigration + genesisVestingHeight uint64 + validatorAuthorityFork *core.ValidatorAuthorityFork + lpodFinalized func(uint64, crypto.Hash32) bool + addr string + chain *core.Chain + mempool *core.Mempool + utxos *core.UTXOSet + registry *core.ValidatorRegistry // live PoS validator registry (optional) + myKey *crypto.LockedValidatorKey // node's own validator key for admin stake ops (optional) + blockStore *store.DB // optional: LevelDB store for pruned-block fallback + log *slog.Logger + mux *http.ServeMux + hub *Hub + apiKey string // empty fails closed for privileged operations + corsOrigins []string // empty = do not grant cross-origin browser access + rateLimiter *RateLimiter // utxoAudit holds the latest background UTXO-store audit result, // pushed by cmd/node via SetUTXOAuditResult and served on @@ -309,6 +314,7 @@ type Server struct { avmStore avm.Store avmActivationHeight uint64 avmGasBurnActivationHeight uint64 +stakedCollateralRingExclusionActivationHeight uint64 guardianFundActivationHeight uint64 guardianFundChainAnchor crypto.Hash32 broadcastTxMu sync.RWMutex @@ -506,6 +512,23 @@ func (s *Server) SetAVMGasBurnActivationHeight(height uint64) { s.hub.SetAVMGasBurnActivationHeight(height) } +// SetStakedCollateralRingExclusionActivationHeight wires the consensus +// activation height into wallet-side transaction verification. +func (s *Server) SetStakedCollateralRingExclusionActivationHeight(height uint64) { + s.stakedCollateralRingExclusionActivationHeight = height +} + +func (s *Server) nextBlockHeight() uint64 { + if s.chain == nil { + return 0 + } + height := s.chain.Height() + if height < ^uint64(0) { + height++ + } + return height +} + // SetTransactionBroadcaster wires accepted locally-submitted transactions to // the node's normal P2P gossip path (normally p2p.Host.BroadcastTx). func (s *Server) SetTransactionBroadcaster(fn func(*core.Transaction)) { diff --git a/api/wallet_changes.go b/api/wallet_changes.go index 461efb0..2349176 100644 --- a/api/wallet_changes.go +++ b/api/wallet_changes.go @@ -278,7 +278,7 @@ func (s *Server) restWalletChanges(w http.ResponseWriter, r *http.Request) { } var resumeHeight uint64 var resumeHash crypto.Hash32 -var resumeEventIndex int + var resumeEventIndex uint64 if resumeProvided { if id != "" || !witnessProvided || q.Get("cursor") != "" { writeJSONError(w, http.StatusBadRequest, "resume coordinates require a new snapshot and a witness") @@ -294,8 +294,8 @@ var resumeEventIndex int writeJSONError(w, http.StatusBadRequest, "resume_hash must be 64 hexadecimal characters") return } -resumeEventIndex, err = strconv.Atoi(q.Get("resume_event_index")) -if err != nil || resumeEventIndex < 0 || resumeEventIndex > walletChangesMaxResumeEvents { + resumeEventIndex, err = strconv.ParseUint(q.Get("resume_event_index"), 10, 64) + if err != nil || resumeEventIndex > walletChangesMaxResumeEvents { writeJSONError(w, http.StatusBadRequest, "resume_event_index exceeds the supported event index") return } @@ -414,13 +414,13 @@ if err != nil || resumeEventIndex < 0 || resumeEventIndex > walletChangesMaxResu reconciliationError(w, resumeHeight, resumeHeight, "resume block event list exceeds decode bounds") return } -if resumeEventIndex > len(resumeEvents) { + if resumeEventIndex > uint64(len(resumeEvents)) { writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "resume event index is outside the canonical block") return } } cursor.Height = resumeHeight -cursor.EventIndex = resumeEventIndex + cursor.EventIndex = int(resumeEventIndex) } if token := q.Get("cursor"); token != "" { cursor, err = parseWalletChangesCursor(entry.cursorKey[:], id, token) @@ -452,7 +452,7 @@ cursor.EventIndex = resumeEventIndex return } -events := make([]map[string]interface{}, 0, walletChangesMaxEvents) + events := make([]map[string]interface{}, 0, limit) next := cursor expectedParent := fromHash if cursor.Height > fromHeight+1 { diff --git a/api/wallet_history_proof.go b/api/wallet_history_proof.go new file mode 100644 index 0000000..9b940c1 --- /dev/null +++ b/api/wallet_history_proof.go @@ -0,0 +1,93 @@ +package api + +import ( + "encoding/hex" + "fmt" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// A UTXO/index hit alone is not evidence that its recorded transaction hash +// occurs in a canonical block. Pruned/missing bodies deliberately yield false. +// Queries use the SAME pinned view as the owner's key-image status. +func walletHistoryOutputProof(read *store.WalletReadSnapshot, refs []struct { + Hash string `json:"tx_hash"` + Index uint32 `json:"out_idx"` +}) map[string]bool { + evidence := walletHistoryOutputEvidence(read, refs) + result := make(map[string]bool, len(refs)) + for id, proof := range evidence { + result[id] = proof != nil + } + return result +} + +type walletCanonicalEvidence struct { + Height uint64 `json:"block_height"` + BlockHash string `json:"block_hash"` + Fee string `json:"fee_napro"` + Origin string `json:"origin"` +} + +func walletHistoryOutputEvidence(read *store.WalletReadSnapshot, refs []struct { + Hash string `json:"tx_hash"` + Index uint32 `json:"out_idx"` +}) map[string]*walletCanonicalEvidence { + result := make(map[string]*walletCanonicalEvidence, len(refs)) + blocks := make(map[uint64]*core.Block) + budget := 8 << 20 + for _, ref := range refs { + id := fmt.Sprintf("%s:%d", ref.Hash, ref.Index) + result[id] = nil + raw, err := hex.DecodeString(ref.Hash) + if err != nil || len(raw) != 32 { + continue + } + var hash crypto.Hash32 + copy(hash[:], raw) + output, err := read.GetUTXO(hash, ref.Index) + if err != nil || output == nil { + continue + } + block, cached := blocks[output.BlockHeight] + if !cached { + var size int + if budget <= 0 { + continue + } + block, size, err = read.ReadCanonicalBlockBoundedWithSize(output.BlockHeight, budget) + budget -= size + if err != nil || block == nil || core.MerkleRoot(block.Txs) != block.Header.MerkleRoot { + block = nil + } + blocks[output.BlockHeight] = block + } + if block == nil { + continue + } + for txIndex, tx := range block.Txs { + if tx.Hash() != hash || int(ref.Index) >= len(tx.Outputs) { + continue + } + actual := tx.Outputs[ref.Index] + if actual.OneTimePub == output.OneTimePub && + actual.TxPubKey == output.TxPubKey && actual.AmountCommit == output.AmountCommit && + actual.EncAmount == output.EncAmount { + bh := block.Hash() + origin := "transfer" + if txIndex == 0 && block.Header.Height > 0 && tx.IsCoinbase() && !tx.IsStake() { + origin = "validator_reward" + } + if tx.IsSale() { + origin = "protocol_sale" + } + result[id] = &walletCanonicalEvidence{Height: block.Header.Height, + BlockHash: hex.EncodeToString(bh[:]), Fee: strconv.FormatUint(tx.Fee, 10), Origin: origin} + } + } + } + return result +} diff --git a/api/wallet_history_proof_test.go b/api/wallet_history_proof_test.go new file mode 100644 index 0000000..4ab80f4 --- /dev/null +++ b/api/wallet_history_proof_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/json" + "fmt" + "path/filepath" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestWalletHistoryRequiresCanonicalTransactionAndExactOutput(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "chain")) + if err != nil { t.Fatal(err) } + defer db.Close() + out := core.Output{OneTimePub: crypto.Point32{1}, TxPubKey: crypto.Point32{2}, AmountCommit: crypto.Commitment{3}} + tx := core.Transaction{Version: core.TxVersionBase, Outputs: []core.Output{out}} + block := core.Block{Header: core.BlockHeader{Height: 1}, Txs: []core.Transaction{tx}} + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + raw, err := json.Marshal(block) + if err != nil { t.Fatal(err) } + if err := db.PutRawBlock(block.Hash(), 1, raw); err != nil { t.Fatal(err) } + stored := &store.StoredUTXO{TxHash: tx.Hash(), OutputIndex: 0, BlockHeight: 1, + OneTimePub: out.OneTimePub, TxPubKey: out.TxPubKey, AmountCommit: out.AmountCommit, EncAmount: out.EncAmount} + if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { t.Fatal(err) } + wrongHash := crypto.HashBytes([]byte("wallet-recorded-but-not-in-block")) + if err := db.PutUTXO(wrongHash, 0, stored); err != nil { t.Fatal(err) } + refs := []struct{ Hash string `json:"tx_hash"`; Index uint32 `json:"out_idx"` }{ + {fmt.Sprintf("%x", tx.Hash()), 0}, {fmt.Sprintf("%x", wrongHash), 0}, + } + snapshot, err := db.NewWalletReadSnapshot() + if err != nil { t.Fatal(err) } + defer snapshot.Release() + proof := walletHistoryOutputProof(snapshot, refs) + if !proof[refs[0].Hash+":0"] || proof[refs[1].Hash+":0"] { + t.Fatal("index presence confused with canonical membership") + } + stored.AmountCommit[0]++ + if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { t.Fatal(err) } + newSnapshot, err := db.NewWalletReadSnapshot() + if err != nil { t.Fatal(err) } + defer newSnapshot.Release() + if walletHistoryOutputProof(newSnapshot, refs)[refs[0].Hash+":0"] { + t.Fatal("wrong output commitment promoted to confirmed") + } + if !walletHistoryOutputProof(snapshot, refs)[refs[0].Hash+":0"] { + t.Fatal("a pinned proof changed after a later mutation") + } +} diff --git a/api/wallet_history_transactions.go b/api/wallet_history_transactions.go new file mode 100644 index 0000000..b15cede --- /dev/null +++ b/api/wallet_history_transactions.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +type walletHistoryTransactionRef struct { + Hash string `json:"tx_hash"` + KeyImages []string `json:"key_images"` +} + +// A successful broadcast or index lookup is not confirmation. Match the +// locally journalled transaction hash AND its input images in a real body. +// This also works for exact-budget sends with no output owned by the sender. +func walletHistoryTransactionEvidence(read *store.WalletReadSnapshot, refs []walletHistoryTransactionRef) map[string]*walletCanonicalEvidence { + result := make(map[string]*walletCanonicalEvidence, len(refs)) + bodies := make(map[uint64]*core.Block) + budget := 8 << 20 + for _, ref := range refs { + result[ref.Hash] = nil + raw, err := hex.DecodeString(ref.Hash) + if err != nil || len(raw) != 32 || len(ref.KeyImages) == 0 { + continue + } + var hash crypto.Hash32 + copy(hash[:], raw) + locator, err := read.LookupTxIdx(hash) + if err != nil { + continue + } + var height uint64 + if locator != nil { + height = locator.Height + } else { + // Old canonical outputs can precede the transaction locator index. + output, err := read.GetUTXO(hash, 0) + if err != nil || output == nil { + continue + } + height = output.BlockHeight + } + block, cached := bodies[height] + if !cached { + if budget <= 0 { + continue + } + var size int + block, size, err = read.ReadCanonicalBlockBoundedWithSize(height, budget) + budget -= size + if err != nil || block == nil { + block = nil + } + bodies[height] = block + } + if block == nil { + continue + } + for _, tx := range block.Txs { + if tx.Hash() != hash || len(tx.Inputs) != len(ref.KeyImages) { + continue + } + expected := make(map[string]bool, len(ref.KeyImages)) + valid := true + for _, image := range ref.KeyImages { + raw, err := hex.DecodeString(image) + if err != nil || len(raw) != 32 || expected[image] { + valid = false + break + } + expected[image] = true + } + for _, input := range tx.Inputs { + image := hex.EncodeToString(input.KeyImage[:]) + if !expected[image] { + valid = false + break + } + delete(expected, image) + } + if !valid || len(expected) != 0 { + continue + } + bh := block.Hash() + result[ref.Hash] = &walletCanonicalEvidence{Height: block.Header.Height, + BlockHash: hex.EncodeToString(bh[:]), Fee: strconv.FormatUint(tx.Fee, 10), Origin: "transfer"} + } + } + return result +} + +func (s *Server) restWalletHistoryTransactions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, 405, "POST required") + return + } + var req struct { + SnapshotID string `json:"snapshot_id"` + Address string `json:"address"` + Checkpoint string `json:"checkpoint_hash"` + Transactions []walletHistoryTransactionRef `json:"transactions"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 100000)).Decode(&req) != nil || + len(req.Transactions) == 0 || len(req.Transactions) > 128 { + writeJSONError(w, 400, "bounded transaction references required") + return + } + total := 0 + seen := make(map[string]bool) + for _, ref := range req.Transactions { + if len(ref.Hash) != 64 || seen[ref.Hash] || len(ref.KeyImages) == 0 || len(ref.KeyImages) > 256 { + writeJSONError(w, 400, "invalid transaction reference") + return + } + seen[ref.Hash] = true + total += len(ref.KeyImages) + } + if total > 1024 { + writeJSONError(w, 400, "transaction input limit exceeded") + return + } + address, err := validSnapshotAddress(req.Address) + if err != nil { + writeJSONError(w, 400, "valid address required") + return + } + lease := s.acquireWalletSnapshot(w, req.SnapshotID, address) + if lease == nil { + return + } + defer lease.Release() + if req.Checkpoint != fmt.Sprintf("%x", lease.entry.hash[:]) { + writeJSONError(w, 409, "snapshot checkpoint mismatch") + return + } + writeJSON(w, 200, map[string]interface{}{ + "checkpoint_hash": req.Checkpoint, + "transactions": walletHistoryTransactionEvidence(lease.entry.read, req.Transactions), + }) +} diff --git a/api/wallet_history_transactions_test.go b/api/wallet_history_transactions_test.go new file mode 100644 index 0000000..54e4e5e --- /dev/null +++ b/api/wallet_history_transactions_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" + "path/filepath" + "testing" +) + +func TestWalletHistoryExactBudgetSendHasCanonicalEvidenceWithoutChange(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "chain")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + image := crypto.KeyImage{7} + tx := core.Transaction{Version: core.TxVersionCLSAG, Fee: 252750, + Inputs: []core.RingInput{{KeyImage: image}}, + Outputs: []core.Output{{OneTimePub: crypto.Point32{8}, TxPubKey: crypto.Point32{9}, AmountCommit: crypto.Commitment{10}}}} + block := core.Block{Header: core.BlockHeader{Height: 12}, Txs: []core.Transaction{tx}} + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err = db.PutRawBlock(block.Hash(), 12, raw); err != nil { + t.Fatal(err) + } + hash := tx.Hash() + // Exercise the pre-locator fallback as well, not only recent t/ records. + if err = db.PutUTXO(hash, 0, &store.StoredUTXO{TxHash: hash, BlockHeight: 12, + OneTimePub: tx.Outputs[0].OneTimePub, TxPubKey: tx.Outputs[0].TxPubKey, + AmountCommit: tx.Outputs[0].AmountCommit}); err != nil { + t.Fatal(err) + } + view, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + defer view.Release() + id := hex.EncodeToString(hash[:]) + ref := walletHistoryTransactionRef{Hash: id, KeyImages: []string{hex.EncodeToString(image[:])}} + proof := walletHistoryTransactionEvidence(view, []walletHistoryTransactionRef{ref})[id] + if proof == nil || proof.Height != 12 || proof.Fee != "252750" { + t.Fatalf("missing exact-budget proof: %+v", proof) + } + wrong := crypto.KeyImage{11} + ref.KeyImages[0] = hex.EncodeToString(wrong[:]) + if walletHistoryTransactionEvidence(view, []walletHistoryTransactionRef{ref})[id] != nil { + t.Fatal("wrong input images accepted") + } +} diff --git a/api/wallet_snapshot.go b/api/wallet_snapshot.go index 1b57f40..6855279 100644 --- a/api/wallet_snapshot.go +++ b/api/wallet_snapshot.go @@ -668,7 +668,7 @@ func (s *Server) restWalletKeyImagesSnapshot( Hash string `json:"tx_hash"` Index uint32 `json:"out_idx"` }, - id, addressText string, + id, addressText string, history ...bool, ) { address, err := validSnapshotAddress(addressText) if err != nil { @@ -731,21 +731,29 @@ func (s *Server) restWalletKeyImagesSnapshot( writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_QUERY_FAILED", "pinned output index unavailable") return } - directSpent, err := lease.entry.read.IsUTXOSpentChecked(txHash, ref.Index) - if err != nil { - writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_QUERY_FAILED", "pinned source index unavailable") - return - } - if utxo == nil && !spent && !directSpent { + if utxo == nil && !spent { writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_SOURCE_MISSING", "source is not indexed at the pinned checkpoint") return } - canonicalSpent[text] = spent || utxo == nil || directSpent + // A public per-output marker cannot identify the hidden real CLSAG + // input. Only the owner's locally derived image establishes spentness. + canonicalSpent[text] = spent pendingLocked[text] = pending[hex.EncodeToString(ki[:])] statuses[text] = canonicalSpent[text] || pendingLocked[text] } + var confirmedRefs map[string]bool + var canonicalOutputs map[string]*walletCanonicalEvidence + if len(history) > 0 && history[0] { + canonicalOutputs = walletHistoryOutputEvidence(lease.entry.read, refs) + confirmedRefs = make(map[string]bool, len(canonicalOutputs)) + for id, proof := range canonicalOutputs { + confirmedRefs[id] = proof != nil + } + } writeJSON(w, http.StatusOK, map[string]interface{}{ "spent": statuses, "canonical_spent": canonicalSpent, "pending_locked": pendingLocked, + "confirmed_refs": confirmedRefs, + "canonical_outputs": canonicalOutputs, "snapshot_id": lease.entry.id, "checkpoint_height": lease.entry.height, "checkpoint_hash": fmt.Sprintf("%x", lease.entry.hash[:]), diff --git a/api/wallet_snapshot_integration_test.go b/api/wallet_snapshot_integration_test.go index 1e0f3b8..6c2fe5a 100644 --- a/api/wallet_snapshot_integration_test.go +++ b/api/wallet_snapshot_integration_test.go @@ -404,10 +404,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] != true || +if initialSpentMap[spentKIHex] != true || initialSpentMap[unspentKIHex] != false || initialSpentMap[pendingKIHex] != false || - initialCanonicalMap[pendingKIHex] != false || initialPendingMap[pendingKIHex] != false { - t.Fatalf("pinned KI/direct-spent statuses: %#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, @@ -752,6 +753,7 @@ func advanceSyntheticLPoDV3( leader crypto.Address, validator crypto.ValidatorPrivKey, validatorPub crypto.ValidatorPubKey, + extraTransactions ...core.Transaction, ) (crypto.Hash32, uint64) { t.Helper() parentHash, parentHeight, err := db.GetTip() @@ -768,6 +770,7 @@ func advanceSyntheticLPoDV3( } request := &store.LPoDSettlement{ Parent: parentHash, PositionProtocol: true, + Transactions: extraTransactions, Timestamp: parent.Header.Timestamp + 3_000_000_000, Proposer: validatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{ @@ -790,6 +793,7 @@ func advanceSyntheticLPoDV3( }, Txs: []core.Transaction{payout, core.LPoDCheckpointTx(checkpoint.Digest())}, } + block.Txs = append(block.Txs, extraTransactions...) block.Header.MerkleRoot = core.MerkleRoot(block.Txs) if err := block.Header.Sign(validator); err != nil { t.Fatal(err) @@ -847,12 +851,14 @@ func snapshotRequest( var decoded map[string]interface{} decoder := json.NewDecoder(response.Body) if err := decoder.Decode(&decoded); err != nil { - t.Fatalf("decode %s response: %v", target, err) + t.Fatalf("decode %s response: %v", target, err) } - // Finish the chunked response before asserting deferred lease accounting. + // A complete JSON value can arrive before the handler's deferred reader + // release. Consume the HTTP response through EOF before asserting lease + // accounting. Also reject transport errors and extra JSON values. var extra interface{} if err := decoder.Decode(&extra); err != io.EOF { - t.Fatalf("finish %s response: expected EOF, got %v (extra=%#v)", target, err, extra) + t.Fatalf("finish %s response: expected EOF, got %v (extra=%#v)", target, err, extra) } return response.StatusCode, decoded } diff --git a/api/wallet_snapshot_lifecycle_test.go b/api/wallet_snapshot_lifecycle_test.go new file mode 100644 index 0000000..bedad20 --- /dev/null +++ b/api/wallet_snapshot_lifecycle_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package api + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// This fixture owns an actual LevelDB view, not a mocked release counter. +func newSnapshotLifecycleEntry(t *testing.T) (*walletSnapshotManager, *walletSnapshotEntry, *store.WalletReadSnapshot) { + t.Helper() + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + manager := newWalletSnapshotManager() + t.Cleanup(manager.closeAll) + entry := &walletSnapshotEntry{ + id: "lifecycle-fixture", address: crypto.Address("synthetic-lifecycle-owner"), + read: read, bytes: 17, expiresAt: time.Now().Add(walletSnapshotTTL), + } + manager.entries[entry.id] = entry + manager.active = 1 + manager.activeByAddress[entry.address] = 1 + manager.checkpointBytes = entry.bytes + return manager, entry, read +} + +func TestWalletSnapshotExpiryWaitsForLastReaderAndIsIdempotent(t *testing.T) { + manager, entry, read := newSnapshotLifecycleEntry(t) + first, err := manager.acquire(entry.id, entry.address) + if err != nil { + t.Fatal(err) + } + defer first.Release() + last, err := manager.acquire(entry.id, entry.address) + if err != nil { + t.Fatal(err) + } + defer last.Release() + + manager.mu.Lock() + entry.expiresAt = time.Now().Add(-time.Second) + manager.mu.Unlock() + manager.expire() + manager.expire() + manager.closeAll() + if _, err := manager.acquire(entry.id, entry.address); !errors.Is(err, errWalletSnapshotExpired) { + t.Fatalf("expired token remained acquirable: %v", err) + } + if err := manager.close(entry.id, entry.address); !errors.Is(err, errWalletSnapshotExpired) { + t.Fatalf("repeated token close: %v", err) + } + if len(manager.entries) != 0 || !entry.closed || entry.inflight != 2 || + entry.read != read || manager.active != 1 || + manager.activeByAddress[entry.address] != 1 || manager.checkpointBytes != 17 { + t.Fatal("expiry failed to retire the token or evicted an in-flight view") + } + if _, _, err := read.GetTip(); err != nil { + t.Fatalf("expiry closed a physical view still used by readers: %v", err) + } + + first.Release() + first.Release() + manager.expire() + manager.closeAll() + if entry.inflight != 1 || manager.active != 1 || entry.read != read || + manager.activeByAddress[entry.address] != 1 || manager.checkpointBytes != 17 { + t.Fatal("first/duplicate reader release freed the last reader's view") + } + if _, _, err := read.GetTip(); err != nil { + t.Fatalf("remaining reader lost its pinned view: %v", err) + } + + last.Release() + last.Release() + manager.expire() + manager.closeAll() + if entry.inflight != 0 || entry.read != nil || entry.bytes != 0 || + manager.active != 0 || len(manager.activeByAddress) != 0 || + manager.checkpointBytes != 0 || len(manager.entries) != 0 { + t.Fatal("final/duplicate release leaked or double-decremented snapshot accounting") + } + // Do not access read after release: this goleveldb version drops its DB + // pointer on Release, so a subsequent Get can panic rather than return + // ErrSnapshotReleased. The manager must remove the view reference. + read.Release() // Wrapper-level duplicate release must remain harmless. +} + +type snapshotLifecycleTransport func(*http.Request) (*http.Response, error) + +func (f snapshotLifecycleTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type snapshotLifecycleBody struct { + io.ReadCloser + reads int + firstJSONBytes int + beforeEOFRead func() + sawEOF *atomic.Bool + unblockHandler func() +} + +func (b *snapshotLifecycleBody) Read(p []byte) (int, error) { + b.reads++ + if b.reads == 1 { + // Deliver exactly the complete first JSON value, regardless of TCP + // fragmentation. EOF cannot arrive until a subsequent read. + return io.ReadFull(b.ReadCloser, p[:b.firstJSONBytes]) + } + b.beforeEOFRead() + n, err := b.ReadCloser.Read(p) + if err == io.EOF { + b.sawEOF.Store(true) + } + return n, err +} + +func (b *snapshotLifecycleBody) Close() error { + // Do not strand the server if the old one-value decoder regresses. + b.unblockHandler() + return b.ReadCloser.Close() +} + +func TestSnapshotRequestFinishesHTTPResponseBeforeLeaseAssertions(t *testing.T) { + manager, entry, _ := newSnapshotLifecycleEntry(t) + finishResponse := make(chan struct{}) + var finishOnce sync.Once + unblock := func() { finishOnce.Do(func() { close(finishResponse) }) } + defer unblock() + var sawEOF atomic.Bool + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lease, err := manager.acquire(entry.id, entry.address) + if err != nil { + t.Errorf("acquire HTTP reader: %v", err) + return + } + defer lease.Release() + if err := manager.close(entry.id, entry.address); err != nil { + t.Errorf("retire HTTP snapshot token: %v", err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + // Deliver the whole JSON before returning/releasing the reader. + // The client must consume the terminal HTTP chunk, not just JSON. + w.(http.Flusher).Flush() + select { + case <-finishResponse: + case <-r.Context().Done(): + case <-time.After(3 * time.Second): + t.Error("client did not finish or cancel the HTTP response") + } + })) + t.Cleanup(httpServer.Close) + client := httpServer.Client() + client.Timeout = 3 * time.Second + baseTransport := client.Transport + client.Transport = snapshotLifecycleTransport(func(r *http.Request) (*http.Response, error) { + response, err := baseTransport.RoundTrip(r) + if err != nil { + return nil, err + } + response.Body = &snapshotLifecycleBody{ + ReadCloser: response.Body, + firstJSONBytes: len("{\"ok\":true}\n"), + beforeEOFRead: func() { + unblock() + }, + sawEOF: &sawEOF, + unblockHandler: unblock, + } + return response, nil + }) + code, body := snapshotRequest(t, client, http.MethodGet, httpServer.URL, nil) + if code != http.StatusOK || body["ok"] != true { + t.Fatalf("unexpected lifecycle HTTP response: %d %#v", code, body) + } + if !sawEOF.Load() { + t.Fatal("HTTP helper returned after JSON without reading through EOF") + } + if got := activeSnapshotSessions(manager); got != 0 { + t.Fatalf("completed HTTP handler retained %d sessions", got) + } +} diff --git a/api/wallet_snapshot_service_test.go b/api/wallet_snapshot_service_test.go new file mode 100644 index 0000000..5c3daa1 --- /dev/null +++ b/api/wallet_snapshot_service_test.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sync" + "testing" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// Test-only service: real REST handlers and LevelDB, synthetic data, no P2P, +// signer service, production credentials, database or consensus worker. +type walletSnapshotLoopback struct { + node *Server + http *httptest.Server + query url.Values + spentKeyImage string + mu sync.Mutex + active int + idle chan struct{} + stop chan struct{} + stopOnce sync.Once + canceled chan struct{} + cancelOnce sync.Once +} + +func newWalletSnapshotLoopback(t *testing.T) *walletSnapshotLoopback { + t.Helper() + db, migration, address, path, validator, pub := syntheticWalletSnapshotLPoDV3Funding(t) + db, _, _, spentKI, _, parent, height := seedWalletSnapshotAPIFixture(t, db, path, address) + // Ordinary inert output rows make metadata lookup and pagination real. + // No transaction is broadcast and no private key/amount is returned. + var keyImage crypto.KeyImage + copy(keyImage[:], crypto.HashToCurvePoint([]byte("service-inert-input")).Bytes()) + tx := core.Transaction{Version: core.TxVersionBase, Inputs: []core.RingInput{{KeyImage: keyImage}}} + for i := 0; i < 3; i++ { + var point crypto.Point32 + copy(point[:], crypto.HashToCurvePoint([]byte(fmt.Sprintf("service-output-%d", i))).Bytes()) + tx.Outputs = append(tx.Outputs, core.Output{OneTimePub: point, TxPubKey: point}) + } + tip, tipHeight := advanceSyntheticLPoDV3(t, db, address, validator, pub, tx) + node := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + node.SetStore(db) + node.SetLPoDConfig(migration, func(h uint64, hash crypto.Hash32) bool { + return h == tipHeight && hash == tip + }) + node.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { return db.NewWalletReadSnapshot() }) + f := &walletSnapshotLoopback{node: node, idle: make(chan struct{}), stop: make(chan struct{}), canceled: make(chan struct{}), query: url.Values{ + "address": {string(address)}, "from_height": {fmt.Sprint(height)}, "from_hash": {fmt.Sprintf("%x", parent[:])}, + "witness_height": {fmt.Sprint(tipHeight)}, "witness_hash": {fmt.Sprintf("%x", tip[:])}, "limit": {"1"}, + }} + f.spentKeyImage = fmt.Sprintf("%x", spentKI[:]) + close(f.idle) + mux := http.NewServeMux() + mux.HandleFunc("/_fixture/state", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + idle := f.idle + f.mu.Unlock() + select { + case <-idle: + case <-r.Context().Done(): + return + case <-time.After(5 * time.Second): + http.Error(w, "native handler did not finish", 500) + return + } + m := node.walletSnapshots + m.mu.Lock() + defer m.mu.Unlock() + inflight := 0 + for _, entry := range m.entries { + inflight += entry.inflight + } + writeJSON(w, 200, map[string]int{"active": m.active, "entries": len(m.entries), "readers": inflight, "bytes": m.checkpointBytes}) + }) + mux.HandleFunc("/_fixture/expire", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", 405) + return + } + m := node.walletSnapshots + m.mu.Lock() + for _, entry := range m.entries { + entry.expiresAt = time.Now().Add(-time.Second) + } + m.expireLocked(time.Now()) + m.mu.Unlock() + writeJSON(w, 200, map[string]bool{"expired": true}) + }) + mux.HandleFunc("/_fixture/stop", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", 405) + return + } + writeJSON(w, 200, map[string]bool{"stopping": true}) + f.stopOnce.Do(func() { close(f.stop) }) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Fixture-Cancel-Witness") == "1" { + go func() { + <-r.Context().Done() + f.cancelOnce.Do(func() { close(f.canceled) }) + }() + } + f.mu.Lock() + if f.active == 0 { + f.idle = make(chan struct{}) + } + f.active++ + f.mu.Unlock() + defer func() { + f.mu.Lock() + f.active-- + if f.active == 0 { + close(f.idle) + } + f.mu.Unlock() + }() + node.ServeHTTP(w, r) + }) + f.http = httptest.NewServer(mux) + t.Cleanup(func() { + f.http.Close() + node.walletSnapshots.closeAll() + _ = db.Close() + }) + return f +} + +func (f *walletSnapshotLoopback) state(t *testing.T, active int) { + t.Helper() + code, body := snapshotRequest(t, f.http.Client(), "GET", f.http.URL+"/_fixture/state", nil) + if code != 200 || body["active"] != float64(active) || body["entries"] != float64(active) || body["readers"] != float64(0) { + t.Fatalf("snapshot accounting: code=%d state=%v want active=%d, no in-flight reader", code, body, active) + } + if active == 0 && body["bytes"] != float64(0) { + t.Fatalf("retained checkpoint bytes: %v", body) + } +} + +// The Node/Vitest service integration invokes this same fixture explicitly. +// Normal Go suites instead exercise cancellation over an actual TCP connection. +func TestWalletSnapshotLoopbackService(t *testing.T) { + f := newWalletSnapshotLoopback(t) + if os.Getenv("APEROD_TEST_SNAPSHOT_SERVICE") == "1" { + info, err := json.Marshal(map[string]interface{}{"url": f.http.URL, "query": f.query.Encode(), "spent_key_image": f.spentKeyImage}) + if err != nil { + t.Fatal(err) + } + fmt.Printf("APEROD_SNAPSHOT_FIXTURE=%s\n", info) + select { + case <-f.stop: + case <-time.After(2 * time.Minute): + t.Fatal("test service was not stopped") + } + f.state(t, 0) + return + } + target := f.http.URL + "/api/v1/wallet/changes?" + f.query.Encode() + code, first := snapshotRequest(t, f.http.Client(), "GET", target, nil) + if code != 200 || first["complete"] != false { + t.Fatalf("paginated fixture: %d %v", code, first) + } + f.state(t, 1) + + // Hold capture before the first page, close TCP via cancellation, then let + // the captured real LevelDB view return. Completion must not retire A. + f.node.walletSnapshotCaptureMu.RLock() + original := f.node.walletSnapshotCapture + f.node.walletSnapshotCaptureMu.RUnlock() + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + f.node.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + close(entered) + <-release + return original() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", target, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Fixture-Cancel-Witness", "1") + result := make(chan error, 1) + go func() { + response, err := f.http.Client().Do(req) + if response != nil { + response.Body.Close() + } + result <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("capture not entered") + } + // Observe cancellation at the server, not merely client-side Do returning. + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("canceled client received a response") + } + case <-time.After(5 * time.Second): + t.Fatal("client did not cancel") + } + select { + case <-f.canceled: + case <-time.After(5 * time.Second): + t.Fatal("server did not observe TCP cancellation") + } + releaseOnce.Do(func() { close(release) }) + f.state(t, 1) + f.node.SetWalletSnapshotCapture(original) + next, err := url.ParseQuery(f.query.Encode()) + if err != nil { + t.Fatal(err) + } + next.Set("snapshot_id", first["snapshot_id"].(string)) + next.Set("cursor", first["cursor"].(string)) + code, page := snapshotRequest(t, f.http.Client(), "GET", f.http.URL+"/api/v1/wallet/changes?"+next.Encode(), nil) + if code != 200 || page["snapshot_id"] != first["snapshot_id"] { + t.Fatalf("other reader's snapshot lost: %d %v", code, page) + } + snapshotRequest(t, f.http.Client(), "DELETE", snapshotURL(f.http.URL, "/api/v1/wallet/snapshot", crypto.Address(f.query.Get("address")), first["snapshot_id"].(string), ""), nil) + f.state(t, 0) +} diff --git a/avm/block_executor.go b/avm/block_executor.go index f9f4ea3..e3ad525 100644 --- a/avm/block_executor.go +++ b/avm/block_executor.go @@ -30,6 +30,7 @@ type Receipt struct { // canonical height index and tip. type PreparedBlock struct { LPoD *store.LPoDSettlement + Sale *store.SaleSettlement Height uint64 BlockHash crypto.Hash32 Receipts []Receipt diff --git a/avm/leveldb_store.go b/avm/leveldb_store.go index 209fcc3..90be5a2 100644 --- a/avm/leveldb_store.go +++ b/avm/leveldb_store.go @@ -37,13 +37,16 @@ func CommitCanonicalBlock(db *store.DB, block *core.Block, rawBlock []byte, prep return fmt.Errorf("avm: prepared effects do not match block") } var settlement []*store.LPoDSettlement - if prepared.LPoD != nil { settlement = append(settlement, prepared.LPoD) } - return db.CommitRawBlockWithAVM( + if prepared.LPoD != nil { + settlement = append(settlement, prepared.LPoD) + } + return db.CommitRawBlockWithSale( hash, block.Header.Height, rawBlock, toStoreWrites(prepared.Writes), prepared.WriteSetCommitment, + prepared.Sale, settlement..., ) } diff --git a/cmd/genesis-reserve/main.go b/cmd/genesis-reserve/main.go new file mode 100644 index 0000000..2e21b3f --- /dev/null +++ b/cmd/genesis-reserve/main.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "syscall" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" + "github.com/aperod/aperod/wallet" +) + +func writeExclusive(path string, value interface{}) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { return err } + ok := false + defer func() { f.Close(); if !ok { os.Remove(path) } }() + if err = json.NewEncoder(f).Encode(value); err != nil { return err } + if err = f.Sync(); err != nil { return err } + ok = true + return nil +} + +func main() { + if err := run(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) } +} + +func run() error { + action := flag.String("action", "", "init-wallet, rekey-wallet or attest") + path := flag.String("wallet", "", "encrypted treasury keystore path") + output := flag.String("output", "", "new exclusive witness/keystore path") + signer := flag.String("validator-key", "", "0600 raw validator signing key") + genesis := flag.String("genesis", "", "canonical genesis hash") + snapshot := flag.String("snapshot", "", "canonical v3 nominal snapshot root") + height := flag.Uint64("height", 0, "exact future activation height") + database := flag.String("database", "", "offline canonical chain DB; fills exact activation/genesis/snapshot") + address := flag.String("liquidity-address", "", "public treasury address") + flag.Parse() + var offlineDB *store.DB + password := os.Getenv("SESSION_SECRET") + if *action == "init-wallet" || *action == "rekey-wallet" || *action == "normalize-wallet" { + if len(password) < 32 || *path == "" { return fmt.Errorf("protected keystore password/path unavailable") } + if *action == "init-wallet" { + mnemonic, err := wallet.GenerateMnemonic(256) + if err != nil { return err } + 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)) + if err != nil { return err } + if err = writeExclusive(*path, keystore); err != nil { return err } + return json.NewEncoder(os.Stdout).Encode(map[string]string{"address": string(address), "custody": "encrypted_operator_keystore"}) + } + raw, err := os.ReadFile(*path) + if err != nil { return fmt.Errorf("protected keystore unavailable") } + var keystore wallet.KeystoreV1 + if err = json.Unmarshal(raw, &keystore); err != nil { return err } + mnemonic, err := wallet.DecryptMnemonic(&keystore, password) + if err != nil { return fmt.Errorf("protected keystore authentication failed") } + keys, err := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) + if *action == "normalize-wallet" { + if err != nil || *output == "" || (string(keys.Address) != keystore.Address && string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address) { + return fmt.Errorf("wallet normalization integrity check failed") + } + keystore.Address = string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) + if err := writeExclusive(*output, &keystore); err != nil { return err } + return json.NewEncoder(os.Stdout).Encode(map[string]string{"address": keystore.Address}) + } + if err != nil || string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address { return fmt.Errorf("keystore address integrity failed") } + 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) + if err != nil { return err } + return json.NewEncoder(os.Stdout).Encode(rekeyed) + } + if *action == "attest" && *database != "" { + db, err := store.Open(*database) + if err != nil { return err } + offlineDB = db + defer db.Close() + _, tip, err := db.GetTip() + if err != nil { return err } + cp, err := db.LoadLPoDCheckpoint() + if err != nil || cp == nil || cp.Allocation == nil || cp.Allocation.Version != 3 || cp.GenesisVesting != nil || cp.State.LastHeight != tip { + return fmt.Errorf("canonical v3 prerequisite absent or annual reserve already active") + } + *height = tip+1 + *genesis = hex.EncodeToString(cp.Allocation.Genesis[:]) + *snapshot = hex.EncodeToString(cp.Allocation.SnapshotRoot[:]) + } + if *action != "attest" || *height == 0 || *output == "" { return fmt.Errorf("explicit action/future activation/output required") } + g, err := hex.DecodeString(*genesis) + if err != nil || len(g) != 32 { return fmt.Errorf("invalid genesis hash") } + s, err := hex.DecodeString(*snapshot) + if err != nil || len(s) != 32 { return fmt.Errorf("invalid nominal snapshot root") } + info, err := os.Lstat(*signer) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { return fmt.Errorf("signing key must be a 0600 regular file") } + fd, err := syscall.Open(*signer, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { return fmt.Errorf("cannot securely open signing key") } + file := os.NewFile(uintptr(fd), *signer) + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, 65)) + if err != nil || (len(raw) != 32 && len(raw) != 64) { return fmt.Errorf("invalid signing key length") } + defer crypto.ZeroBytes(raw) + if len(raw) == 32 { raw = ed25519.NewKeyFromSeed(raw); defer crypto.ZeroBytes(raw) } + key, err := crypto.ValidatorPrivKeyFromBytes(raw) + if err != nil { return err } + proof := &store.GenesisVestingFunding{Version: 2, Height: *height, + Recipient: store.GenesisVestingRecipient, Reserve: store.GenesisVestingReserve, + OtherAllocations: store.GenesisNominalTotal-store.GenesisVestingReserve-store.GenesisLiquidityAmount, + Trust: store.GenesisVestingTrust, LiquidityRecipient: crypto.Address(*address), + LiquidityAmount: store.GenesisLiquidityAmount, LiquidityTrust: store.GenesisLiquidityTrust} + copy(proof.Genesis[:], g); copy(proof.Snapshot[:], s) + if _, _, _, err = crypto.DecodeAddress(proof.LiquidityRecipient); err != nil { return fmt.Errorf("invalid public treasury address") } + signature, err := key.Sign(proof.Message()) + if err != nil { return err } + proof.Attestations = []store.LPoDAttestation{{Validator: key.Public(), Signature: signature}} + if offlineDB != nil { + if err := offlineDB.CheckGenesisVestingConfig(*height, proof); err != nil { return err } + } + return writeExclusive(*output, proof) +} \ No newline at end of file diff --git a/cmd/mint-record-audit/main.go b/cmd/mint-record-audit/main.go new file mode 100644 index 0000000..d609e6e --- /dev/null +++ b/cmd/mint-record-audit/main.go @@ -0,0 +1,228 @@ +// mint-record-audit verifies old operator records against a CLOSED database +// copy. It never updates amounts, repairs state or remints missing outputs. +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +type record struct { + ID int `json:"id"` + Address string `json:"address"` + TxHash string `json:"tx_hash"` + OutputIndex uint32 `json:"output_index"` + CandidateAPRO string `json:"candidate_apro"` + BlindHex string `json:"blind_hex"` + // Optional public output material, e.g. from a pinned node response. Its + // opening alone is NEVER a canonical membership or unspentness claim. + PublicCommit string `json:"public_commit_hex,omitempty"` + PublicHeight uint64 `json:"public_height,omitempty"` + WholeOpeningCap uint64 `json:"whole_opening_cap,omitempty"` +} +type evidence struct { + ID int `json:"id"` + Status string `json:"status"` + Amount *uint64 `json:"verified_amount_napro,string"` + Height uint64 `json:"stored_output_height"` + CanonicalBody bool `json:"canonical_body_verified"` + MaterialSource string `json:"material_source"` +} + +func amountCandidate(s string) (uint64, bool) { + x, ok := new(big.Rat).SetString(s) + if !ok || x.Sign() < 0 { + return 0, false + } + x.Mul(x, new(big.Rat).SetInt64(100000000)) + if !x.IsInt() || !x.Num().IsUint64() { + return 0, false + } + return x.Num().Uint64(), true +} + +// A display value is only a search hint. Accept it ONLY when the derived blind +// and actual output commitment both match. Never trust the SQL float itself. +func recoverAmount(r record, u *store.StoredUTXO, maxWhole uint64, extra []uint64) (*uint64, string) { + _, pub, _, err := crypto.DecodeAddress(crypto.Address(r.Address)) + if err != nil { + return nil, "invalid_address" + } + raw, err := hex.DecodeString(r.BlindHex) + if err != nil || len(raw) != 32 { + return nil, "missing_blind_evidence" + } + var blind crypto.BlindFactor + copy(blind[:], raw) + verify := func(n uint64) bool { + b, err := crypto.DeterministicMintBlind(pub, n) + if err != nil || !bytes.Equal(b[:], blind[:]) { + return false + } + c, err := crypto.Commit(n, b) + return err == nil && c == u.AmountCommit + } + candidates := append([]uint64(nil), extra...) + if n, ok := amountCandidate(r.CandidateAPRO); ok { + candidates = append(candidates, n) + } + for _, n := range candidates { + // Old releases also used random blinds. A candidate opening with the + // recorded blind is sufficient when the ACTUAL commitment matches. + c, err := crypto.Commit(n, blind) + if (err == nil && c == u.AmountCommit) || verify(n) { + v := n + return &v, "opening_verified" + } + } + for whole := uint64(0); whole <= maxWhole; whole++ { + n := whole * 100000000 + if verify(n) { + v := n + return &v, "opening_verified" + } + } + if r.WholeOpeningCap != 0 { + n, err := wholeOpening(u.AmountCommit, blind, r.WholeOpeningCap) + if err != nil { + return nil, "whole_opening_search_invalid" + } + if n != nil { + return n, "opening_verified" + } + } + return nil, "opening_unresolved" +} + +func audit(d *store.DB, r record, maxWhole uint64, extra []uint64) evidence { + out := evidence{ID: r.ID} + raw, err := hex.DecodeString(r.TxHash) + if err != nil || len(raw) != 32 { + out.Status = "invalid_transaction_identity" + return out + } + var hash crypto.Hash32 + copy(hash[:], raw) + u, err := d.GetUTXO(hash, r.OutputIndex) + if err != nil { + out.Status = "output_read_failed" + return out + } + if u == nil { + if r.PublicCommit == "" { + out.Status = "output_absent_not_reminted" + return out + } + material, err := hex.DecodeString(r.PublicCommit) + if err != nil || len(material) != 32 { + out.Status = "invalid_public_material" + return out + } + u = &store.StoredUTXO{BlockHeight: r.PublicHeight} + copy(u.AmountCommit[:], material) + out.MaterialSource = "caller_public_material_not_snapshot_output" + } else { + out.MaterialSource = "closed_database_output" + } + out.Height = u.BlockHeight + out.Amount, out.Status = recoverAmount(r, u, maxWhole, extra) + if out.Amount == nil { + return out + } + block, err := d.GetBlockByHeight(u.BlockHeight) + if err != nil || block == nil { + out.Status = "opening_verified_body_unavailable" + return out + } + for _, rawTx := range block.TxData { + var tx core.Transaction + if json.Unmarshal(rawTx, &tx) != nil || tx.Hash() != hash { + continue + } + if int(r.OutputIndex) >= len(tx.Outputs) || tx.Outputs[r.OutputIndex].AmountCommit != u.AmountCommit { + out.Status = "canonical_output_mismatch" + return out + } + out.CanonicalBody = true + out.Status = "canonical_opening_verified_spentness_unknown" + return out + } + out.Status = "opening_verified_transaction_not_in_canonical_body" + return out +} + +func run() error { + path := flag.String("copied-db", "", "closed, consistent LevelDB COPY only") + max := flag.Uint64("max-whole-apro", 2000000, "bounded whole-APRO search, at most 2000000") + extraText := flag.String("extra-candidates-napro", "[]", "JSON array of decimal STRINGS; search hints, not evidence") + flag.Parse() + if *path == "" || *max > 2000000 { + return fmt.Errorf("closed copy and bounded search required") + } + target, err := filepath.EvalSymlinks(*path) + if err != nil { + return err + } + if target == "/opt/aperod/data/testnet/chain.db" { + return fmt.Errorf("refusing live production database") + } + var extraStrings []string + if err := json.Unmarshal([]byte(*extraText), &extraStrings); err != nil || len(extraStrings) > 100 { + return fmt.Errorf("invalid candidate list") + } + var extra []uint64 + for _, s := range extraStrings { + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || strconv.FormatUint(n, 10) != s { + return fmt.Errorf("invalid candidate") + } + extra = append(extra, n) + } + var rows []record + data, err := io.ReadAll(io.LimitReader(os.Stdin, 1048577)) + if err != nil || len(data) > 1048576 { + return fmt.Errorf("input too large") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&rows); err != nil || len(rows) > 100 { + return fmt.Errorf("invalid bounded input") + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return fmt.Errorf("trailing input") + } + d, err := store.OpenReadOnly(target) + if err != nil { + return err + } + defer d.Close() + results := make([]evidence, 0, len(rows)) + for _, r := range rows { + results = append(results, audit(d, r, *max, extra)) + } + // No addresses, blind scalars, keys or input records are emitted. + return json.NewEncoder(os.Stdout).Encode(map[string]any{ + "scope": "read-only mint opening audit; not a global issuance or wallet spentness proof", + "activation_authorized": false, "records": results, + }) +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "mint record audit:", err) + os.Exit(1) + } +} diff --git a/cmd/mint-record-audit/main_test.go b/cmd/mint-record-audit/main_test.go new file mode 100644 index 0000000..84b8389 --- /dev/null +++ b/cmd/mint-record-audit/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "path/filepath" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestMintDisplayHintRequiresActualOpening(t *testing.T) { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + spend, view := keys.Spend.Public, keys.View.Public + address := crypto.EncodeAddress(crypto.MainnetByte, spend, view) + const n = 55105 * 100000000 + blind, err := crypto.DeterministicMintBlind(spend, n) + if err != nil { + t.Fatal(err) + } + commit, err := crypto.Commit(n, blind) + if err != nil { + t.Fatal(err) + } + r := record{Address: string(address), BlindHex: hex.EncodeToString(blind[:]), CandidateAPRO: "0"} + u := &store.StoredUTXO{AmountCommit: commit} + amount, status := recoverAmount(r, u, 3, []uint64{n}) + if amount == nil || *amount != n || status != "opening_verified" { + t.Fatal("exact opening not recovered") + } + r.CandidateAPRO = "55105" + amount, _ = recoverAmount(r, u, 0, nil) + if amount == nil || *amount != n { + t.Fatal("verified candidate rejected") + } + u.AmountCommit[0] ^= 1 + if amount, _ := recoverAmount(r, u, 0, []uint64{n}); amount != nil { + t.Fatal("display hint used despite mismatching actual commitment") + } + randomBlind := blind + randomBlind[0] ^= 1 + randomCommit, err := crypto.Commit(n, randomBlind) + if err != nil { + t.Fatal(err) + } + r.BlindHex = hex.EncodeToString(randomBlind[:]) + u.AmountCommit = randomCommit + amount, _ = recoverAmount(r, u, 0, nil) + if amount == nil || *amount != n { + t.Fatal("legacy random-blind opening rejected") + } +} + +func TestMintAuditNeedsCanonicalBodyAndNeverMutates(t *testing.T) { + path := filepath.Join(t.TempDir(), "chain") + d, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer d.Close() + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + spend, view := keys.Spend.Public, keys.View.Public + address := crypto.EncodeAddress(crypto.MainnetByte, spend, view) + blind, _ := crypto.DeterministicMintBlind(spend, 100000000) + commit, _ := crypto.Commit(100000000, blind) + tx := core.Transaction{Outputs: []core.Output{{AmountCommit: commit}}} + hash := tx.Hash() + u := &store.StoredUTXO{TxHash: hash, BlockHeight: 5, AmountCommit: commit} + if err := d.PutUTXO(hash, 0, u); err != nil { + t.Fatal(err) + } + r := record{ID: 1, Address: string(address), TxHash: hex.EncodeToString(hash[:]), CandidateAPRO: "1", BlindHex: hex.EncodeToString(blind[:])} + missing := audit(d, r, 0, nil) + if missing.Amount == nil || missing.CanonicalBody { + t.Fatal("opening alone became canonical membership") + } + txData, _ := json.Marshal(tx) + blockHash := crypto.HashBytes([]byte("canonical block")) + if err := d.PutBlock(blockHash, &store.StoredBlock{Height: 5, Hash: blockHash, TxData: []json.RawMessage{txData}}); err != nil { + t.Fatal(err) + } + verified := audit(d, r, 0, nil) + if verified.Amount == nil || !verified.CanonicalBody { + t.Fatal("canonical matched output not verified") + } + r.OutputIndex = 1 + absent := audit(d, r, 0, nil) + if absent.Amount != nil || absent.Status != "output_absent_not_reminted" { + t.Fatal("missing output recreated") + } + if u, err := d.GetUTXO(hash, 1); err != nil || u != nil { + t.Fatal("audit wrote missing output") + } + r.OutputIndex = 0 + if err := d.DeleteUTXO(hash, 0); err != nil { + t.Fatal(err) + } + r.PublicCommit = hex.EncodeToString(commit[:]) + r.PublicHeight = 5 + public := audit(d, r, 0, nil) + if public.Amount == nil || !public.CanonicalBody || public.MaterialSource != "caller_public_material_not_snapshot_output" { + t.Fatal("caller material not checked against canonical body") + } + r.OutputIndex = 1 + unbound := audit(d, r, 0, nil) + if unbound.CanonicalBody || unbound.Status != "canonical_output_mismatch" { + t.Fatal("public opening became fabricated canonical output") + } +} + +func TestMintCandidateUnitConversionsAreOnlyHints(t *testing.T) { + for _, s := range []string{"-1", "NaN", "Infinity", "0.000000001", "184467440737.09551616"} { + if _, ok := amountCandidate(s); ok { + t.Fatal("invalid hint accepted", s) + } + } + if n, ok := amountCandidate("0.1"); !ok || n != 10000000 { + t.Fatal("APRO conversion incorrect") + } +} diff --git a/cmd/mint-record-audit/whole_opening.go b/cmd/mint-record-audit/whole_opening.go new file mode 100644 index 0000000..01c0cbc --- /dev/null +++ b/cmd/mint-record-audit/whole_opening.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/binary" + "fmt" + "math" + + "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" +) + +// wholeOpening is a bounded baby-step/giant-step opening of P-rG = vH. +// It needs the recorded blind, not any spend/view secret. Fractional-APRO +// outputs will not match this search and remain unknown; no rounding is used. +func wholeOpening(commit crypto.Commitment, blind crypto.BlindFactor, max uint64) (*uint64, error) { + if max > 10000000000 { + return nil, fmt.Errorf("whole amount range exceeds primary cap") + } + p, err := new(edwards25519.Point).SetBytes(commit[:]) + if err != nil { + return nil, err + } + zero, err := crypto.Commit(0, blind) + if err != nil { + return nil, err + } + rg, err := new(edwards25519.Point).SetBytes(zero[:]) + if err != nil { + return nil, err + } + p.Subtract(p, rg) + var noBlind crypto.BlindFactor + hCommit, err := crypto.Commit(100000000, noBlind) + if err != nil { + return nil, err + } + h, err := new(edwards25519.Point).SetBytes(hCommit[:]) + if err != nil { + return nil, err + } + m := uint64(math.Sqrt(float64(max))) + 1 + key := func(p *edwards25519.Point) [32]byte { var k [32]byte; copy(k[:], p.Bytes()); return k } + table := make(map[[32]byte]uint64, m) + jPoint := edwards25519.NewIdentityPoint() + for j := uint64(0); j < m; j++ { + table[key(jPoint)] = j + jPoint.Add(jPoint, h) + } + var encoded [32]byte + binary.LittleEndian.PutUint64(encoded[:], m) + scalar, err := new(edwards25519.Scalar).SetCanonicalBytes(encoded[:]) + if err != nil { + return nil, err + } + stride := new(edwards25519.Point).ScalarMult(scalar, h) + for i := uint64(0); i <= max/m; i++ { + if j, ok := table[key(p)]; ok { + n := i*m + j + if n <= max { + amount := n * 100000000 + opening, err := crypto.Commit(amount, blind) + if err == nil && opening == commit { + return &amount, nil + } + } + } + p.Subtract(p, stride) + } + return nil, nil +} diff --git a/cmd/mint-record-audit/whole_opening_test.go b/cmd/mint-record-audit/whole_opening_test.go new file mode 100644 index 0000000..79eda3a --- /dev/null +++ b/cmd/mint-record-audit/whole_opening_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "testing" + + "github.com/aperod/aperod/crypto" +) + +func TestWholeOpeningUsesRecordedBlindWithoutAssumingMintDerivation(t *testing.T) { + blind, err := crypto.DeterministicMintBlind(crypto.Point32{7}, 123) + if err != nil { + t.Fatal(err) + } + for _, whole := range []uint64{0, 1, 37, 1000, 65535} { + c, err := crypto.Commit(whole*100000000, blind) + if err != nil { + t.Fatal(err) + } + v, err := wholeOpening(c, blind, 65535) + if err != nil || v == nil || *v != whole*100000000 { + t.Fatal("whole opening not recovered", whole, err) + } + } + c, _ := crypto.Commit(100000001, blind) + if v, err := wholeOpening(c, blind, 65535); err != nil || v != nil { + t.Fatal("fractional output rounded into an integer") + } + c, _ = crypto.Commit(65536*100000000, blind) + if v, err := wholeOpening(c, blind, 65535); err != nil || v != nil { + t.Fatal("bounded search exceeded declared range") + } + if _, err := wholeOpening(c, blind, 10000000001); err == nil { + t.Fatal("unbounded work accepted") + } +} diff --git a/cmd/node/main.go b/cmd/node/main.go index 1e326f6..eb823c4 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -792,6 +792,27 @@ func checkStartupIntegrity( } func run() error { +// Preflight must never dispatch a config-free mutating maintenance mode. +preflightRequested := false +preflightCapability := false +for _, arg := range os.Args[1:] { +if arg == "--sale-upgrade-preflight" { preflightRequested = true } +if arg == "--sale-upgrade-preflight-capability" { preflightRequested = true; preflightCapability = true } +} +if preflightRequested { +for _, arg := range os.Args[1:] { +switch arg { +case "--check-store", "--check-snapshot", "--compact-db", "--repair-height-index": +return fmt.Errorf("sale preflight cannot be combined with other maintenance modes") +} +} +} +if preflightCapability { +// --validate-config accompanies this probe so an older binary which ignores +// unknown flags can only validate configuration, never start a live node. +fmt.Fprintln(os.Stdout, `{"schema":1,"command":"sale-upgrade-preflight"}`) +return nil +} // ── 0. config-free maintenance subcommands ──────────────────────────────── // Processed before config loading so operators can verify a chain.db that // was just rsync'd without needing a fully-configured node.yaml. @@ -813,6 +834,8 @@ func run() error { configFlagExplicit := false resetP2PIdentity := false validateOnly := false +saleUpgradePreflight := false +saleUpgradeCopy := "" strictMemLimit := false resetTip := false repairDB := false @@ -830,6 +853,11 @@ func run() error { resetP2PIdentity = true case "--validate-config": validateOnly = true +case "--sale-upgrade-preflight": +saleUpgradePreflight = true +case "--sale-upgrade-copy": +if i+2 >= len(os.Args) { return fmt.Errorf("--sale-upgrade-copy requires a completed backup directory") } +saleUpgradeCopy = os.Args[i+2] case "--strict-memlimit": strictMemLimit = true case "--reset-tip": @@ -897,6 +925,15 @@ func run() error { if err != nil { return fmt.Errorf("load config: %w", err) } +// Read-only inspection does not activate any consensus configuration. +if saleUpgradePreflight { +if !configFlagExplicit || cfg.DataDir == "" { return fmt.Errorf("sale preflight requires explicit --config and data_dir") } +if validateOnly || resetTip || repairDB || rebuildKeyImages || forcePurgeKIIndex || rebuildSpentIndex || resetP2PIdentity { +return fmt.Errorf("sale preflight cannot be combined with mutation or validation shortcuts") +} +return runSaleUpgradePreflight(cfg, saleUpgradeCopy, os.Stdout) +} +if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sale-upgrade-preflight") } if err := guardGuardianFundActivation(cfg); err != nil { return err } @@ -916,7 +953,7 @@ func run() error { absConfig, absDataDir) } - // --validate-config: exit 0 after a successful parse+validate so +// --validate-config: exit 0 after a successful parse+validate so // operators (and node-config.sh) can verify node.yaml without starting // the node. Prints the config path and network name so the caller can // confirm which file was checked. @@ -938,7 +975,7 @@ func run() error { return nil } - // ── 2. Setup logger ─────────────────────────────────────────────────────── +// ── 2. Setup logger ─────────────────────────────────────────────────────── level := slog.LevelInfo switch cfg.LogLevel { case "debug": @@ -1315,6 +1352,15 @@ func run() error { // The resume path initialises it inside the startup scan below. var registry *core.ValidatorRegistry var lpodMigration *store.LPoDMigration + saleMigration, err := db.LoadSaleMigrationFile(cfg.Consensus.SaleMigrationFile) + if err != nil { + log.Error("protocol sale startup validation failed", "err", err) + os.Exit(1) + } + authorityFork, err := loadValidatorAuthorityFork(cfg.Consensus) + if err != nil { + return err + } // ── Early API server (syncing phase) ───────────────────────────────────── // Create and start the API server before the startup block scan so that @@ -1327,6 +1373,9 @@ func run() error { var apiSrv *api.Server if cfg.API.Enabled && cfg.API.ListenAddr != "" { apiSrv = api.NewServer(cfg.API.ListenAddr, chain, mempool, utxos, log) +apiSrv.SetStakedCollateralRingExclusionActivationHeight( + cfg.Consensus.StakedCollateralRingExclusionActivationHeight, +) apiSrv.SetAllowedOrigins(cfg.API.CORS) apiSrv.SetNodeViewKey(cfg.Consensus.ViewKey) apiSrv.SetStore(db) @@ -1808,6 +1857,11 @@ func run() error { } } // InitFromGenesis is idempotent (!exists guard), so NewEngine's later + if authorityFork != nil { + if err := registry.ConfigureSignatureAuthority(authorityFork, tipHeight+1); err != nil { + return err + } + } // call is a safe no-op for genesis validators already in the registry. genesisStakeForReplay := core.MinStakeNAPR * 10 // must match consensus.NewEngine registry.InitFromGenesis(validators, genesisStakeForReplay) @@ -2605,8 +2659,23 @@ func run() error { mempool.SetStakeAdmissionCheck(func(tx core.Transaction, height uint64) error { return registry.ValidateBlockStakeTxs([]core.Transaction{tx}, height) }) + var genesisVestingFunding *store.GenesisVestingFunding + if cfg.Consensus.GenesisVestingActivationHeight > 0 { + var err error + genesisVestingFunding, err = store.LoadGenesisVestingFunding(cfg.Consensus.GenesisVestingFundingFile, cfg.Consensus.GenesisVestingActivationHeight) + if err != nil { + return fmt.Errorf("load Development Fund authorization: %w", err) + } + } + if err := db.CheckGenesisVestingConfig(cfg.Consensus.GenesisVestingActivationHeight, genesisVestingFunding); err != nil { + return fmt.Errorf("Development Fund startup safety: %w", err) + } engine = consensus.NewEngine(consensus.Config{ + ValidatorAuthorityFork: authorityFork, LPoDMigration: lpodMigration, + SaleMigration: saleMigration, + GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, + GenesisVestingFunding: genesisVestingFunding, BlockTime: cfg.Consensus.BlockTime, BFTThreshold: genesisConfig.BFTThreshold, Validators: validators, @@ -2622,6 +2691,7 @@ func run() error { OracleMaxDeviation: cfg.Consensus.OracleMaxDeviation, RingCTV4ActivationHeight: cfg.Consensus.RingCTV4ActivationHeight, RingCTCLSAGActivationHeight: cfg.Consensus.RingCTCLSAGActivationHeight, +StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRingExclusionActivationHeight, AVMActivationHeight: cfg.Consensus.AVMActivationHeight, AVMGasBurnActivationHeight: cfg.Consensus.AVMGasBurnActivationHeight, AVMExecutor: avm.NewBlockExecutor(avm.LevelStore{DB: db}), @@ -2864,6 +2934,8 @@ func run() error { }, chain, mempool, log) if apiSrv != nil { apiSrv.SetLPoDConfig(lpodMigration, engine.IsFinalizedHash) +apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalizedHash) + apiSrv.SetGenesisVestingConfig(cfg.Consensus.GenesisVestingActivationHeight) } if db != nil && lpodMigration != nil && lpodMigration.Version == 3 { if err := db.BackfillLPoDEarningsIndex(lpodMigration); err != nil { @@ -2879,6 +2951,9 @@ func run() error { // where blocks are accepted without cryptographic verification (nil verifier // = fail-open). Wiring first closes that window entirely. txVerifier := core.NewTxVerifier(utxos) +txVerifier.SetStakedCollateralRingExclusionActivationHeight( + cfg.Consensus.StakedCollateralRingExclusionActivationHeight, +) // Wire vesting enforcement so the verifier rejects spending of still-locked // genesis allocations at the protocol level (not just display-only). // Use the actual persisted genesis block timestamp (nanoseconds ÷ 1e9) rather @@ -3033,6 +3108,10 @@ func run() error { // Wire engine-dependent options now that the consensus engine exists. apiSrv.SetRegistry(engine.Registry()) apiSrv.SetValidatorKey(myKey) + if err := apiSrv.SetAuditValidatorAuthorityFork(authorityFork); err != nil { + log.Error("daily audit authority binding failed", "err", err) + os.Exit(1) + } apiSrv.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { var read *store.WalletReadSnapshot err := engine.WithCanonicalRead(func() error { diff --git a/cmd/node/sale_upgrade_preflight.go b/cmd/node/sale_upgrade_preflight.go new file mode 100644 index 0000000..ddcff5c --- /dev/null +++ b/cmd/node/sale_upgrade_preflight.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// The normal updater obtains a new completed logical copy via the private Unix +// socket while the service keeps running. An explicit copy is for offline +// verification only; it must include the same completed manifest. +func runSaleUpgradePreflight(cfg *config.Config, stage string, out io.Writer) error { + if stage == "" { + socket := filepath.Join(cfg.DataDir, backupSocketName) + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", socket) + }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 20 * time.Minute} + response, err := client.Post("http://localhost/checkpoint", "", nil) + if err != nil { + return fmt.Errorf("sale preflight: fresh consistent backup unavailable; service must remain running") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("sale preflight: backup refused (%d); service must remain running", response.StatusCode) + } + var reply checkpointResponse + if err := decodeSalePreflightJSON(response.Body, &reply); err != nil { + return err + } + root, err := filepath.EvalSymlinks(filepath.Join(cfg.DataDir, ".backup-staging")) + if err != nil { + return err + } + resolved, err := filepath.EvalSymlinks(reply.Path) + if err != nil || filepath.Dir(resolved) != root || !strings.HasPrefix(filepath.Base(resolved), "checkpoint-") { + return fmt.Errorf("sale preflight: backup response outside owned checkpoint directory") + } + stage = resolved + // Preserve refused copies as evidence. Successful copies are disposable. + return verifySaleUpgradeStage(cfg, stage, &reply, out, true) + } + return verifySaleUpgradeStage(cfg, stage, nil, out, false) +} + +func decodeSalePreflightJSON(reader io.Reader, value any) error { + decoder := json.NewDecoder(io.LimitReader(reader, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return fmt.Errorf("sale preflight: invalid backup receipt") + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return fmt.Errorf("sale preflight: trailing backup receipt") + } + return nil +} + +func verifySaleUpgradeStage(cfg *config.Config, stage string, reply *checkpointResponse, out io.Writer, disposable bool) error { + file, err := os.Open(filepath.Join(stage, "manifest.json")) + if err != nil { + return fmt.Errorf("sale preflight: completed backup manifest required") + } + var manifest checkpointManifest + decodeErr := decodeSalePreflightJSON(file, &manifest) + closeErr := file.Close() + if decodeErr != nil { + return decodeErr + } + if closeErr != nil { + return closeErr + } + hashRaw, err := hex.DecodeString(manifest.TipHash) + if err != nil || len(hashRaw) != 32 || !manifest.Success || + manifest.TipHash != strings.ToLower(manifest.TipHash) { + return fmt.Errorf("sale preflight: incomplete backup manifest") + } + if reply != nil && (reply.TipHash != manifest.TipHash || reply.TipHeight != manifest.TipHeight) { + return fmt.Errorf("sale preflight: backup response and manifest disagree") + } + var hash crypto.Hash32 + copy(hash[:], hashRaw) + report, err := store.CheckSaleUpgradeCopy(filepath.Join(stage, "chain.db"), + filepath.Join(cfg.DataDir, "chain.db"), cfg.Consensus.SaleMigrationFile, hash, manifest.TipHeight) + if err != nil { + return err + } + if err := json.NewEncoder(out).Encode(report); err != nil { + return err + } + if disposable { + if err := os.RemoveAll(stage); err != nil { + return fmt.Errorf("sale preflight: verified temporary copy cleanup failed") + } + } + return nil +} diff --git a/cmd/node/sale_upgrade_preflight_test.go b/cmd/node/sale_upgrade_preflight_test.go new file mode 100644 index 0000000..fe695c3 --- /dev/null +++ b/cmd/node/sale_upgrade_preflight_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func saleUpgradeStage(t *testing.T, root string) (string, checkpointManifest) { + t.Helper() + stage, err := os.MkdirTemp(root, "checkpoint-") + if err != nil { + t.Fatal(err) + } + db, err := store.Open(filepath.Join(stage, "chain.db")) + if err != nil { + t.Fatal(err) + } + block := core.Block{} + block.Header.MerkleRoot = core.MerkleRoot(nil) + raw, _ := json.Marshal(block) + hash := block.Hash() + if err := db.PutRawBlock(hash, 0, raw); err != nil { + t.Fatal(err) + } + if err := db.PutTip(hash, 0); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + manifest := checkpointManifest{Success: true, TipHash: fmt.Sprintf("%x", hash[:]), TipHeight: 0} + raw, _ = json.Marshal(manifest) + if err := os.WriteFile(filepath.Join(stage, "manifest.json"), raw, 0600); err != nil { + t.Fatal(err) + } + return stage, manifest +} + +func TestSaleUpgradeStageManifest(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir()} + stage, manifest := saleUpgradeStage(t, t.TempDir()) + var output bytes.Buffer + if err := verifySaleUpgradeStage(cfg, stage, nil, &output, false); err != nil { + t.Fatal(err) + } + var report store.SaleUpgradeReport + if err := json.Unmarshal(output.Bytes(), &report); err != nil || report.Status != "ok" || report.Mode != "inactive" { + t.Fatal(output.String(), err) + } + reply := checkpointResponse{Path: stage, TipHeight: 1, TipHash: manifest.TipHash} + if err := verifySaleUpgradeStage(cfg, stage, &reply, io.Discard, false); err == nil { + t.Fatal("receipt/manifest disagreement accepted") + } + manifest.Success = false + raw, _ := json.Marshal(manifest) + os.WriteFile(filepath.Join(stage, "manifest.json"), raw, 0600) + if err := verifySaleUpgradeStage(cfg, stage, nil, io.Discard, false); err == nil { + t.Fatal("incomplete copy accepted") + } +} + +func TestSaleUpgradeFreshSocketCopy(t *testing.T) { + dataDir := t.TempDir() + root := filepath.Join(dataDir, ".backup-staging") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + stage, manifest := saleUpgradeStage(t, root) + path := filepath.Join(dataDir, backupSocketName) + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/checkpoint" || r.ContentLength != 0 { + t.Error("unexpected backup request") + w.WriteHeader(400) + return + } + json.NewEncoder(w).Encode(checkpointResponse{Path: stage, TipHeight: manifest.TipHeight, TipHash: manifest.TipHash}) + })} + go server.Serve(listener) + defer server.Close() + cfg := &config.Config{DataDir: dataDir} + if err := runSaleUpgradePreflight(cfg, "", io.Discard); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stage); !os.IsNotExist(err) { + t.Fatal("successful owned temporary copy not cleaned") + } + if _, err := os.Stat(root); err != nil { + t.Fatal("cleanup removed parent") + } +} + +func TestSaleUpgradeCannotDispatchMutatingModes(t *testing.T) { + before := os.Args + defer func() { os.Args = before }() + for _, mode := range []string{"--compact-db", "--repair-height-index", "--check-store", "--check-snapshot"} { + os.Args = []string{"node", "--sale-upgrade-preflight", mode} + if err := run(); err == nil { + t.Fatalf("accepted incompatible mode %s", mode) + } + } +} + +func TestSaleUpgradeCapabilityDoesNotLoadConfig(t *testing.T) { + beforeArgs, beforeStdout := os.Args, os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer reader.Close() + defer func() { os.Args, os.Stdout = beforeArgs, beforeStdout; writer.Close() }() + os.Args = []string{"node", "--sale-upgrade-preflight-capability", "--validate-config", "--config", "/definitely-missing-config"} + os.Stdout = writer + if err := run(); err != nil { + t.Fatal(err) + } + writer.Close() + result, err := io.ReadAll(reader) + if err != nil || string(result) != "{\"schema\":1,\"command\":\"sale-upgrade-preflight\"}\n" { + t.Fatalf("capability receipt %s: %v", result, err) + } +} diff --git a/cmd/node/validator_authority.go b/cmd/node/validator_authority.go new file mode 100644 index 0000000..2915e21 --- /dev/null +++ b/cmd/node/validator_authority.go @@ -0,0 +1,70 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// A file is not authorization merely because it contains a valid possession +// signature. The independently deployed configuration pins its full SHA-256. +func loadValidatorAuthorityFork(cfg config.ConsensusConfig) (*core.ValidatorAuthorityFork, error) { + if cfg.ValidatorAuthorityForkFile == "" { + if cfg.ValidatorAuthorityForkSHA256 != "" { + return nil, fmt.Errorf("authority fork digest without a rule file") + } + return nil, nil + } + want, err := hex.DecodeString(cfg.ValidatorAuthorityForkSHA256) + if err != nil || len(want) != 32 { + return nil, fmt.Errorf("authority fork requires an independently approved SHA-256 pin") + } + file, err := os.Open(cfg.ValidatorAuthorityForkFile) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, 65537)) + if err != nil || len(raw) > 65536 { + return nil, fmt.Errorf("authority fork file exceeds limit") + } + sum := sha256.Sum256(raw) + if !bytes.Equal(sum[:], want) { + return nil, fmt.Errorf("authority fork file SHA-256 mismatch") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var rule core.ValidatorAuthorityFork + if err := decoder.Decode(&rule); err != nil { + return nil, err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("authority fork trailing data") + } + if err := rule.Validate(); err != nil { + return nil, err + } + // The old witness signer is allowed ONLY for the approved historical file. + // No substituted/future witness may reuse compromised trust authority. + if cfg.LPoDMigrationFile != "" { + if rule.LegacyWitnessSHA256 == (crypto.Hash32{}) { + return nil, fmt.Errorf("authority fork requires a pinned historical LPoD witness") + } + witness, err := os.ReadFile(cfg.LPoDMigrationFile) + if err != nil { + return nil, err + } + if crypto.Hash32(sha256.Sum256(witness)) != rule.LegacyWitnessSHA256 { + return nil, fmt.Errorf("authority fork historical witness mismatch") + } + } + return &rule, nil +} diff --git a/cmd/node/validator_authority_test.go b/cmd/node/validator_authority_test.go new file mode 100644 index 0000000..f289ac5 --- /dev/null +++ b/cmd/node/validator_authority_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestAuthorityForkLoaderPinsRuleAndHistoricalWitness(t *testing.T) { + old, _, _ := crypto.GenerateValidatorKey() + next, _, _ := crypto.GenerateValidatorKey() + witness := []byte("existing immutable witness") + f := &core.ValidatorAuthorityFork{Version: 1, Height: 10, + Identity: old.Public(), Replacement: next.Public(), Genesis: crypto.HashBytes([]byte("genesis")), + Parent: crypto.HashBytes([]byte("parent")), RegistrySHA256: crypto.HashBytes([]byte("registry")), + LegacyWitnessSHA256: crypto.Hash32(sha256.Sum256(witness))} + f.Possession, _ = next.Sign(f.Message()) + raw, _ := json.Marshal(f) + dir := t.TempDir() + rulePath, witnessPath := filepath.Join(dir, "rule.json"), filepath.Join(dir, "witness.json") + if err := os.WriteFile(rulePath, raw, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(witnessPath, witness, 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + cfg := config.ConsensusConfig{ValidatorAuthorityForkFile: rulePath, + ValidatorAuthorityForkSHA256: hex.EncodeToString(sum[:]), LPoDMigrationFile: witnessPath} + if _, err := loadValidatorAuthorityFork(cfg); err != nil { + t.Fatal(err) + } + cfg.ValidatorAuthorityForkSHA256 = "" + if _, err := loadValidatorAuthorityFork(cfg); err == nil { + t.Fatal("unpinned rule accepted") + } + cfg.ValidatorAuthorityForkSHA256 = hex.EncodeToString(sum[:]) + if err := os.WriteFile(witnessPath, []byte("new forged witness"), 0600); err != nil { + t.Fatal(err) + } + if _, err := loadValidatorAuthorityFork(cfg); err == nil { + t.Fatal("substituted witness accepted") + } +} diff --git a/cmd/private-history-fixture/main.go b/cmd/private-history-fixture/main.go new file mode 100644 index 0000000..d71de06 --- /dev/null +++ b/cmd/private-history-fixture/main.go @@ -0,0 +1,206 @@ +// Offline cryptographic scanner fixture. This is not production data or an +// end-to-end consensus/issuance proof. No payment or network request is made. +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/wallet" +) + +type output struct { + Amount string `json:"amount_napr,omitempty"` + Hash string `json:"tx_hash"` + Index int `json:"out_idx"` + Pub string `json:"one_time_pub"` + TxPub string `json:"tx_pub_key"` + Commit string `json:"amount_commit"` + Encrypted string `json:"enc_amount"` + Height uint64 `json:"block_height"` +} +type evidence struct { + Height uint64 `json:"block_height"` + Hash string `json:"block_hash"` + Fee string `json:"fee_napro"` + Origin string `json:"origin"` +} +type fixture struct { + Scope string `json:"scope"` + Mnemonic string `json:"test_mnemonic"` + Address crypto.Address `json:"address"` + Snapshot string `json:"snapshot_id"` + Checkpoint string `json:"checkpoint_hash"` + Height uint64 `json:"checkpoint_height"` + Expected string `json:"expected_napro"` + Outputs []output `json:"outputs"` + Evidence map[string]evidence `json:"canonical_outputs"` + Images []string `json:"owner_key_images"` + Bodies []core.Block `json:"bodies"` +} + +func run() error { + count := flag.Int("rows", 12000, "owned cryptographic output count (256..12000)") + path := flag.String("out", "", "temporary output JSON path") + mainnet := flag.Bool("mainnet", true, "derive the address prefix used by native wallets") + historical := flag.Int("historical-rows", 0, "synthetic old mint/reward outputs before the sale anchor (0..512)") + flag.Parse() + if *count < 256 || *count > 12000 || *path == "" || *historical < 0 || *historical > 512 || *historical >= *count { + return fmt.Errorf("rows must be 256..12000 and out is required") + } + // Public, standardized synthetic mnemonic; never import it with real funds. + phrase := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + dk, err := wallet.DeriveFromMnemonic(phrase, "", 0, 0) + if err != nil { + return err + } + if *mainnet { + dk.Address = crypto.AddressFromKeys(crypto.MainnetByte, dk.Keys) + } + network, _, _, err := crypto.DecodeAddress(dk.Address) + if err != nil { + return err + } + authority, pub, err := crypto.GenerateValidatorKey() + if err != nil { + return err + } + genesis := core.Block{Header: core.BlockHeader{MerkleRoot: core.MerkleRoot(nil)}} + cp := sale.Checkpoint{Genesis: genesis.Hash(), Parent: genesis.Hash(), Height: uint64(*historical + 1), Network: network, + SnapshotRoot: crypto.HashBytes([]byte("synthetic forward anchor; no historical issuance claim")), + SaleRemaining: 100000 * 100000000, SaleAuthority: pub} + trusted := []crypto.ValidatorPubKey{} + authorities := []crypto.ValidatorPrivKey{} + for i := 0; i < 4; i++ { + k, p, err := crypto.GenerateValidatorKey() + if err != nil { + return err + } + trusted = append(trusted, p) + authorities = append(authorities, k) + } + var ledger *sale.Ledger + f := fixture{Scope: "synthetic signed-body/scanner rehearsal, not network consensus proof", Mnemonic: phrase, Address: dk.Address, + Snapshot: hex.EncodeToString(cp.SnapshotRoot[:]), Evidence: map[string]evidence{}, Bodies: []core.Block{genesis}} + scanner := wallet.NewScannerFromDerived(dk) + parent := genesis.Hash() + var expected uint64 + for i := 0; i < *count; i++ { + height := uint64(i + 1) + amount := uint64(i%7+1) * 100000000 + origin := "protocol_sale" + var tx *core.Transaction + if i < *historical { + amount = 3 * 100000000 + origin = "validator_reward" + if i < *historical/2 { + tx, err = core.BuildMintTx(dk.Address, amount, height) + } else { + tx, err = core.BuildAuthorizedRewardTx(dk.Address, amount, height, parent, authority) + } + } else { + if ledger == nil { + cp.Parent, cp.HistoricIssued = parent, expected + for j := 0; j < 3; j++ { + sig, e := authorities[j].Sign(cp.Message()) + if e != nil { + return e + } + cp.Attestations = append(cp.Attestations, sale.Attestation{Validator: trusted[j], Signature: sig}) + } + ledger, err = sale.Begin(cp, cp.Genesis, cp.Parent, cp.Height, trusted) + if err != nil { + return err + } + } + tx, err = core.BuildSaleTransaction(core.SaleAuthorization{Genesis: cp.Genesis, Height: height, Parent: parent, + Order: crypto.HashBytes([]byte(fmt.Sprintf("fixture-order-%d", i))), + Payment: crypto.HashBytes([]byte(fmt.Sprintf("fixture-payment-%d", i))), Amount: amount}, dk.Address, cp.Network, authority) + } + if err != nil { + return err + } + b := core.Block{Header: core.BlockHeader{Height: height, PrevHash: parent, Timestamp: int64(height) * 3000000000, ValidatorPub: pub}, Txs: []core.Transaction{*tx}} + b.Header.MerkleRoot = core.MerkleRoot(b.Txs) + if err = b.Header.Sign(authority); err != nil { + return err + } + if tx.IsSale() { + ledger, err = ledger.ApplyCanonicalTransactions(height, parent, b.Hash(), []*core.Transaction{tx}) + if err != nil { + return err + } + } + owned := scanner.ScanBlock(&b) + legacyAmount := "" + if i < *historical { + // The native legacy path takes the historical reward amount hint, + // then checks its deterministic commitment and height-derived key. + // The generic stealth scanner deliberately skips these outputs. + hs := crypto.ScalarFromUint64(height) + offset, e := crypto.ScalarMulBase(hs) + if e != nil { + return e + } + expectedPub, e := crypto.AddPoints(dk.Keys.Spend.Public, offset) + if e != nil { + return e + } + blind, e := crypto.DeterministicMintBlindV2(dk.Keys.Spend.Public, amount, height) + if e != nil { + return e + } + commit, e := crypto.Commit(amount, blind) + if e != nil { + return e + } + if tx.Outputs[0].OneTimePub != expectedPub || tx.Outputs[0].AmountCommit != commit { + return fmt.Errorf("historical opening or ownership failed at %d", height) + } + owned = []core.OwnedUTXO{{Amount: amount, HsScalar: hs, Blind: blind}} + legacyAmount = fmt.Sprint(amount) + } + if len(owned) != 1 || owned[0].Amount != amount { + return fmt.Errorf("scanner did not independently recover row %d", i) + } + secret, err := crypto.AddScalars(owned[0].HsScalar, dk.Keys.Spend.Private) + if err != nil { + return err + } + image, err := crypto.ComputeKeyImage(secret, tx.Outputs[0].OneTimePub) + if err != nil { + return err + } + f.Images = append(f.Images, hex.EncodeToString(image[:])) + o := tx.Outputs[0] + hash := tx.Hash() + bh := b.Hash() + f.Outputs = append(f.Outputs, output{Amount: legacyAmount, Hash: hex.EncodeToString(hash[:]), Pub: hex.EncodeToString(o.OneTimePub[:]), + TxPub: hex.EncodeToString(o.TxPubKey[:]), Commit: hex.EncodeToString(o.AmountCommit[:]), + Encrypted: hex.EncodeToString(o.EncAmount[:]), Height: height}) + f.Evidence[fmt.Sprintf("%x:0", hash)] = evidence{Height: height, Hash: hex.EncodeToString(bh[:]), Fee: "0", Origin: origin} + f.Bodies = append(f.Bodies, b) + parent = bh + expected += amount + } + f.Checkpoint = hex.EncodeToString(parent[:]) + f.Height = uint64(*count) + f.Expected = fmt.Sprint(expected) + raw, err := json.Marshal(f) + if err != nil { + return err + } + return os.WriteFile(*path, raw, 0600) +} +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/sale-epoch-proposal/authority.go b/cmd/sale-epoch-proposal/authority.go new file mode 100644 index 0000000..d8dab68 --- /dev/null +++ b/cmd/sale-epoch-proposal/authority.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// Resolve only the independently pinned, canonical authority fork already +// configured on the producer. Native validator identity is not its signing key. +func signingAuthority(cfg config.ConsensusConfig, d *store.DB) (*core.ValidatorAuthorityFork, error) { + if cfg.ValidatorAuthorityForkFile == "" { + if cfg.ValidatorAuthorityForkSHA256 != "" { return nil, fmt.Errorf("authority digest without rule") } + return nil, nil + } + want, err := hex.DecodeString(cfg.ValidatorAuthorityForkSHA256) + if err != nil || len(want) != 32 { return nil, fmt.Errorf("independent authority digest required") } + f, err := os.Open(cfg.ValidatorAuthorityForkFile) + if err != nil { return nil, err } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 65537)) + if err != nil || len(raw) > 65536 { return nil, fmt.Errorf("authority rule exceeds limit") } + sum := sha256.Sum256(raw) + if !bytes.Equal(sum[:], want) { return nil, fmt.Errorf("authority digest mismatch") } + dec := json.NewDecoder(bytes.NewReader(raw)); dec.DisallowUnknownFields() + var rule core.ValidatorAuthorityFork + if err := dec.Decode(&rule); err != nil { return nil, err } + if err := dec.Decode(new(any)); err != io.EOF { return nil, fmt.Errorf("authority trailing data") } + if err := rule.Validate(); err != nil { return nil, err } + for _, anchor := range []struct{ height uint64; hash crypto.Hash32 }{{0, rule.Genesis}, {rule.Height-1, rule.Parent}} { + hash, found, err := d.GetCanonicalHash(anchor.height) + if err != nil || !found || hash != anchor.hash { return nil, fmt.Errorf("authority canonical anchor mismatch") } + } + if cfg.LPoDMigrationFile != "" { + witness, err := os.ReadFile(cfg.LPoDMigrationFile) + if err != nil || rule.LegacyWitnessSHA256 == (crypto.Hash32{}) || + crypto.Hash32(sha256.Sum256(witness)) != rule.LegacyWitnessSHA256 { + return nil, fmt.Errorf("authority historical witness mismatch") + } + } + return &rule, nil +} diff --git a/cmd/sale-epoch-proposal/main.go b/cmd/sale-epoch-proposal/main.go new file mode 100644 index 0000000..e586cdc --- /dev/null +++ b/cmd/sale-epoch-proposal/main.go @@ -0,0 +1,172 @@ +// Prepare an explicit nominal epoch from a CLOSED canonical database. +// It does not start consensus, mutate allocation state, or admit payments. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" +) + +func run() error { + dbPath := flag.String("db", "", "closed canonical chain.db path") + cfgPath := flag.String("config", "", "node config; required only for signing") + out := flag.String("out", "", "new configuration file, never overwritten") + burn := flag.String("burned-napro", "", "explicit operator-attested recorded burn baseline") + sign := flag.Bool("sign", false, "sign with the current producer after authorized checks") + flag.Parse() + if *dbPath == "" || *out == "" || *burn == "" { + return fmt.Errorf("db, out and exact burned-napro required") + } + burned, err := strconv.ParseUint(*burn, 10, 64) + if err != nil || strconv.FormatUint(burned, 10) != *burn { + return fmt.Errorf("invalid exact recorded burn") + } + d, err := store.Open(*dbPath) + if err != nil { + return fmt.Errorf("closed database required: %w", err) + } + defer d.Close() + active, err := d.LoadSaleState() + if err != nil || active != nil { + return fmt.Errorf("existing sale ledger must not be reopened as a fresh budget") + } + parent, height, err := d.GetTip() + if err != nil || height == ^uint64(0) { + return fmt.Errorf("canonical tip unavailable") + } + raw, err := d.GetRawBlockByHeight(0) + if err != nil { + return err + } + var genesis core.Block + if json.Unmarshal(raw, &genesis) != nil { + return fmt.Errorf("canonical genesis unavailable") + } + raw, err = d.GetRawBlockByHeight(height) + if err != nil { + return err + } + var head core.Block + if json.Unmarshal(raw, &head) != nil || head.Hash() != parent { + return fmt.Errorf("canonical parent body mismatch") + } + l, err := d.LoadLPoDCheckpoint() + if err != nil || l == nil || l.Allocation == nil { + return fmt.Errorf("canonical LPoD checkpoint unavailable") + } + e := &sale.AllocationEpochBasis{FundingHeight: l.Allocation.FundingHeight, FundingHash: l.Allocation.FundingBlock, + Burned: burned, ProtectedLPoDPrincipal: l.PrincipalLocked, LegacyHoldingsUnverified: true} + r := sale.Reconciliation{AllocationEpoch: e, Genesis: genesis.Hash(), Parent: parent, + LPoDCommitment: l.Digest(), OtherClaimsRoot: crypto.HashBytes([]byte("aperod/sale/no-additional-obligations/v1")), + HistoricIssued: burned, ValidatorReserved: l.Allocation.ValidatorRemaining, + GuardianReserved: l.State.Balance, LPoDReserved: l.Allocation.Remaining} + left := sale.CapNAPRO + for _, n := range []uint64{burned, r.ValidatorReserved, r.GuardianReserved, r.LPoDReserved} { + if n > left { + return fmt.Errorf("nominal reserves exceed allocation") + } + left -= n + } + e.Available = left + if err := store.VerifySaleReserveFloors(l, r); err != nil { + return err + } + _, reserved, err := r.Unassigned() + if err != nil { + return err + } + cp := sale.Checkpoint{Genesis: genesis.Hash(), Network: crypto.MainnetByte, Parent: parent, Height: height + 1, + SnapshotRoot: r.Digest(), HistoricIssued: burned, OtherReserved: reserved, + SaleRemaining: left, SaleAuthority: head.Header.ValidatorPub} + m := store.SaleMigration{Checkpoint: cp, Reconciliation: r} + if *sign { +if *cfgPath == "" { return fmt.Errorf("explicit node config required for signing") } +cfg, err := config.Load(*cfgPath) +if err != nil { return err } +authority, err := signingAuthority(cfg.Consensus, d) +if err != nil { return err } +certificate, err := d.LoadFinalityCertificate() +if err != nil || certificate == nil || certificate.Height != height || certificate.BlockHash != parent { +return fmt.Errorf("exact canonical parent finality certificate required before signing") +} +producerVote := false +message := crypto.HashBytes([]byte("aperod/finalize/v1"), parent[:]) +for _, vote := range certificate.Votes { +if vote.Validator.Equals(head.Header.ValidatorPub) && authority.Authority(vote.Validator, height).Verify(message, vote.Signature) { +producerVote = true +} +} +if !producerVote { +return fmt.Errorf("canonical producer finality vote required before signing") +} + info, err := os.Stat(cfg.Consensus.ValidatorKey) + if err != nil || info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("protected producer key file required") + } + private, err := os.ReadFile(cfg.Consensus.ValidatorKey) + if err != nil { + return fmt.Errorf("cannot read configured signer") + } + key, err := crypto.NewLockedValidatorKey(private, nil) + crypto.ZeroBytes(private) + if err != nil { + return fmt.Errorf("invalid configured signer") + } + defer key.Destroy() +if !key.Public().Equals(authority.Authority(head.Header.ValidatorPub, height+1)) { + return fmt.Errorf("signer is not the canonical parent producer") + } +// Sale transactions are signed by the active key, not the retained stake ID. +cp.SaleAuthority = key.Public() +m.Checkpoint.SaleAuthority = key.Public() + signature, err := key.Sign(cp.Message()) + if err != nil { + return err + } + m.Trusted = []crypto.ValidatorPubKey{key.Public()} + m.Checkpoint.Attestations = []sale.Attestation{{Validator: key.Public(), Signature: signature}} + known, err := store.SaleKnownIssuedFloor(l) + if err != nil { + return err + } + if err := sale.VerifyReconciledCheckpoint(m.Checkpoint, r, genesis.Hash(), parent, l.Digest(), height+1, known, m.Trusted); err != nil { + return err + } + } + f, err := os.OpenFile(*out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + if err := json.NewEncoder(f).Encode(&m); err != nil { + f.Close() + os.Remove(*out) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(*out) + return err + } + if err := f.Close(); err != nil { + return err + } +checkpointHash := cp.Message() +fmt.Printf("epoch_proposal height=%d available_napro=%d checkpoint=%x historical_supply_verified=false signed=%t\n", height+1, left, checkpointHash[:], *sign) + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/sale-ledger-reconcile/main.go b/cmd/sale-ledger-reconcile/main.go new file mode 100644 index 0000000..263231c --- /dev/null +++ b/cmd/sale-ledger-reconcile/main.go @@ -0,0 +1,65 @@ +// Explicit offline maintenance. Never runs a node or loads a signing key. +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func main() { + cfgPath := flag.String("config", "", "explicit node configuration") + copyPath := flag.String("copy", "", "completed copy chain.db; empty means stopped configured database") + tipHex := flag.String("expected-tip", "", "exact canonical tip hash") + height := flag.Uint64("expected-height", 0, "exact canonical tip height") + apply := flag.Bool("apply", false, "atomically apply validated no-sale catch-up") + flag.Parse() + if *cfgPath == "" { + fail("explicit configuration required") + } + cfg, err := config.Load(*cfgPath) + if err != nil { + fail("configuration unavailable") + } + if cfg.DataDir == "" || cfg.Consensus.SaleMigrationFile == "" { + fail("signed sale configuration required") + } + raw, err := hex.DecodeString(*tipHex) + if err != nil || len(raw) != 32 || *height == 0 { + fail("explicit nonzero expected tip required") + } + var hash crypto.Hash32 + copy(hash[:], raw) + target := *copyPath + if target == "" { + target = filepath.Join(cfg.DataDir, "chain.db") + } + var d *store.DB + if *apply { + d, err = store.Open(target) + } else { + d, err = store.OpenReadOnly(target) + } + if err != nil { + fail("exclusive database unavailable; stop the producer before apply") + } + report, err := d.ReconcileSaleLedger(cfg.Consensus.SaleMigrationFile, hash, *height, *apply) + closeErr := d.Close() + if err != nil { + fail(err.Error()) + } + if closeErr != nil { + fail("database close failed") + } + if json.NewEncoder(os.Stdout).Encode(report) != nil { + fail("receipt write failed") + } +} +func fail(message string) { fmt.Fprintln(os.Stderr, message); os.Exit(1) } diff --git a/cmd/sale-preflight/claims.go b/cmd/sale-preflight/claims.go new file mode 100644 index 0000000..a3ce876 --- /dev/null +++ b/cmd/sale-preflight/claims.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" +) + +// This is an operator declaration, not a signed activation configuration. +type operatorClaims struct { + Scope string `json:"scope"` + GuardianTotal string `json:"guardian_total_napro"` + Additional string `json:"additional_off_registry_napro"` +} + +func exactAmount(value string) (uint64, error) { + n, err := strconv.ParseUint(value, 10, 64) + if err != nil || strconv.FormatUint(n, 10) != value { + return 0, fmt.Errorf("claims require canonical unsigned decimal strings") + } + return n, nil +} + +func loadClaims(path string) (*operatorClaims, error) { + var claims operatorClaims + if err := decodeDeclaration(path, &claims); err != nil { + return nil, err + } + return &claims, nil +} + +func decodeDeclaration(path string, target any) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > 4096 { + return fmt.Errorf("declaration must be a regular file of at most 4096 bytes") + } + decoder := json.NewDecoder(io.LimitReader(f, 4097)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("declaration contains trailing data") + } + return nil +} diff --git a/cmd/sale-preflight/claims_declaration_test.go b/cmd/sale-preflight/claims_declaration_test.go new file mode 100644 index 0000000..ed5c135 --- /dev/null +++ b/cmd/sale-preflight/claims_declaration_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClaimsFileRejectsAmbiguousShapeAndOversize(t *testing.T) { + valid := filepath.Join(t.TempDir(), "valid.json") + if err := os.WriteFile(valid, []byte(`{"scope":"operator-forward-declaration-not-activation-v1","guardian_total_napro":"100000000000000000","additional_off_registry_napro":"0"}`), 0600); err != nil { + t.Fatal(err) + } + if claims, err := loadClaims(valid); err != nil || claims.Additional != "0" { + t.Fatalf("valid declaration rejected: %v", err) + } + for _, content := range []string{ + `{"scope":"operator-forward-declaration-not-activation-v1","guardian_total_napro":100000000000000000,"additional_off_registry_napro":"0"}`, + `{"unknown":"0"}`, + `{} {}`, + `{}` + strings.Repeat(" ", 4096), + } { + file := filepath.Join(t.TempDir(), "claims.json") + if err := os.WriteFile(file, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, err := loadClaims(file); err == nil { + t.Fatal("unsafe claims file accepted") + } + } +} + +func TestExactAmountRejectsAmbiguousDecimalAndOverflow(t *testing.T) { + for _, value := range []string{"", "00", "-1", "1e8", "1.0", "18446744073709551616"} { + if _, err := exactAmount(value); err == nil { + t.Fatalf("accepted %q", value) + } + } + for _, value := range []string{"0", "1", "1000000000000000000"} { + if _, err := exactAmount(value); err != nil { + t.Fatalf("rejected %q: %v", value, err) + } + } +} diff --git a/cmd/sale-preflight/epoch_review.go b/cmd/sale-preflight/epoch_review.go new file mode 100644 index 0000000..69d1b7b --- /dev/null +++ b/cmd/sale-preflight/epoch_review.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/hex" + "fmt" + "strconv" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" +) + +// epochReview reports movements since the canonical LPoD funding anchor. +// These gross flows are not a net wallet balance, a burn baseline or permission +// to issue. In particular, a prior wallet balance is not implicitly zero. +func epochReview(c *store.LPoDCheckpoint) (map[string]any, error) { + if c == nil || c.Allocation == nil || c.Allocation.Version != 3 { + return nil, fmt.Errorf("epoch review: canonical LPoD v3 allocation required") + } + a := c.Allocation + if a.FundingHeight == 0 || a.FundingBlock == (crypto.Hash32{}) || + c.State.LastHeight < a.FundingHeight { + return nil, fmt.Errorf("epoch review: funding anchor unavailable") + } + if err := c.State.Validate(); err != nil { + return nil, err + } + if a.InitialValidatorRemaining > sale.CapNAPRO || + a.ValidatorRemaining > a.InitialValidatorRemaining { + return nil, fmt.Errorf("epoch review: invalid validator reserve movement") + } + draw := a.InitialValidatorRemaining - a.ValidatorRemaining + if c.State.RewardInflow > draw { + return nil, fmt.Errorf("epoch review: LPoD inflow exceeds validator draw") + } + direct := draw - c.State.RewardInflow + if c.State.LeaderPaid > sale.CapNAPRO || + c.State.AngelPaid > sale.CapNAPRO-c.State.LeaderPaid { + return nil, fmt.Errorf("epoch review: invalid LPoD reward payments") + } + paid := c.State.LeaderPaid + c.State.AngelPaid + if direct > sale.CapNAPRO-paid { + return nil, fmt.Errorf("epoch review: reward payments exceed allocation cap") + } + return map[string]any{ + "scope": "gross pool movements since LPoD funding; not net wallet issuance or sale authorization", + "funding_height": a.FundingHeight, + "funding_block_hash": hex.EncodeToString(a.FundingBlock[:]), + "through_height": c.State.LastHeight, + "validator_draw_napro": strconv.FormatUint(draw, 10), + "validator_to_lpod_napro": strconv.FormatUint(c.State.RewardInflow, 10), + "direct_validator_reward_outflow_napro": strconv.FormatUint(direct, 10), + "lpod_reward_outflow_napro": strconv.FormatUint(paid, 10), + "gross_reward_outflow_to_wallets_napro": strconv.FormatUint(direct+paid, 10), + "initial_wallet_carry_napro": nil, + "post_anchor_wallet_burn_napro": nil, + "post_anchor_other_issuance_napro": nil, + "activation_authorized": false, + }, nil +} diff --git a/cmd/sale-preflight/epoch_review_test.go b/cmd/sale-preflight/epoch_review_test.go new file mode 100644 index 0000000..3f81a73 --- /dev/null +++ b/cmd/sale-preflight/epoch_review_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/store" +) + +func epochFixture() *store.LPoDCheckpoint { + return &store.LPoDCheckpoint{ + Allocation: &store.LPoDAllocation{ + Version: 3, FundingHeight: 100, FundingBlock: crypto.Hash32{1}, + InitialValidatorRemaining: 2000 * lpod.Unit, + ValidatorRemaining: 1980 * lpod.Unit, + }, + State: lpod.State{ + FundingDebit: lpod.InitialNAPRO, LastHeight: 110, + RewardInflow: 10 * lpod.Unit, SurplusInflow: 10 * lpod.Unit, + Balance: lpod.InitialNAPRO + 7*lpod.Unit, + LeaderPaid: lpod.Unit, AngelPaid: 2 * lpod.Unit, + AccruedLiability: 2 * lpod.Unit, DeficitOutflow: 3 * lpod.Unit, + }, + } +} + +func TestEpochReviewSeparatesInternalFlowsAndWalletPayments(t *testing.T) { + r, err := epochReview(epochFixture()) + if err != nil { + t.Fatal(err) + } + if r["funding_height"] != uint64(100) || + r["validator_draw_napro"] != "2000000000" || + r["validator_to_lpod_napro"] != "1000000000" || + r["gross_reward_outflow_to_wallets_napro"] != "1300000000" { + t.Fatalf("internal redistribution counted as wallet issuance: %v", r) + } + for _, key := range []string{"initial_wallet_carry_napro", "post_anchor_wallet_burn_napro", "post_anchor_other_issuance_napro"} { + if r[key] != nil { + t.Fatalf("unknown %s silently became zero", key) + } + } + if r["activation_authorized"] != false { + t.Fatal("unsigned review authorized activation") + } +} + +func TestEpochReviewRejectsUnanchoredOrInconsistentCounters(t *testing.T) { + for _, kind := range []string{"height", "hash", "settled", "remaining", "inflow", "conservation"} { + t.Run(kind, func(t *testing.T) { + c := epochFixture() + switch kind { + case "height": + c.Allocation.FundingHeight = 0 + case "hash": + c.Allocation.FundingBlock = crypto.Hash32{} + case "settled": + c.State.LastHeight = 99 + case "remaining": + c.Allocation.ValidatorRemaining = c.Allocation.InitialValidatorRemaining + 1 + case "inflow": + c.Allocation.ValidatorRemaining = c.Allocation.InitialValidatorRemaining + case "conservation": + c.State.Balance++ + } + if _, err := epochReview(c); err == nil { + t.Fatal("invalid epoch accounting accepted") + } + }) + } +} diff --git a/cmd/sale-preflight/main.go b/cmd/sale-preflight/main.go new file mode 100644 index 0000000..cf11241 --- /dev/null +++ b/cmd/sale-preflight/main.go @@ -0,0 +1,154 @@ +// sale-preflight only reads a stopped-node copy. It does not sign, reserve, +// initialize, repair or activate an allocation ledger. +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func run() error { + copied := flag.String("copied-db", "", "separate, quiesced LevelDB copy") + claimsPath := flag.String("claims", "", "optional operator declaration; produces an unsigned snapshot candidate only") + burnReview := flag.String("recorded-burn-napro", "", "optional recorded counter for unsigned post-burn classification review, not historical proof") + walletNetReview := flag.String("outside-wallet-net-napro", "", "optional declared net issued holdings outside LPoD balance/principal, including validator self-stake") + netMode := flag.Bool("net-allocation-candidate", false, "unsigned V2 candidate; requires explicit wallet accounting and operator claims") + walletRoot := flag.String("wallet-accounting-root", "", "exact lowercase hash of the outside-wallet accounting declaration") + flag.Parse() + if *netMode && (*burnReview == "" || *walletNetReview == "" || *walletRoot == "" || *claimsPath == "") { + return fmt.Errorf("V2 candidate requires recorded burn, outside wallet net, wallet root and claims") + } + if !*netMode && *walletRoot != "" { + return fmt.Errorf("wallet accounting root requires V2 candidate mode") + } + if *walletNetReview != "" && *burnReview == "" { + return fmt.Errorf("-outside-wallet-net-napro requires the same -recorded-burn-napro baseline") + } + if *claimsPath != "" && !*netMode { + return fmt.Errorf("claims require explicit net-allocation candidate mode; legacy sale budgets are not supported") + } + if *copied == "" { + return fmt.Errorf("-copied-db is required; never target the running database") + } + target, err := filepath.EvalSymlinks(*copied) + if err != nil { + return err + } + // Production's live path is explicitly excluded even though the storage + // wrapper itself is read-only and refuses to create a missing database. + if target == "/opt/aperod/data/testnet/chain.db" { + return fmt.Errorf("refusing the live production database") + } + db, err := store.OpenReadOnly(target) + if err != nil { + return err + } + defer db.Close() + tip, height, err := db.GetTip() + if err != nil { + return err + } + checkpoint, err := db.LoadLPoDCheckpoint() + if err != nil { + return err + } + if checkpoint == nil || checkpoint.Allocation == nil || checkpoint.Allocation.NominalSnapshot == nil { + return fmt.Errorf("canonical nominal funding declaration unavailable") + } + allocation := checkpoint.Allocation + issued, err := store.SaleKnownIssuedFloor(checkpoint) + if err != nil { + return err + } + sales, err := db.LoadSaleState() + if err != nil { + return err + } + coverage, err := reviewUTXOMaterial(db.IterUTXOs) + if err != nil { + return err + } + digest := checkpoint.Digest() + genesisRaw, genesisErr := db.GetRawBlockByHeight(0) + var genesis core.Block + genesisDecoded := genesisErr == nil && len(genesisRaw) != 0 && json.Unmarshal(genesisRaw, &genesis) == nil + genesisMatches := genesisDecoded && genesis.Hash() == allocation.Genesis + report := map[string]any{ + "scope": "read-only operator snapshot; not historical issuance or finality proof", + "height": height, "tip_hash": hex.EncodeToString(tip[:]), + "lpod_commitment": hex.EncodeToString(digest[:]), + "lpod_height": checkpoint.State.LastHeight, + "declared_issuance_floor_napro": strconv.FormatUint(issued, 10), + "nominal_declaration_before_rewards_napro": strconv.FormatUint(allocation.NominalSnapshot.NominalCirculatingBefore, 10), + "post_declaration_reward_inflow_napro": strconv.FormatUint(checkpoint.State.RewardInflow, 10), + "nominal_eligible_napro": strconv.FormatUint(allocation.NominalEligible, 10), + "lpod_unassigned_napro": strconv.FormatUint(allocation.Remaining, 10), + "initial_validator_remaining_napro": strconv.FormatUint(allocation.InitialValidatorRemaining, 10), + "preexisting_guardian_reservation_napro": strconv.FormatUint(allocation.NominalSnapshot.PreexistingGuardianReservation, 10), + "validator_remaining_napro": strconv.FormatUint(allocation.ValidatorRemaining, 10), + "sale_ledger_present": sales != nil, + "utxo_material_coverage": coverage, + "sale_activation_authorized": false, + "remaining_claims_reconciled": false, + "genesis_body_available": genesisDecoded, + "genesis_body_matches_declaration": genesisMatches, + } + epoch, err := epochReview(checkpoint) + if err != nil { + return err + } + report["lpod_allocation_epoch_review"] = epoch + if *burnReview != "" { + burned, err := exactAmount(*burnReview) + if err != nil { + return err + } + var wallets *uint64 + if *walletNetReview != "" { + v, err := exactAmount(*walletNetReview) + if err != nil { + return err + } + wallets = &v + } + review, err := store.ReviewPostBurnSaleSource(checkpoint, burned, wallets) + if err != nil { + return err + } + report["post_burn_source_review"] = review + report["post_burn_source_review_scope"] = "unsigned classification; outside-wallet net and burn baseline require authority attestation" + if *netMode { + if sales != nil || checkpoint.State.LastHeight != height || !genesisMatches { + return fmt.Errorf("net candidate requires exact unactivated canonical snapshot") + } + claims, err := loadClaims(*claimsPath) + if err != nil { + return err + } + r, budget, err := netCandidate(checkpoint, tip, *claims, burned, *wallets, *walletRoot) + if err != nil { + return err + } + report["net_candidate_reconciliation"] = r + report["net_candidate_sale_budget_napro"] = strconv.FormatUint(budget, 10) + report["checkpoint_signing_authorized"] = false + report["net_candidate_scope"] = "unsigned forward V2 declaration; wallet accounting and burn baseline are operator attestations, not historical proof" + } + } + return json.NewEncoder(os.Stdout).Encode(report) +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "sale preflight:", err) + os.Exit(1) + } +} diff --git a/cmd/sale-preflight/net_candidate.go b/cmd/sale-preflight/net_candidate.go new file mode 100644 index 0000000..f30b18c --- /dev/null +++ b/cmd/sale-preflight/net_candidate.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/hex" + "fmt" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" +) + +func netCandidate(c *store.LPoDCheckpoint, parent crypto.Hash32, claims operatorClaims, burn, wallet uint64, rootText string) (sale.Reconciliation, uint64, error) { + var r sale.Reconciliation + if claims.Scope != "operator-forward-declaration-not-activation-v1" { + return r, 0, fmt.Errorf("net candidate: explicit operator claims required") + } + guardian, err := exactAmount(claims.GuardianTotal) + if err != nil { + return r, 0, err + } + extra, err := exactAmount(claims.Additional) + if err != nil { + return r, 0, err + } + if c == nil || c.Allocation == nil || guardian != c.State.FundingDebit { + return r, 0, fmt.Errorf("net candidate: canonical initial Guardian funding mismatch") + } + raw, err := hex.DecodeString(rootText) + if err != nil || len(raw) != 32 || hex.EncodeToString(raw) != rootText { + return r, 0, fmt.Errorf("net candidate: exact lowercase wallet accounting root required") + } + var root crypto.Hash32 + copy(root[:], raw) + basis := &sale.NetAllocationBasis{Burned: burn, WalletNet: wallet, WalletAccountingRoot: root} + consumed, err := basis.Consumed() + if err != nil { + return r, 0, err + } + review, err := store.ReviewPostBurnSaleSource(c, burn, &wallet) + if err != nil { + return r, 0, err + } + if parent == (crypto.Hash32{}) || c.Allocation.Genesis == (crypto.Hash32{}) { + return r, 0, fmt.Errorf("net candidate: exact canonical identity required") + } + r = sale.Reconciliation{NetAllocation: basis, Genesis: c.Allocation.Genesis, Parent: parent, LPoDCommitment: c.Digest(), + OtherClaimsRoot: crypto.HashBytes([]byte("aperod/sale/net-extra-claims/v2"), []byte(claims.Scope), []byte(claims.Additional)), + HistoricIssued: consumed, ValidatorReserved: review.ValidatorReserve, GuardianReserved: review.LPoDRewardBalance, + LPoDReserved: review.LPoDPrincipal + review.LPoDUnassigned, OtherReserved: extra} + if err := store.VerifySaleReserveFloors(c, r); err != nil { + return r, 0, err + } + budget, _, err := r.Unassigned() + return r, budget, err +} diff --git a/cmd/sale-preflight/net_candidate_test.go b/cmd/sale-preflight/net_candidate_test.go new file mode 100644 index 0000000..95f947f --- /dev/null +++ b/cmd/sale-preflight/net_candidate_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/store" +) + +func TestNetCandidateProtectsPrincipalAndDeclaredWalletsWithoutOldNominalDeduction(t *testing.T) { + genesis := crypto.HashBytes([]byte("genesis")) + parent := crypto.HashBytes([]byte("parent")) + c := &store.LPoDCheckpoint{ + Allocation: &store.LPoDAllocation{Version: 3, Genesis: genesis, ValidatorRemaining: 199731332900000000, + InitialValidatorRemaining: 199731332900000000 + 62503922060849, + NominalSnapshot: &store.LPoDNominalSnapshot{NominalCirculatingBefore: 623000000000000000}}, + State: lpod.State{FundingDebit: 100000000000000000, RewardInflow: 62503922060849, SurplusInflow: 62503922060849, Balance: 100062503922060849}, + PrincipalDeposited: 500000000000000, PrincipalLocked: 500000000000000, + } + claims := operatorClaims{Scope: "operator-forward-declaration-not-activation-v1", GuardianTotal: "100000000000000000", Additional: "0"} + root := crypto.HashBytes([]byte("synthetic accounting only")) + text := hex.EncodeToString(root[:]) + r, budget, err := netCandidate(c, parent, claims, 13745052310704501, 100010000000000, text) + if err != nil { + t.Fatal(err) + } + if budget != 685861100867234650 || r.NetAllocation == nil || r.LPoDReserved != 500000000000000 { + t.Fatalf("wrong classified candidate %d", budget) + } + for _, bad := range []string{"", strings.Repeat("0", 64), strings.ToUpper(text), text + "00"} { + if _, _, err := netCandidate(c, parent, claims, 13745052310704501, 100010000000000, bad); err == nil { + t.Fatal("missing or ambiguous accounting root accepted") + } + } + claims.GuardianTotal = "200000000000000000" + if _, _, err := netCandidate(c, parent, claims, 13745052310704501, 100010000000000, text); err == nil { + t.Fatal("second Guardian allocation accepted") + } +} diff --git a/cmd/sale-preflight/utxo_coverage.go b/cmd/sale-preflight/utxo_coverage.go new file mode 100644 index 0000000..d3f3f49 --- /dev/null +++ b/cmd/sale-preflight/utxo_coverage.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// This is material coverage, not a supply or spendability calculation. Neither +// an optional amount field nor a public spent-output index opens a confidential +// commitment or identifies the owner's hidden real input. +type utxoMaterialCoverage struct { + Scope string `json:"scope"` + Rows uint64 `json:"stored_output_rows"` + AmountMetadataPresent uint64 `json:"amount_metadata_present_rows"` + AmountMetadataMissing uint64 `json:"amount_metadata_missing_rows"` + EncryptedAmountRows uint64 `json:"encrypted_amount_rows"` + ZeroTxPublicKeyRows uint64 `json:"zero_tx_public_key_rows"` + ProtocolLockedRows uint64 `json:"protocol_locked_rows"` + GlobalBalanceVerified bool `json:"global_net_balance_verified"` + OwnerSpentnessVerified bool `json:"owner_spentness_verified"` +} + +func reviewUTXOMaterial(iterate func(func(*store.StoredUTXO) error) error) (utxoMaterialCoverage, error) { + report := utxoMaterialCoverage{ + Scope: "complete stored output namespace; may include spent outputs; metadata is not a verified amount opening", + } + err := iterate(func(u *store.StoredUTXO) error { + if u == nil { + return fmt.Errorf("UTXO material review: nil output") + } + report.Rows++ + if u.AmountNAPRO == 0 { + // Absent metadata is unknown, not evidence of a zero-value output. + report.AmountMetadataMissing++ + } else { + report.AmountMetadataPresent++ + } + if u.EncAmount != ([8]byte{}) { + report.EncryptedAmountRows++ + } + if u.TxPubKey == (crypto.Point32{}) { + report.ZeroTxPublicKeyRows++ + } + if u.ProtocolLocked { + report.ProtocolLockedRows++ + } + return nil + }) + if err != nil { + return utxoMaterialCoverage{}, fmt.Errorf("UTXO material review: %w", err) + } + return report, nil +} diff --git a/cmd/sale-preflight/utxo_coverage_test.go b/cmd/sale-preflight/utxo_coverage_test.go new file mode 100644 index 0000000..0188b61 --- /dev/null +++ b/cmd/sale-preflight/utxo_coverage_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestUTXOMaterialCoverageDoesNotInferNetHoldings(t *testing.T) { + rows := []*store.StoredUTXO{ + {AmountNAPRO: 100, ProtocolLocked: true}, + {TxPubKey: crypto.Point32{1}, EncAmount: [8]byte{2}}, + {}, + } + report, err := reviewUTXOMaterial(func(visit func(*store.StoredUTXO) error) error { + for _, row := range rows { + if err := visit(row); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if report.Rows != 3 || report.AmountMetadataPresent != 1 || + report.AmountMetadataMissing != 2 || report.EncryptedAmountRows != 1 || + report.ZeroTxPublicKeyRows != 2 || report.ProtocolLockedRows != 1 { + t.Fatalf("incorrect material coverage: %+v", report) + } + if report.GlobalBalanceVerified || report.OwnerSpentnessVerified { + t.Fatal("material metadata was promoted to global holdings or spentness proof") + } + if rows[1].AmountNAPRO != 0 { + t.Fatal("review must not invent or rewrite amounts") + } +} + +func TestUTXOMaterialCoverageFailsWithoutPartialEvidence(t *testing.T) { + want := errors.New("storage read failed") + report, err := reviewUTXOMaterial(func(visit func(*store.StoredUTXO) error) error { + if err := visit(&store.StoredUTXO{AmountNAPRO: 100}); err != nil { + return err + } + return want + }) + if !errors.Is(err, want) || report.Rows != 0 { + t.Fatalf("partial coverage survived storage failure: %+v, %v", report, err) + } + if _, err := reviewUTXOMaterial(func(visit func(*store.StoredUTXO) error) error { + return visit(nil) + }); err == nil { + t.Fatal("nil stored output accepted") + } +} diff --git a/config/config.go b/config/config.go index eab544a..8db269b 100644 --- a/config/config.go +++ b/config/config.go @@ -196,10 +196,10 @@ type P2PConfig struct { // under an inbound flood can always gossip produced blocks to the network. // Recommended: 4. 0 = feature disabled. MinOutbound int `yaml:"min_outbound"` -// AllowedPeers is the required list of hex-encoded SHA-256 SPKI fingerprints -// permitted to join a TLS-enabled P2P network. Each peer is authenticated -// against this list during the TLS handshake; an empty or malformed list -// prevents the TLS host from starting. + // AllowedPeers is the required list of hex-encoded SHA-256 SPKI fingerprints + // when P2P TLS is enabled. Any peer whose TLS fingerprint is not on the list + // is rejected during the TLS handshake; an empty or malformed list prevents + // the TLS listener from starting. AllowedPeers []string `yaml:"allowed_peers"` // PeerWhitelist is an optional list of IP addresses and/or CIDR ranges // (e.g. "1.2.3.4", "10.0.0.0/8") whose inbound connections are accepted. @@ -314,13 +314,15 @@ type P2PConfig struct { // ConsensusConfig holds PoA settings. type ConsensusConfig struct { - ValidatorKey string `yaml:"validator_key"` // path to ED25519 key file - ViewKey string `yaml:"view_key"` // hex-encoded Ed25519 view private scalar for automatic UTXO amount decryption (optional) - RewardAddress string `yaml:"reward_address"` // APRO wallet address for block rewards - BlockRewardNAPR uint64 `yaml:"block_reward_napro"` // legacy pre-authorization reward in nAPRO; ignored after reward authorization activation. - BlockTime time.Duration `yaml:"block_time"` - OracleURL string `yaml:"oracle_url"` // HTTP endpoint returning {"price_usd": }; empty = skip - OracleMaxDeviation float64 `yaml:"oracle_max_deviation"` // max fractional price deviation (e.g. 0.05 = 5%); 0 = disabled + ValidatorAuthorityForkFile string `yaml:"validator_authority_fork_file"` + ValidatorAuthorityForkSHA256 string `yaml:"validator_authority_fork_sha256"` + ValidatorKey string `yaml:"validator_key"` // path to ED25519 key file + ViewKey string `yaml:"view_key"` // hex-encoded Ed25519 view private scalar for automatic UTXO amount decryption (optional) + RewardAddress string `yaml:"reward_address"` // APRO wallet address for block rewards + BlockRewardNAPR uint64 `yaml:"block_reward_napro"` // legacy pre-authorization reward in nAPRO; ignored after reward authorization activation. + BlockTime time.Duration `yaml:"block_time"` + OracleURL string `yaml:"oracle_url"` // HTTP endpoint returning {"price_usd": }; empty = skip + OracleMaxDeviation float64 `yaml:"oracle_max_deviation"` // max fractional price deviation (e.g. 0.05 = 5%); 0 = disabled // StopProducingAtHeight is an operator safety barrier: this validator will // not propose blocks at or above this height. Zero disables the barrier. // Relay/non-validator nodes should leave this unset. @@ -352,6 +354,9 @@ type ConsensusConfig struct { // RingCTCLSAGActivationHeight is the first height requiring RingCT v5. // Zero deliberately disables v5 rather than activating it immediately. RingCTCLSAGActivationHeight uint64 `yaml:"ring_ct_clsag_activation_height"` + // StakedCollateralRingExclusionActivationHeight is the first block height + // whose CLSAG rings must exclude staked collateral. Zero disables this fork. + StakedCollateralRingExclusionActivationHeight uint64 `yaml:"staked_collateral_ring_exclusion_activation_height"` // RewardAuthorizationActivationHeight is the first block height whose // validator reward must carry an on-chain authorization. Zero disables the // post-RingCT reward path; set the same future value on every validator. @@ -374,6 +379,12 @@ type ConsensusConfig struct { // LPoDMigrationFile is a complete, height/genesis-bound reconciliation witness. // Empty disables the fork. Merely setting a target amount never funds the pool. LPoDMigrationFile string `yaml:"lpod_migration_file"` + // SaleMigrationFile is a local signed checkpoint and disjoint allocation + // witness. Empty disables sales; it is not an amount or payment-server flag. + SaleMigrationFile string `yaml:"sale_migration_file"` + // Future, coordinated activation of the fixed annual Development Fund schedule. + GenesisVestingActivationHeight uint64 `yaml:"genesis_vesting_activation_height"` + GenesisVestingFundingFile string `yaml:"genesis_vesting_funding_file"` // LPoDV2TrustAuthorities is the explicit version-2 checkpoint trust anchor. // Values are exact 32-byte Ed25519 public-key hex strings; empty disables v2. // It is independent of genesis validators and never changes the consensus @@ -382,20 +393,20 @@ type ConsensusConfig struct { // LPoDV3TrustAuthorities explicitly authorize the trust-attested nominal // reclassification. They do not change the consensus validator set. LPoDV3TrustAuthorities []string `yaml:"lpod_v3_trust_authorities"` -// RegistryBootstrap is an explicit operator-attested offline recovery anchor. -// It is not independent historical committee authentication. -RegistryBootstrap *RegistryBootstrapConfig `yaml:"registry_bootstrap"` + // RegistryBootstrap is an explicit operator-attested offline recovery anchor. + // It is not independent historical committee authentication. + RegistryBootstrap *RegistryBootstrapConfig `yaml:"registry_bootstrap"` } type RegistryBootstrapConfig struct { -Trust string `yaml:"trust"` -SnapshotFile string `yaml:"snapshot_file"` -SHA256 string `yaml:"sha256"` -Height uint64 `yaml:"height"` -// Optional separately pinned public KI export from a DB at this exact anchor. -// Old DB-backed snapshots intentionally omit historical key images. -KeyImageFile string `yaml:"key_image_file"` -KeyImageSHA256 string `yaml:"key_image_sha256"` + Trust string `yaml:"trust"` + SnapshotFile string `yaml:"snapshot_file"` + SHA256 string `yaml:"sha256"` + Height uint64 `yaml:"height"` + // Optional separately pinned public KI export from a DB at this exact anchor. + // Old DB-backed snapshots intentionally omit historical key images. + KeyImageFile string `yaml:"key_image_file"` + KeyImageSHA256 string `yaml:"key_image_sha256"` } // APIConfig holds RPC/REST settings. @@ -617,19 +628,19 @@ func (c *Config) Warnings() []string { // Validate checks that required fields are present and valid. func (c *Config) Validate() error { -if anchor := c.Consensus.RegistryBootstrap; anchor != nil { -if anchor.KeyImageFile != "" || anchor.KeyImageSHA256 != "" { -digest,err:=hex.DecodeString(anchor.KeyImageSHA256) -if anchor.KeyImageFile=="" || err!=nil || len(digest)!=32 { -return fmt.Errorf("registry_bootstrap key-image file requires a SHA-256 digest") -} -} -digest, err := hex.DecodeString(anchor.SHA256) -if !c.Consensus.NonValidator || anchor.Trust != "operator_attested" || -anchor.Height == 0 || anchor.SnapshotFile == "" || err != nil || len(digest) != 32 { -return fmt.Errorf("registry_bootstrap requires non_validator, trust: operator_attested, snapshot_file, height > 0 and a SHA-256 digest") -} -} + if anchor := c.Consensus.RegistryBootstrap; anchor != nil { + if anchor.KeyImageFile != "" || anchor.KeyImageSHA256 != "" { + digest, err := hex.DecodeString(anchor.KeyImageSHA256) + if anchor.KeyImageFile == "" || err != nil || len(digest) != 32 { + return fmt.Errorf("registry_bootstrap key-image file requires a SHA-256 digest") + } + } + digest, err := hex.DecodeString(anchor.SHA256) + if !c.Consensus.NonValidator || anchor.Trust != "operator_attested" || + anchor.Height == 0 || anchor.SnapshotFile == "" || err != nil || len(digest) != 32 { + return fmt.Errorf("registry_bootstrap requires non_validator, trust: operator_attested, snapshot_file, height > 0 and a SHA-256 digest") + } + } if c.Network == "" { return fmt.Errorf("network must be set") } @@ -652,12 +663,25 @@ return fmt.Errorf("registry_bootstrap requires non_validator, trust: operator_at rewardActivation := c.Consensus.RewardAuthorizationActivationHeight ringCTActivation := c.Consensus.RingCTV4ActivationHeight clsagActivation := c.Consensus.RingCTCLSAGActivationHeight + stakedRingActivation := c.Consensus.StakedCollateralRingExclusionActivationHeight + if c.Network == "mainnet" && stakedRingActivation == 0 { + return fmt.Errorf("mainnet nodes must configure staked_collateral_ring_exclusion_activation_height") + } if clsagActivation > 0 && ringCTActivation > 0 && clsagActivation < ringCTActivation { return fmt.Errorf( "ring_ct_clsag_activation_height (%d) must be >= ringct_v4_activation_height (%d)", clsagActivation, ringCTActivation, ) } + if stakedRingActivation > 0 && clsagActivation == 0 { + return fmt.Errorf("staked_collateral_ring_exclusion_activation_height requires ring_ct_clsag_activation_height") + } + if stakedRingActivation > 0 && stakedRingActivation < clsagActivation { + return fmt.Errorf( + "staked_collateral_ring_exclusion_activation_height (%d) must be >= ring_ct_clsag_activation_height (%d)", + stakedRingActivation, clsagActivation, + ) + } if rewardActivation > 0 && ringCTActivation > 0 && rewardActivation < ringCTActivation { return fmt.Errorf( "reward_authorization_activation_height (%d) must be >= ringct_v4_activation_height (%d)", @@ -687,6 +711,12 @@ return fmt.Errorf("registry_bootstrap requires non_validator, trust: operator_at } } guardianActivation := c.Consensus.GuardianFundActivationHeight + if c.Consensus.GenesisVestingActivationHeight > 0 && c.Consensus.LPoDMigrationFile == "" { + return fmt.Errorf("genesis_vesting_activation_height requires a canonical LPoD migration") + } + if (c.Consensus.GenesisVestingActivationHeight > 0) != (c.Consensus.GenesisVestingFundingFile != "") { + return fmt.Errorf("genesis vesting activation height and purpose-bound funding file must be set together") + } guardianAnchor := c.Consensus.GuardianFundChainAnchor if (guardianActivation == 0) != (guardianAnchor == "") { return fmt.Errorf("guardian_fund_activation_height and guardian_fund_chain_anchor must be set together") diff --git a/config/config_test.go b/config/config_test.go index d650dd2..32f58cc 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -163,6 +163,7 @@ func TestLoad_AVMGasBurnActivationPersistsAcrossRestart(t *testing.T) { non_validator: true ringct_v4_activation_height: 100 ring_ct_clsag_activation_height: 101 + staked_collateral_ring_exclusion_activation_height: 130 avm_activation_height: 110 reward_authorization_activation_height: 120 avm_gas_burn_activation_height: 125 @@ -181,6 +182,19 @@ func TestLoad_AVMGasBurnActivationPersistsAcrossRestart(t *testing.T) { if got := firstStart.Consensus.AVMGasBurnActivationHeight; got != 125 { t.Fatalf("loaded activation height = %d, want 125", got) } +if got := firstStart.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { + t.Fatalf("loaded staked-collateral activation height = %d, want 130", got) +} +firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 100 +if err := firstStart.Validate(); err == nil { + t.Fatal("staked-collateral activation before CLSAG activation was accepted") +} +firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 0 +firstStart.Network = "mainnet" +firstStart.Consensus.NonValidator = true +if err := firstStart.Validate(); err == nil { + t.Fatal("mainnet node without an explicit staked-collateral activation was accepted") +} restarted, err := Load(configPath) if err != nil { @@ -190,6 +204,9 @@ func TestLoad_AVMGasBurnActivationPersistsAcrossRestart(t *testing.T) { t.Fatalf("activation after restart = %d, want %d", got, firstStart.Consensus.AVMGasBurnActivationHeight) } +if got := restarted.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { + t.Fatalf("staked-collateral activation after restart = %d, want 130", got) +} if err := restarted.Validate(); err != nil { t.Fatalf("Validate() after restart: %v", err) } diff --git a/config/rehearsal-offline.yaml b/config/rehearsal-offline.yaml index b58ae53..dc898e2 100644 --- a/config/rehearsal-offline.yaml +++ b/config/rehearsal-offline.yaml @@ -16,6 +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 reward_authorization_activation_height: 1750000 avm_activation_height: 1850000 api: diff --git a/consensus/lpod.go b/consensus/lpod.go index 6f7ac7d..ee8f0e8 100644 --- a/consensus/lpod.go +++ b/consensus/lpod.go @@ -70,6 +70,8 @@ func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.L } } r := &store.LPoDSettlement{Parent: block.Header.PrevHash, PositionProtocol: true, Timestamp: block.Header.Timestamp, +GenesisVestingActivationHeight: e.cfg.GenesisVestingActivationHeight, +GenesisVestingFunding: e.cfg.GenesisVestingFunding, Proposer: block.Header.ValidatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{}, PreviousStake: map[string]store.LPoDValidatorStake{}, AuditPreviousStake: map[string]store.LPoDValidatorStake{}, @@ -78,6 +80,9 @@ func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.L // and the dynamic minimum. Keep it before projecting any stake operation in // this block; this evidence is optional and never affects consensus hashes. registryBefore := e.cfg.Registry.TakeSnapshot() +if height == e.cfg.GenesisVestingActivationHeight && height > 0 { +if err := e.requireLPoDParentFinalized(height-1, block.Header.PrevHash); err != nil { return nil, nil, nil, err } +} r.RegistryBefore = ®istryBefore if height == m.Height { r.Migration = m @@ -125,7 +130,7 @@ func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.L if e.txVerifier == nil || e.utxos == nil { return nil, nil, nil, fmt.Errorf("lpod: position input verifier unavailable") } - if err := e.txVerifier.VerifyTx(&tx); err != nil { +if err := e.txVerifier.VerifyTxAtHeight(&tx, block.Header.Height); err != nil { return nil, nil, nil, err } } diff --git a/consensus/native_chrome_transfer_test.go b/consensus/native_chrome_transfer_test.go new file mode 100644 index 0000000..eb439c4 --- /dev/null +++ b/consensus/native_chrome_transfer_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package consensus + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/wallet" +) + +// Opt-in because this drives a real installed Chromium and an isolated PostgreSQL. +// No production connections, keys, validator process or funds are used. +func TestNativeChromeCanonicalTransfer(t *testing.T) { + if os.Getenv("NATIVE_CHROME_TRANSFER_E2E") != "1" { + t.Skip("opt-in native Chromium: NATIVE_CHROME_TRANSFER_E2E=1") + } + senderPhrase, err := wallet.GenerateMnemonic(wallet.Strength256) + if err != nil { + t.Fatal(err) + } + recipientPhrase, err := wallet.GenerateMnemonic(wallet.Strength256) + if err != nil { + t.Fatal(err) + } + owner, err := wallet.DeriveFromMnemonic(senderPhrase, "", 0, 0) + if err != nil { + t.Fatal(err) + } + recipient, err := wallet.DeriveFromMnemonic(recipientPhrase, "", 0, 0) + if err != nil { + t.Fatal(err) + } + e, db, deposit, action, keys, priv, _ := positionEngineWithOwner(t, owner.Keys) + e.cfg.RingCTCLSAGActivationHeight = 2 + e.utxos.SetCLSAGActivationHeight(2) + e.pool.SetVerifier(e.txVerifier) + acceptPositionBlock(t, e, priv, 3_000_000_000, *deposit) + // These are consensus-authorized payout outputs, not UTXO/decoy mocks. + for i := 0; i < 20; 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 + withdrawal, err := core.BuildLPoDPositionTx(exit, keys.Spend.Private, crypto.Scalar32{}) + if err != nil { + t.Fatal(err) + } + 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 { + 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))) + server.SetStore(db) + server.SetLPoDConfig(e.cfg.LPoDMigration, e.IsFinalizedHash) + server.SetWalletSnapshotCapture(db.NewWalletReadSnapshot) + // Deliberately isolated test credential; never a workspace/production key. + const rpcKey = "native-chrome-isolated-fixture-only" + server.SetAPIKey(rpcKey) + server.SetReady() + server.SetUTXOReady() + var mu sync.Mutex + issuedToOwner := func() uint64 { + rows, _, err := db.LPoDWalletOutputs(action.Beneficiary, "", 128) + 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() + mux.HandleFunc("/fixture/mine", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", 405) + return + } + mu.Lock() + defer mu.Unlock() + transactions := e.pool.SelectTxs(100) + if len(transactions) != 1 { + http.Error(w, "expected exactly one genuine mempool transaction", 409) + return + } + beforeFunding := issuedToOwner() + block := acceptPositionBlock(t, e, priv, 1, transactions...) + if !e.IsFinalizedHash(block.Header.Height, block.Hash()) { + t.Error("transfer block not finalized") + http.Error(w, "not finalized", 500) + return + } + accepted := transactions[0] + canonical := e.chain.GetByHeight(block.Header.Height) + found := false + for _, tx := range canonical.Txs { + if tx.Hash() == accepted.Hash() { + found = true + } + } + if !found { + t.Error("accepted hash not in canonical block") + http.Error(w, "missing transaction", 500) + return + } + durable, err := db.GetBlock(block.Hash()) + if err != nil || durable == nil { + t.Error("canonical block not durable") + http.Error(w, "not durable", 500) + return + } + // Exercise the real API's 10-confirmation bookkeeping, without faking status. + for i := 0; i < 10; i++ { + acceptPositionBlock(t, e, priv, 1) + } + afterFunding := issuedToOwner() + if afterFunding < beforeFunding { + t.Error("funding index unexpectedly shrank") + http.Error(w, "funding index changed", 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "tx_hash": fmt.Sprintf("%x", accepted.Hash()), "block_hash": fmt.Sprintf("%x", block.Hash()), + "height": block.Header.Height, "fee_napr": accepted.Fee, "finalized": true, + "canonical_funding_credit_napr": fmt.Sprint(afterFunding - beforeFunding), + }) + }) + mux.Handle("/", server) + httpServer := httptest.NewServer(mux) + defer httpServer.Close() + fixtureDir := t.TempDir() + input := filepath.Join(fixtureDir, "private-input.json") + raw, _ := json.Marshal(map[string]any{ + "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, + }) + if err := os.WriteFile(input, raw, 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("pnpm", "--filter", "@workspace/api-server", "exec", "vitest", "run", + "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"} { + if value, ok := os.LookupEnv(name); ok { + cmd.Env = append(cmd.Env, name+"="+value) + } + } + cmd.Env = append(cmd.Env, "NATIVE_TRANSFER_INPUT="+input) + cmd.WaitDelay = 5 * time.Second + result, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("native transfer fixture failed: %v\n%s", err, result) + } + t.Log(string(result)) +} diff --git a/consensus/poa.go b/consensus/poa.go index b6df4f7..63d890a 100644 --- a/consensus/poa.go +++ b/consensus/poa.go @@ -33,9 +33,16 @@ type AdminMintRecordStore interface { // Config holds PoA consensus parameters. type Config struct { - LPoDMigration *store.LPoDMigration - BlockTime time.Duration - BFTThreshold float64 // fraction of validators needed to finalize (e.g. 0.667) + // ValidatorAuthorityFork separates collateral identity from future signing + // authority. Nil preserves the deployed protocol without any change. + ValidatorAuthorityFork *core.ValidatorAuthorityFork + LPoDMigration *store.LPoDMigration + SaleMigration *store.SaleMigration + // Approved durable purchase intents are built at the real slot and parent. + // Nil never enables the legacy admin-mint path. + SaleTransactions func(height uint64, parent crypto.Hash32) ([]core.Transaction, error) + BlockTime time.Duration + BFTThreshold float64 // fraction of validators needed to finalize (e.g. 0.667) // Validators is the bootstrap list from genesis (used to seed the Registry). // After startup the active set is managed by Registry. Validators []crypto.ValidatorPubKey @@ -120,6 +127,9 @@ type Config struct { // RingCTCLSAGActivationHeight is the first height requiring v5; zero // disables activation to avoid an accidental uncoordinated fork. RingCTCLSAGActivationHeight uint64 +// StakedCollateralRingExclusionActivationHeight is the first height at which +// CLSAG rings must exclude staked collateral. Zero disables this fork. +StakedCollateralRingExclusionActivationHeight uint64 // AVMActivationHeight is the first height accepting v6 AVM transactions. // Zero disables AVM consensus. AVMActivationHeight uint64 @@ -140,6 +150,9 @@ type Config struct { // is always the canonical genesis block hash, never the activation parent. GuardianFundActivationHeight uint64 GuardianFundChainAnchor crypto.Hash32 + // A coordinated future fork earmarks the annual Development Fund reserve. + GenesisVestingActivationHeight uint64 + GenesisVestingFunding *store.GenesisVestingFunding } // FinalizeMsg is a vote by a validator to finalize a block. @@ -323,6 +336,14 @@ const DefaultPoolBlockRewardNAPR uint64 = 300_000_000 // NewEngine creates a new PoA consensus engine. func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logger) *Engine { + cfg.ValidatorAuthorityFork = cfg.ValidatorAuthorityFork.Clone() + if cfg.SaleTransactions == nil && cfg.SaleMigration != nil && cfg.Store != nil && + cfg.MyKey != nil && cfg.MyKey.Public().Equals(cfg.SaleMigration.Checkpoint.SaleAuthority) { + cfg.SaleTransactions = func(height uint64, parent crypto.Hash32) ([]core.Transaction, error) { + return cfg.Store.PendingPaidSales(cfg.SaleMigration.Checkpoint.Genesis, height, parent, + cfg.SaleMigration.Checkpoint.Network, cfg.MyKey) + } + } if cfg.LPoDMigration != nil { m := *cfg.LPoDMigration if m.Version == 1 { @@ -363,6 +384,11 @@ func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logg store: cfg.Store, adminMintStore: adminMintStore, } + if err := e.validateAuthorityFork(); err != nil { + e.halted.Store(true) + e.log.Error("validator authority fork invalid; consensus halted", "err", err) + return e + } // Seed only a fresh registry. A restored snapshot is authoritative: adding // missing static fallback keys after restore would invent active validators // (notably the placeholder genesis key on a non-validator node) and turn @@ -570,6 +596,9 @@ func (e *Engine) TimestampRejectedCount() int64 { func (e *Engine) SetTxVerifier(v *core.TxVerifier, utxos *core.UTXOSet) { e.txVerifier = v e.utxos = utxos +if v != nil { +v.SetStakedCollateralRingExclusionActivationHeight(e.cfg.StakedCollateralRingExclusionActivationHeight) +} } // activeValidators returns the current active validator set. @@ -675,10 +704,18 @@ func (e *Engine) tick() error { } // Check if it's our turn to propose + if e.cfg.Registry != nil { + e.cfg.Registry.SetSignatureAdmissionHeight(nextHeight) + } proposer := e.proposerAt(nextRound) - if e.cfg.MyKey == nil || !proposer.Equals(e.cfg.MyKey.Public()) { + if e.cfg.MyKey == nil || !proposer.Equals(e.localIdentity(nextHeight)) || + !e.authority(proposer, nextHeight).Equals(e.cfg.MyKey.Public()) { return nil // not our slot } + if err := e.checkAuthorityParent(core.BlockHeader{Height: nextHeight, PrevHash: tip.Hash()}); err != nil { + e.halted.Store(true) + return err + } e.log.Info("producing block", "height", nextHeight, "round", nextRound) block, err := e.produceBlock(nextHeight, uint64(nextRound), tip) @@ -829,6 +866,9 @@ func (e *Engine) tick() error { e.pool.SetBaseFee(newFee) // Stake txs already applied above — no processStakeTxs call needed. + if e.cfg.Registry != nil { + e.cfg.Registry.SetSignatureAdmissionHeight(block.Header.Height + 1) + } if e.cfg.Registry != nil && block.Header.Height%core.EpochLength == 0 { newSet := e.cfg.Registry.UpdateEpoch(block.Header.Height) e.log.Info("epoch updated (self-produced)", @@ -1252,7 +1292,7 @@ func (e *Engine) validateBlockEconomics(block *core.Block) error { var totalAVMGas uint64 for i := range block.Txs { tx := &block.Txs[i] - if tx.IsCoinbase() || tx.IsStake() || tx.IsLPoD() || tx.IsLPoDPosition() || tx.IsLPoDPayout() { + if tx.IsCoinbase() || tx.IsStake() || tx.IsLPoD() || tx.IsLPoDPosition() || tx.IsLPoDPayout() || tx.IsSale() { continue } minFee := tx.MinFeeAt(expectedBaseFee) @@ -1570,6 +1610,12 @@ func (e *Engine) resolveAdminMintsPersistenceFailed(err error) { e.mintMu.Unlock() } func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.Block, error) { + if e.cfg.MyKey == nil || !e.authority(e.localIdentity(height), height).Equals(e.cfg.MyKey.Public()) { + return nil, fmt.Errorf("configured key is not the signing authority at height %d", height) + } + if err := e.checkAuthorityParent(core.BlockHeader{Height: height, PrevHash: parent.Hash()}); err != nil { + return nil, err + } if err := e.validateGuardianConfig(); err != nil { return nil, err } @@ -1622,6 +1668,22 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B txs = append(txs, tx) } + if e.cfg.SaleMigration != nil && height >= e.cfg.SaleMigration.Checkpoint.Height && e.cfg.SaleTransactions != nil { + sales, err := e.cfg.SaleTransactions(height, parent.Hash()) + if err != nil { + return nil, fmt.Errorf("sale: approved intent construction failed: %w", err) + } + if len(sales) > 128 { + return nil, fmt.Errorf("sale: approved intent block limit exceeded") + } + for _, tx := range sales { + if !tx.IsSale() { + return nil, fmt.Errorf("sale: intent provider returned a non-sale transaction") + } + } + txs = append(txs, sales...) + } + // Pre-screen: re-verify each ring-sig tx against the current UTXO set // BEFORE including it in the block. A tx that passed Add() when it // entered the mempool may now have an invalid ring sig if the UTXOSet @@ -1644,13 +1706,7 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B screened = append(screened, *tx) continue } - if err := core.ValidateTxVersionAtHeight( - tx, - height, - e.cfg.RingCTV4ActivationHeight, - e.cfg.RingCTCLSAGActivationHeight, - e.cfg.AVMActivationHeight, - ); err != nil { + if err := e.validateVersionWithSales(tx, height, e.cfg.RingCTV4ActivationHeight); err != nil { hash := tx.Hash() e.log.Warn("produceBlock: banning tx rejected by RingCT activation policy", "hash", hash, @@ -1660,7 +1716,12 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B e.pool.BanTx(hash) continue } - if err := e.txVerifier.VerifyTx(tx); err != nil { + if tx.IsSale() { + // Full issuer/budget/replay validation runs against the assembled block. + screened = append(screened, *tx) + continue + } + if err := e.txVerifier.VerifyTxAtHeight(tx, height); err != nil { hash := tx.Hash() e.log.Warn("produceBlock: banning mempool tx that failed re-verification", "hash", hash, @@ -1836,7 +1897,7 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B MerkleRoot: core.MerkleRoot(txs), Timestamp: time.Now().UTC().UnixNano(), Round: uint32(round), - ValidatorPub: e.cfg.MyKey.Public(), + ValidatorPub: e.localIdentity(height), OraclePrice: oraclePrice, BaseFee: currentBaseFee, } @@ -1876,17 +1937,20 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B const maxClockSkewNs = int64(15 * 1_000_000_000) func (e *Engine) handleIncomingBlock(block *core.Block) error { -// An accepted peer block changes the in-memory tip before the durable -// callback commits it. Keep that entire transition atomic with local-vote -// ancestry checks, using the same lock order as tick: productionMu then mu. -e.productionMu.Lock() -defer e.productionMu.Unlock() + // An accepted peer block changes the in-memory tip before the durable + // callback commits it. Keep that entire transition atomic with local-vote + // ancestry checks, using the same lock order as tick: productionMu then mu. + e.productionMu.Lock() + defer e.productionMu.Unlock() if e.halted.Load() { return fmt.Errorf("consensus engine halted after persistent rollback failure") } // Basic structural validation - if err := block.Validate(); err != nil { + if err := e.checkAuthorityParent(block.Header); err != nil { + return err + } + if err := block.Validate(e.authority(block.Header.ValidatorPub, block.Header.Height)); err != nil { return fmt.Errorf("invalid block: %w", err) } @@ -2018,7 +2082,7 @@ defer e.productionMu.Unlock() return fmt.Errorf("tx verifier not configured: refusing to accept block %d without cryptographic verification", block.Header.Height) } - if err := e.txVerifier.VerifyBlock(block); err != nil { + if err := e.verifyBlockWithSales(block); err != nil { return fmt.Errorf("tx crypto verification failed: %w", err) } @@ -2091,6 +2155,9 @@ defer e.productionMu.Unlock() return e.failCanonicalPersistence(block, stakeRollback, persistErr) } // Stake txs already applied above — no processStakeTxs call needed. + if e.cfg.Registry != nil { + e.cfg.Registry.SetSignatureAdmissionHeight(block.Header.Height + 1) + } // ── EIP-1559 base fee update ───────────────────────────────────────────── blockBaseFee := block.Header.BaseFee @@ -2168,13 +2235,7 @@ func (e *Engine) validateRingCTV4Activation(block *core.Block) error { } activation := e.cfg.RingCTV4ActivationHeight for i := range block.Txs { - if err := core.ValidateTxVersionAtHeight( - &block.Txs[i], - block.Header.Height, - activation, - e.cfg.RingCTCLSAGActivationHeight, - e.cfg.AVMActivationHeight, - ); err != nil { + if err := e.validateVersionWithSales(&block.Txs[i], block.Header.Height, activation); err != nil { return fmt.Errorf("tx[%d]: %w", i, err) } } @@ -2240,6 +2301,10 @@ func (e *Engine) prepareAVMBlock(block *core.Block) (*avm.PreparedBlock, error) return nil, err } } + prepared.Sale, err = e.prepareSale(block) + if err != nil { + return nil, err + } return prepared, nil } @@ -2274,7 +2339,7 @@ func (e *Engine) castVote(block *core.Block) error { vote := FinalizeMsg{ BlockHash: hash, Height: block.Header.Height, - ValidatorPub: e.cfg.MyKey.Public(), + ValidatorPub: e.localIdentity(block.Header.Height), Signature: sig, } // Process our own vote immediately @@ -2308,7 +2373,7 @@ func (e *Engine) handleVote(vote FinalizeMsg) error { // Verify the vote signature msg := crypto.HashBytes([]byte("aperod/finalize/v1"), vote.BlockHash[:]) - if !vote.ValidatorPub.Verify(msg, vote.Signature) { + if !e.authority(vote.ValidatorPub, vote.Height).Verify(msg, vote.Signature) { return fmt.Errorf("invalid vote signature from %s", vote.ValidatorPub.ID()) } @@ -2485,7 +2550,7 @@ func (e *Engine) restoreFinalityCertificate() error { message := crypto.HashBytes([]byte("aperod/finalize/v1"), certificate.BlockHash[:]) for _, vote := range certificate.Votes { id := vote.Validator.Hex() - if !committee[id] || seen[id] || !vote.Validator.Verify(message, vote.Signature) { + if !committee[id] || seen[id] || !e.authority(vote.Validator, certificate.Height).Verify(message, vote.Signature) { return fmt.Errorf("finality: invalid, duplicate, or non-committee certificate vote") } seen[id] = true @@ -2546,7 +2611,7 @@ func (e *Engine) restoreLocalFinalityVote() error { return fmt.Errorf("local vote signer is not a current validator") } message := crypto.HashBytes([]byte("aperod/finalize/v1"), vote.BlockHash[:]) - if !vote.Validator.Verify(message, vote.Signature) { + if !e.authority(vote.Validator, vote.Height).Verify(message, vote.Signature) { return fmt.Errorf("local vote signature is invalid") } if err := e.verifyLocalVoteAncestry(vote.Height, vote.BlockHash); err != nil { diff --git a/consensus/sale.go b/consensus/sale.go new file mode 100644 index 0000000..8e65d2e --- /dev/null +++ b/consensus/sale.go @@ -0,0 +1,59 @@ +package consensus + +import ( + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +func (e *Engine) validateVersionWithSales(tx *core.Transaction, height, activation uint64) error { + if tx.IsSale() { + if e.cfg.SaleMigration == nil || height < e.cfg.SaleMigration.Checkpoint.Height { + return fmt.Errorf("sale: inactive transaction version") + } + _, err := tx.SaleAuthorization() + return err + } + return core.ValidateTxVersionAtHeight(tx, height, activation, e.cfg.RingCTCLSAGActivationHeight, e.cfg.AVMActivationHeight) +} + +func (e *Engine) prepareSale(b *core.Block) (*store.SaleSettlement, error) { +if e.cfg.SaleMigration != nil && e.cfg.GenesisVestingActivationHeight != 0 && +e.cfg.GenesisVestingActivationHeight >= e.cfg.SaleMigration.Checkpoint.Height { +return nil, fmt.Errorf("sale: scheduled new genesis reserve overlaps the sale allocation") +} + if e.cfg.Store == nil { + for _, tx := range b.Txs { + if tx.IsSale() { + return nil, fmt.Errorf("sale: durable store missing") + } + } + if e.cfg.SaleMigration != nil && b.Header.Height >= e.cfg.SaleMigration.Checkpoint.Height { + return nil, fmt.Errorf("sale: durable store missing") + } + return nil, nil + } + return e.cfg.Store.PrepareSaleBlock(b, e.cfg.SaleMigration) +} + +func (e *Engine) verifyBlockWithSales(b *core.Block) error { + prepared, err := e.prepareSale(b) + if err != nil { + return err + } + if prepared == nil { + return e.txVerifier.VerifyBlock(b) + } + // The sale-specific validator has checked opening, issuer authority, + // checkpoint, allocation and replay fences. Keep the generic verifier + // unchanged: arbitrary zero-input transactions still fail there. + filtered := *b + filtered.Txs = make([]core.Transaction, 0, len(b.Txs)) + for _, tx := range b.Txs { + if !tx.IsSale() { + filtered.Txs = append(filtered.Txs, tx) + } + } + return e.txVerifier.VerifyBlock(&filtered) +} diff --git a/consensus/sale_finality.go b/consensus/sale_finality.go new file mode 100644 index 0000000..4caea53 --- /dev/null +++ b/consensus/sale_finality.go @@ -0,0 +1,23 @@ +package consensus + +import "github.com/aperod/aperod/crypto" + +// Sales must remain recoverable after restart, when the in-memory per-height +// cache contains only the restored, quorum-verified certificate. A canonical +// ancestor of that finalized block is finalized too. Do not apply this fallback +// to LPoD witness/tier verification, which retains its stricter evidence path. +func (e *Engine) IsSaleFinalizedHash(height uint64, hash crypto.Hash32) bool { + if height == 0 || hash == (crypto.Hash32{}) || e.cfg.Store == nil { return false } + e.mu.Lock() + var anchorHeight uint64 + var anchorHash crypto.Hash32 + for h, v := range e.lpodFinalizedHashes { + if h > anchorHeight && v != (crypto.Hash32{}) { anchorHeight, anchorHash = h, v } + } + e.mu.Unlock() + if anchorHeight < height || anchorHash == (crypto.Hash32{}) { return false } + anchor, found, err := e.cfg.Store.GetCanonicalHash(anchorHeight) + if err != nil || !found || anchor != anchorHash { return false } + canonical, found, err := e.cfg.Store.GetCanonicalHash(height) + return err == nil && found && canonical == hash +} diff --git a/consensus/sale_integration_test.go b/consensus/sale_integration_test.go new file mode 100644 index 0000000..5f59c0d --- /dev/null +++ b/consensus/sale_integration_test.go @@ -0,0 +1,437 @@ +package consensus + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" + "github.com/aperod/aperod/wallet" +) + +// Independent engines use real quorum signatures and separately committed +// 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) +} + +func TestSaleTwoValidatorNetAllocationCanonicalDelivery(t *testing.T) { + testSaleTwoValidatorCanonicalDelivery(t, true) +} + +func testSaleTwoValidatorCanonicalDelivery(t *testing.T, netBasis bool) { + t.Helper() + 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) + if err != nil { + t.Fatal(err) + } + buyer.Address = crypto.AddressFromKeys(crypto.MainnetByte, buyer.Keys) + g := engineBlock(t, 0, 0, crypto.Hash32{}, keys[0], nil) + parent := engineBlock(t, store.LPoDV2ActivationHeight-1, 0, g.Hash(), keys[0], nil) + parent.Header.Timestamp = time.Now().UnixNano() - 6_000_000_000 + parent.Header.Sign(keys[0]) + lm := &store.LPoDMigration{Version: 3, PositionLifecycleVersion: 1, + Height: store.LPoDV2ActivationHeight, Genesis: g.Hash(), ParentHash: parent.Hash(), + TrustAssumption: store.LPoDV3TrustAssumption, NominalEligible: 6_000_000_000 * lpod.Unit, + ValidatorRemaining: 2_000_000_000 * lpod.Unit, TrustedValidators: pubs} + lm.NominalSnapshot = &store.LPoDNominalSnapshot{Basis: store.LPoDV3NominalBasis, + NominalCirculatingBefore: 6_000_000_000 * lpod.Unit, EligibleNominal: lm.NominalEligible, + ValidatorRemaining: lm.ValidatorRemaining, EligibilityRule: store.LPoDV3EligibilityRule} + lm.SnapshotRoot = lm.NominalSnapshot.Root() + lm.ReconciliationRoot = lm.Root() + for i, k := range keys { + sig, err := k.Sign(lm.AttestationMessage()) + if err != nil { + t.Fatal(err) + } + lm.Attestations = append(lm.Attestations, store.LPoDAttestation{Validator: pubs[i], Signature: sig}) + } + dbs := make([]*store.DB, 2) + directories := make([]string, 2) + registries := make([]*core.ValidatorRegistry, 2) + var funding *core.Block + for i := range dbs { + directories[i] = t.TempDir() + d, err := store.Open(directories[i]) + if err != nil { + t.Fatal(err) + } + dbs[i] = d + t.Cleanup(func() { d.Close() }) + raw, _ := json.Marshal(g) + if err = d.CommitRawBlockWithAVM(g.Hash(), 0, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + raw, _ = json.Marshal(parent) + if err = d.PutRawBlock(parent.Hash(), parent.Header.Height, raw); err != nil { + t.Fatal(err) + } + if err = d.PutTip(parent.Hash(), parent.Header.Height); err != nil { + t.Fatal(err) + } + if err = d.StoreStakingPoolRemaining(lm.ValidatorRemaining); err != nil { + t.Fatal(err) + } + rg := core.NewValidatorRegistry() + rg.InitFromGenesis(pubs, 100_000*lpod.Unit) + registries[i] = rg + snapshot := rg.TakeSnapshot() + stakes := map[string]store.LPoDValidatorStake{} + for _, p := range pubs { + stakes[p.Hex()] = store.LPoDValidatorStake{Amount: 100_000 * lpod.Unit, Active: true} + } + settlement := &store.LPoDSettlement{Parent: parent.Hash(), Migration: lm, PositionProtocol: true, + Timestamp: parent.Header.Timestamp + 3_000_000_000, Proposer: pubs[0].Hex(), Leader: buyer.Address, + Stake: stakes, PreviousStake: stakes, AuditPreviousStake: stakes, AuditStake: stakes, + RegistryBefore: &snapshot, RegistryAfter: &snapshot} + c, pay, err := d.PreviewLPoD(lm.Height, settlement) + if err != nil { + t.Fatal(err) + } + reward, err := d.PayoutLPoD(lm.Height, settlement, c, pay) + if err != nil { + t.Fatal(err) + } + b := engineBlock(t, lm.Height, 1, parent.Hash(), keys[0], []core.Transaction{reward, core.LPoDCheckpointTx(c.Digest())}) + b.Header.Timestamp = settlement.Timestamp + b.Header.Sign(keys[0]) + if funding != nil && funding.Hash() != b.Hash() { + t.Fatal("independent funding differs") + } + funding = b + raw, _ = json.Marshal(b) + if err = d.CommitRawBlockWithAVM(b.Hash(), b.Header.Height, raw, nil, crypto.Hash32{}, settlement); err != nil { + t.Fatal(err) + } + } + lp, err := dbs[0].LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + knownIssued, err := store.SaleKnownIssuedFloor(lp) + if err != nil { + t.Fatal(err) + } + reconciliation := sale.Reconciliation{Genesis: g.Hash(), Parent: funding.Hash(), LPoDCommitment: lp.Digest(), + OtherClaimsRoot: crypto.HashBytes([]byte("offline disjoint Guardian reservation")), + HistoricIssued: knownIssued, + ValidatorReserved: lp.Allocation.ValidatorRemaining, GuardianReserved: 1_000_000_000 * lpod.Unit} + if netBasis { + // This fresh fixture has no initial wallet carry or burns. Pool reward + // payments are actual canonical outputs, not a nominal circulation field. + reconciliation.NetAllocation = &sale.NetAllocationBasis{ + WalletNet: lp.State.LeaderPaid + lp.State.AngelPaid, + WalletAccountingRoot: crypto.HashBytes([]byte("offline funded net opening declaration")), + } + reconciliation.HistoricIssued = reconciliation.NetAllocation.WalletNet + reconciliation.GuardianReserved = lp.State.Balance + reconciliation.LPoDReserved = lp.Allocation.Remaining + lp.PrincipalLocked + } + 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]} + for i, k := range keys { + sig, err := k.Sign(cp.Message()) + if err != nil { + t.Fatal(err) + } + cp.Attestations = append(cp.Attestations, sale.Attestation{Validator: pubs[i], Signature: sig}) + } + migration := &store.SaleMigration{Checkpoint: cp, Reconciliation: reconciliation, Trusted: pubs} + // A fully re-signed declaration must not recycle rewards already paid after + // the nominal funding snapshot, even though its remaining validator reserve + // and all other signatures are otherwise valid. + if knownIssued <= lm.NominalSnapshot.NominalCirculatingBefore { + t.Fatal("funded control must include post-declaration reward issuance") + } + stale := *migration + if netBasis { + if reconciliation.NetAllocation.WalletNet == 0 { + t.Fatal("funded net control has no known reward payment") + } + omitted := *reconciliation.NetAllocation + omitted.WalletNet = 0 + stale.Reconciliation.NetAllocation = &omitted + stale.Reconciliation.HistoricIssued = 0 + } else { + stale.Reconciliation.HistoricIssued = lm.NominalSnapshot.NominalCirculatingBefore + } + stale.Checkpoint.HistoricIssued = stale.Reconciliation.HistoricIssued + stale.Checkpoint.SnapshotRoot = stale.Reconciliation.Digest() + stale.Checkpoint.Attestations = nil + for i, k := range keys { + sig, err := k.Sign(stale.Checkpoint.Message()) + if err != nil { + t.Fatal(err) + } + stale.Checkpoint.Attestations = append(stale.Checkpoint.Attestations, sale.Attestation{Validator: pubs[i], Signature: sig}) + } + stalePath := filepath.Join(t.TempDir(), "stale-issued-checkpoint.json") + staleBytes, err := json.Marshal(stale) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stalePath, staleBytes, 0600); err != nil { + t.Fatal(err) + } + for _, d := range dbs { + if _, err := d.LoadSaleMigrationFile(stalePath); err == nil { + t.Fatal("fully signed checkpoint omitted known post-declaration issuance") + } + } + configPath := filepath.Join(t.TempDir(), "sale-checkpoint.json") + configBytes, err := json.Marshal(migration) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, configBytes, 0600); err != nil { + t.Fatal(err) + } + for _, d := range dbs { + if loaded, err := d.LoadSaleMigrationFile(configPath); err != nil || loaded.Checkpoint.Message() != cp.Message() { + t.Fatal("signed exact-parent startup configuration rejected", err) + } + } + engines := make([]*Engine, 2) + for i, d := range dbs { + chain := core.NewChain() + if err := chain.SetGenesis(g); err != nil { + t.Fatal(err) + } + chain.FastForward([]*core.Block{parent, funding}) + locked, err := crypto.NewLockedValidatorKey(keys[i].Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(locked.Destroy) + cfg := Config{MyKey: locked, Validators: pubs, Registry: registries[i], Store: d, + LPoDMigration: lm, SaleMigration: migration, StakingPoolNAPR: lm.ValidatorRemaining, + RewardAddress: string(buyer.Address), RewardAuthorizationActivationHeight: lm.Height, + BFTThreshold: .667, RingCTV4ActivationHeight: lm.Height} + cfg.OnCanonicalBlock = func(b *core.Block, p *avm.PreparedBlock) error { + raw, err := json.Marshal(b) + if err != nil { + return err + } + return avm.CommitCanonicalBlock(d, b, raw, p) + } + e := NewEngine(cfg, chain, core.NewMempool(core.DefaultMempoolConfig()), testNopLogger()) + u := core.NewUTXOSet() + if err := u.ApplyBlock(funding); err != nil { + t.Fatal(err) + } + registries[i].SetUTXOSet(u) + e.SetTxVerifier(core.NewTxVerifier(u), u) + engines[i] = e + } + // Both restored engines authenticate the exact parent with two real votes. + for _, e := range engines { + for i, k := range keys { + sig, err := k.Sign(crypto.HashBytes([]byte("aperod/finalize/v1"), funding.Hash().Bytes())) + if err != nil { + t.Fatal(err) + } + if err = e.HandleVote(FinalizeMsg{Height: funding.Header.Height, BlockHash: funding.Hash(), ValidatorPub: pubs[i], Signature: sig}); err != nil { + t.Fatal(err) + } + } + if !e.IsFinalizedHash(funding.Header.Height, funding.Hash()) { + t.Fatal("parent lacks real quorum") + } + } + var producer *Engine + for _, e := range engines { + if e.proposerAt(2).Equals(e.cfg.MyKey.Public()) { + producer = e + } + } + 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]) + if err != nil { + return nil, err + } + return []core.Transaction{*tx}, nil + } + block, err := producer.produceBlock(cp.Height, 2, funding) + if err != nil { + t.Fatal(err) + } + for _, e := range engines { + if err = e.handleIncomingBlock(block); err != nil { + t.Fatal(err) + } + } + a, err := dbs[0].LoadSaleState() + if err != nil { + t.Fatal(err) + } + b, err := dbs[1].LoadSaleState() + if err != nil { + t.Fatal(err) + } + if a == nil || b == nil || *a != *b || a.Sold != 100*lpod.Unit { + t.Fatal("validator sale ledgers disagree") + } + for _, e := range engines { + for i, k := range keys { + sig, err := k.Sign(crypto.HashBytes([]byte("aperod/finalize/v1"), block.Hash().Bytes())) + if err != nil { + t.Fatal(err) + } + if err = e.HandleVote(FinalizeMsg{Height: block.Header.Height, BlockHash: block.Hash(), + ValidatorPub: pubs[i], Signature: sig}); err != nil { + t.Fatal(err) + } + } + 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") +} + } + for _, d := range dbs { + if _, err := d.LoadSaleMigrationFile(""); err == nil { + t.Fatal("activated ledger silently accepted disabled sale configuration") + } + if loaded, err := d.LoadSaleMigrationFile(configPath); err != nil || loaded.Checkpoint.Message() != cp.Message() { + t.Fatal("durable restart configuration rejected", err) + } + } + 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") +} + engines[0].halted.Store(true) + if engines[0].CanSettleSale() { + t.Fatal("halted producer still admits invoices") + } + engines[0].halted.Store(false) + engines[0].cfg.StopProducingAtHeight = block.Header.Height + 1 + if engines[0].CanSettleSale() { + t.Fatal("paused producer still admits invoices") + } + engines[0].cfg.StopProducingAtHeight = 0 + found := wallet.NewScannerFromDerived(buyer).ScanBlock(block) + var delivered uint64 + for _, o := range found { + for _, tx := range block.Txs { + if tx.IsSale() && tx.Hash() == o.TxHash { + delivered += o.Amount + } + } + } + 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()) +} + 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()) + } + for i, d := range dbs { + if err := d.Close(); err != nil { + t.Fatal(err) + } + reopened, err := store.Open(directories[i]) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + persisted, err := reopened.LoadSaleState() + if err != nil || persisted == nil || *persisted != *a { + t.Fatalf("validator %d lost sale debit on reopening: %v", i, err) + } + if _, err := reopened.LoadSaleMigrationFile(configPath); err != nil { + t.Fatal("reopened ledger rejected its signed configuration", err) + } + } +} diff --git a/consensus/sale_readiness.go b/consensus/sale_readiness.go new file mode 100644 index 0000000..21652d4 --- /dev/null +++ b/consensus/sale_readiness.go @@ -0,0 +1,29 @@ +package consensus + +// CanSettleSale checks the actual current producer, not merely possession of a +// working signing key. Admission must close while halted, paused or ineligible. +func (e *Engine) CanSettleSale() bool { + if e.cfg.SaleMigration == nil || e.cfg.SaleTransactions == nil || + e.cfg.MyKey == nil || e.cfg.Store == nil || e.cfg.Registry == nil || e.halted.Load() { + return false + } + if !e.cfg.MyKey.Public().Equals(e.cfg.SaleMigration.Checkpoint.SaleAuthority) { + return false + } + hash, height, err := e.cfg.Store.GetTip() + if err != nil || height == ^uint64(0) || + (e.cfg.StopProducingAtHeight != 0 && height+1 >= e.cfg.StopProducingAtHeight) { + return false + } + state, err := e.cfg.Store.LoadSaleState() + if err != nil || state == nil || state.Hash != hash || state.Height != height || + state.Checkpoint != e.cfg.SaleMigration.Checkpoint.Message() { + return false + } + for _, member := range e.cfg.Registry.GetActiveValidators() { +if member.Equals(e.localIdentity(height+1)) { + return true + } + } + return false +} diff --git a/consensus/validator_authority.go b/consensus/validator_authority.go new file mode 100644 index 0000000..20b055f --- /dev/null +++ b/consensus/validator_authority.go @@ -0,0 +1,75 @@ +package consensus + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func (e *Engine) authority(identity crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { + return e.cfg.ValidatorAuthorityFork.Authority(identity, height) +} + +func (e *Engine) localIdentity(height uint64) crypto.ValidatorPubKey { + if e.cfg.MyKey == nil { + return nil + } + return e.cfg.ValidatorAuthorityFork.LocalIdentity(e.cfg.MyKey.Public(), height) +} + +func (e *Engine) validateAuthorityFork() error { + f := e.cfg.ValidatorAuthorityFork + if f == nil { + return nil + } + if err := f.Validate(); err != nil { + return err + } + if e.cfg.Store == nil || e.cfg.Registry == nil { + return fmt.Errorf("authority fork requires canonical storage and registry") + } + genesis, found, err := e.cfg.Store.GetCanonicalHash(0) + if err != nil || !found || genesis != f.Genesis { + return fmt.Errorf("authority fork canonical genesis mismatch") + } + entry, found := e.cfg.Registry.GetEntry(f.Identity) + if !found || entry.Seeded || !e.cfg.Registry.IsActive(f.Identity) { + return fmt.Errorf("authority fork identity lacks active authenticated stake") + } + if err := e.cfg.Registry.ConfigureSignatureAuthority(f, e.chain.Height()+1); err != nil { + return err + } + if e.chain.Height() >= f.Height-1 { + parent, found, err := e.cfg.Store.GetCanonicalHash(f.Height - 1) + if err != nil || !found || parent != f.Parent { + return fmt.Errorf("authority fork pinned canonical parent unavailable") + } + } + if e.chain.Height() == f.Height-1 { + raw, err := json.Marshal(e.cfg.Registry.TakeSnapshot()) + if err != nil || crypto.Hash32(sha256.Sum256(raw)) != f.RegistrySHA256 { + return fmt.Errorf("authority fork parent registry fingerprint mismatch") + } + } + return nil +} + +func (e *Engine) checkAuthorityParent(header core.BlockHeader) error { + f := e.cfg.ValidatorAuthorityFork + if f == nil { + return nil + } + if err := f.CheckParent(header); err != nil { + return err + } + if header.Height == f.Height { + raw, err := json.Marshal(e.cfg.Registry.TakeSnapshot()) + if err != nil || crypto.Hash32(sha256.Sum256(raw)) != f.RegistrySHA256 { + return fmt.Errorf("authority fork activation registry mismatch") + } + } + return nil +} diff --git a/consensus/validator_authority_test.go b/consensus/validator_authority_test.go new file mode 100644 index 0000000..4acb1f8 --- /dev/null +++ b/consensus/validator_authority_test.go @@ -0,0 +1,123 @@ +package consensus + +import ( + "crypto/sha256" + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func authorityEngineFixture(t *testing.T) (*Engine, crypto.ValidatorPrivKey, crypto.ValidatorPrivKey) { + t.Helper() + e, _, parent, old := lpodConsensusFixture(t) + next, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + key, err := crypto.NewLockedValidatorKey(next.Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(key.Destroy) + // Preserve a realistic sole-validator nominal stake, not an empty registry. + snap := e.cfg.Registry.TakeSnapshot() + snap.Validators[old.Public().Hex()].StakeNAPR = 1_000_000 * core.BaseUnitsPerAPR + e.cfg.Registry.RestoreFromSnapshot(snap) + raw, _ := json.Marshal(snap) + f := &core.ValidatorAuthorityFork{Version: 1, Height: 2, Parent: parent.Hash(), + Genesis: e.chain.Genesis().Hash(), Identity: old.Public(), Replacement: pub, + RegistrySHA256: crypto.Hash32(sha256.Sum256(raw))} + f.Possession, err = next.Sign(f.Message()) + if err != nil { + t.Fatal(err) + } + cfg := e.cfg + cfg.MyKey, cfg.ValidatorAuthorityFork = key, f + e = NewEngine(cfg, e.chain, e.pool, e.log) + if e.halted.Load() { + t.Fatal("fixture authority fork rejected") + } + return e, old, next +} + +func TestAuthorityForkLPoDBlockFinalityAndRestart(t *testing.T) { + e, old, next := authorityEngineFixture(t) + before, _ := json.Marshal(e.cfg.Registry.TakeSnapshot()) + if err := e.tick(); err != nil { + t.Fatal(err) + } + b := e.chain.Tip() + if b.Header.Height != 2 || !b.Header.ValidatorPub.Equals(old.Public()) { + t.Fatal("collateral identity changed or block not produced") + } + if b.Header.VerifySignature() || !b.Header.VerifySignature(next.Public()) { + t.Fatal("future block did not exclusively use replacement authority") + } + if len(b.Txs) != 2 || !b.Txs[1].IsLPoD() { + t.Fatal("real LPoD settlement missing") + } + if !e.IsFinalized(2) { + t.Fatal("replacement finality vote did not finalize block") + } + cert, err := e.cfg.Store.LoadFinalityCertificate() + if err != nil || cert == nil || len(cert.Votes) != 1 { + t.Fatal("replacement certificate not persisted") + } + if !cert.Votes[0].Validator.Equals(old.Public()) { + t.Fatal("certificate accounting identity changed") + } + after, _ := json.Marshal(e.cfg.Registry.TakeSnapshot()) + if string(before) != string(after) { + t.Fatal("stake/nonce/generation changed") + } + rebuilt := NewEngine(e.cfg, e.chain, e.pool, e.log) + if rebuilt.halted.Load() || !rebuilt.IsFinalized(2) { + t.Fatal("cannot restore replacement-signed finality") + } + entry, ok := rebuilt.cfg.Registry.GetEntry(old.Public()) + if !ok || entry.StakeNAPR != 1_000_000*core.BaseUnitsPerAPR { + t.Fatal("stake not preserved on restart") + } +} + +func TestAuthorityForkRejectsOldBlockAndVote(t *testing.T) { + e, old, _ := authorityEngineFixture(t) + b := &core.Block{Header: core.BlockHeader{Height: 2, PrevHash: e.chain.Tip().Hash(), + Timestamp: 2, ValidatorPub: old.Public(), MerkleRoot: core.MerkleRoot(nil)}} + if err := b.Header.Sign(old); err != nil { + t.Fatal(err) + } + if err := e.handleIncomingBlock(b); err == nil { + t.Fatal("old key produced activation block") + } + hash := b.Hash() + msg := crypto.HashBytes([]byte("aperod/finalize/v1"), hash[:]) + signature, err := old.Sign(msg) + if err != nil { + t.Fatal(err) + } + if err := e.handleVote(FinalizeMsg{Height: 2, BlockHash: b.Hash(), + ValidatorPub: old.Public(), Signature: signature}); err == nil { + t.Fatal("old key authorized a future finality vote") + } +} + +func TestAuthorityForkPinnedParentAndRegistryFailClosed(t *testing.T) { + e, _, next := authorityEngineFixture(t) + f := e.cfg.ValidatorAuthorityFork.Clone() + f.Parent[0] ^= 1 + f.Possession, _ = next.Sign(f.Message()) + cfg := e.cfg + cfg.ValidatorAuthorityFork = f + if !NewEngine(cfg, e.chain, e.pool, e.log).halted.Load() { + t.Fatal("accepted a different independently pinned parent") + } + snap := e.cfg.Registry.TakeSnapshot() + snap.Validators[f.Identity.Hex()].StakeNAPR-- + e.cfg.Registry.RestoreFromSnapshot(snap) + if err := e.tick(); err == nil { + t.Fatal("accepted replica registry divergence") + } +} diff --git a/core/block.go b/core/block.go index aa276ed..fad4df4 100644 --- a/core/block.go +++ b/core/block.go @@ -75,13 +75,17 @@ func (h *BlockHeader) Sign(priv crypto.ValidatorPrivKey) error { } // VerifySignature checks that the header's Signature is valid for ValidatorPub. -func (h *BlockHeader) VerifySignature() bool { +func (h *BlockHeader) VerifySignature(authority ...crypto.ValidatorPubKey) bool { // Temporarily clear signature to recompute hash sig := h.Signature h.Signature = nil hash := h.Hash() h.Signature = sig - return h.ValidatorPub.Verify(hash, sig) +pub := h.ValidatorPub +if len(authority) == 1 { +pub = authority[0] +} +return pub.Verify(hash, sig) } // Hash returns the hash of the full block (= header hash, since txs are in MerkleRoot). @@ -101,14 +105,14 @@ func (b *Block) IsGenesis() bool { // Validate performs structural validation of the block. // It does NOT check consensus rules (done in consensus.Engine). -func (b *Block) Validate() error { +func (b *Block) Validate(authority ...crypto.ValidatorPubKey) error { if b.Header.Timestamp <= 0 { return fmt.Errorf("block %d: invalid timestamp", b.Header.Height) } if len(b.Header.ValidatorPub) == 0 { return fmt.Errorf("block %d: missing validator public key", b.Header.Height) } - if !b.Header.VerifySignature() { +if !b.Header.VerifySignature(authority...) { return fmt.Errorf("block %d: invalid validator signature", b.Header.Height) } expectedRoot := MerkleRoot(b.Txs) diff --git a/core/decoy_sampling_test.go b/core/decoy_sampling_test.go index 97787fb..eefcda1 100644 --- a/core/decoy_sampling_test.go +++ b/core/decoy_sampling_test.go @@ -145,12 +145,23 @@ type samplingStore struct { result []DecoyUTXO err error calls int + exclude map[crypto.Point32]bool } func (*samplingStore) LookupRingMember(crypto.Point32) (*UTXO, error) { return nil, nil } -func (s *samplingStore) SampleRingMembers(_ int, _ map[crypto.Point32]bool) ([]DecoyUTXO, error) { +func (s *samplingStore) SampleRingMembers(_ int, exclude map[crypto.Point32]bool) ([]DecoyUTXO, error) { s.calls++ - return s.result, s.err + s.exclude = exclude + if s.err != nil { + return nil, s.err + } + out := make([]DecoyUTXO, 0, len(s.result)) + for _, candidate := range s.result { + if !exclude[candidate.OneTimePub] { + out = append(out, candidate) + } + } + return out, nil } func TestDecoySamplingPersistentStoreNoMemoryFallback(t *testing.T) { @@ -171,6 +182,30 @@ func TestDecoySamplingPersistentStoreNoMemoryFallback(t *testing.T) { } } +func TestDecoySamplingExcludesStakedOutputsBeforeSampling(t *testing.T) { + set := NewUTXOSet() + staked := samplingFixture(8) + eligible := samplingFixture(9) + set.Add(staked) + if err := set.MarkStaked(staked.TxHash, staked.OutputIndex); err != nil { + t.Fatalf("MarkStaked: %v", err) + } + + backend := &samplingStore{result: []DecoyUTXO{ + {OneTimePub: staked.OneTimePub, AmountCommit: staked.AmountCommit}, + {OneTimePub: eligible.OneTimePub, AmountCommit: eligible.AmountCommit}, + }} + set.ringMembers = backend + + got := set.SampleCLSAGDecoys(1, nil) + if len(got) != 1 || got[0].OneTimePub != eligible.OneTimePub { + t.Fatalf("sampled ring members = %v, want the one eligible decoy", got) + } + if !backend.exclude[staked.OneTimePub] { + t.Fatal("sampler backend did not receive the staked-output exclusion") + } +} + func TestDecoySamplingBuildersAbortOnEntropyError(t *testing.T) { keys, err := crypto.GenerateWalletKeys() if err != nil { diff --git a/core/mempool.go b/core/mempool.go index baa7cd0..9b56afe 100644 --- a/core/mempool.go +++ b/core/mempool.go @@ -198,11 +198,8 @@ func (m *Mempool) Add(tx Transaction) error { return fmt.Errorf("mempool: coinbase (zero-input) transactions are not accepted from external sources") } - if m.cfg.CurrentHeight != nil { - nextHeight := m.cfg.CurrentHeight() - if nextHeight < ^uint64(0) { - nextHeight++ - } +nextHeight := m.nextBlockHeight() +if m.cfg.CurrentHeight != nil { if err := ValidateTxVersionAtHeight( &tx, nextHeight, @@ -221,7 +218,7 @@ func (m *Mempool) Add(tx Transaction) error { // C-0/C-1: prevents supply inflation via forged AmountCommit or unbound stake amount. // Stake txs are included — they carry ring inputs whose proofs must be valid. if m.cfg.Verifier != nil { - if err := m.cfg.Verifier.VerifyTx(&tx); err != nil { +if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { return fmt.Errorf("mempool: crypto verification failed: %w", err) } } @@ -393,6 +390,17 @@ func (m *Mempool) Add(tx Transaction) error { return nil } +func (m *Mempool) nextBlockHeight() uint64 { + if m.cfg.CurrentHeight == nil { + return 0 + } + height := m.cfg.CurrentHeight() + if height < ^uint64(0) { + height++ + } + return height +} + // Remove removes a transaction from the mempool (called after block inclusion). func (m *Mempool) Remove(hash crypto.Hash32) { m.mu.Lock() @@ -506,13 +514,14 @@ func (m *Mempool) AddPrivileged(tx Transaction) error { if err := m.validateAVMNonce(&tx); err != nil { return err } +nextHeight := m.nextBlockHeight() // C1 fix: run full cryptographic verification even for privileged transactions, // EXCEPT for coinbase (zero-input) transactions. VerifyTx explicitly rejects // coinbase txs to prevent external inflation attacks; engine-synthesized mints // are trusted by construction and must not be re-verified through that path. // Any non-coinbase tx routed through AddPrivileged is still fully verified. if m.cfg.Verifier != nil && !tx.IsCoinbase() { - if err := m.cfg.Verifier.VerifyTx(&tx); err != nil { +if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { return fmt.Errorf("mempool: crypto verification failed (privileged): %w", err) } } diff --git a/core/ringct_v5_test.go b/core/ringct_v5_test.go index 10cb39d..36ce74a 100644 --- a/core/ringct_v5_test.go +++ b/core/ringct_v5_test.go @@ -2,10 +2,13 @@ package core_test import ( "encoding/json" + "path/filepath" + "strings" "testing" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func TestRingCTV5BuilderVerifierAndState(t *testing.T) { @@ -112,6 +115,208 @@ func TestRingCTV5ActivationBoundary(t *testing.T) { } } +func TestRingCTV5RejectsStakedCollateralAcrossRestartAndRollback(t *testing.T) { + alice, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + bob, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + const supply = uint64(10_000_000) + chain, _, blind := makeGenesisWithCoinbase(t, alice, supply) + scanner := core.NewWalletScanner(alice.View.Private, alice.Spend.Public, alice.View.Public, crypto.MainnetByte) + owned := scanner.ScanChain(chain, 0, chain.Height()) + if len(owned) != 1 { + t.Fatalf("genesis owned outputs=%d, want 1", len(owned)) + } + owned[0].Blind = blind + + dbPath := filepath.Join(t.TempDir(), "db") + db, err := store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + set := core.NewUTXOSetWithDB(db) + set.SetCLSAGActivationHeight(1) + set.Add(&owned[0].UTXO) +const stakedCollateralActivationHeight = uint64(2_880_000) +verifier := core.NewTxVerifier(set) +verifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) + + decoys := make([]core.DecoyUTXO, crypto.RingSize-1) + decoyUTXOs := make([]*core.UTXO, crypto.RingSize-1) + for i := range decoys { + keys, keyErr := crypto.GenerateWalletKeys() + if keyErr != nil { + t.Fatal(keyErr) + } + decoyBlind, blindErr := crypto.NewBlindFactor() + if blindErr != nil { + t.Fatal(blindErr) + } + commit, commitErr := crypto.Commit(uint64(i+1), decoyBlind) + if commitErr != nil { + t.Fatal(commitErr) + } + utxo := &core.UTXO{ + TxHash: crypto.HashBytes([]byte{byte(i + 1)}), + OutputIndex: 0, + OneTimePub: keys.Spend.Public, + AmountCommit: commit, + BlockHeight: 1, + } + set.Add(utxo) + decoys[i] = core.DecoyUTXO{OneTimePub: utxo.OneTimePub, AmountCommit: commit} + decoyUTXOs[i] = utxo + if err := db.PutUTXO(utxo.TxHash, utxo.OutputIndex, &store.StoredUTXO{ + TxHash: utxo.TxHash, OutputIndex: utxo.OutputIndex, OneTimePub: utxo.OneTimePub, + AmountCommit: utxo.AmountCommit, BlockHeight: utxo.BlockHeight, + }); err != nil { + t.Fatal(err) + } + } + if err := db.PutUTXO(owned[0].TxHash, owned[0].OutputIndex, &store.StoredUTXO{ + TxHash: owned[0].TxHash, OutputIndex: owned[0].OutputIndex, + OneTimePub: owned[0].OneTimePub, AmountCommit: owned[0].AmountCommit, + BlockHeight: owned[0].BlockHeight, + }); err != nil { + t.Fatal(err) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + + // Preserve the existing spent-decoy semantics: a normally spent output is + // still resolvable and remains eligible as a CLSAG ring member. + set.Remove(decoyUTXOs[0].TxHash, decoyUTXOs[0].OutputIndex) + set.AddSpentDecoyForTest(decoyUTXOs[0]) + + aliceAddr := crypto.AddressFromKeys(crypto.MainnetByte, alice) + bobAddr := crypto.AddressFromKeys(crypto.MainnetByte, bob) + result, err := core.NewTxBuilder(alice.Spend.Private, alice.View.Private, alice.Spend.Public, owned, 1). + WithVersion(core.TxVersionCLSAG).WithDecoys(decoys). + Build(1_000_000, bobAddr, aliceAddr) + if err != nil { + t.Fatalf("build v5: %v", err) + } +if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { + t.Fatalf("historically valid transaction rejected before stake transition: %v", err) + } +if err := verifier.VerifyTx(&result.Tx); err == nil || !strings.Contains(err.Error(), "height-aware") { + t.Fatalf("heightless verification did not fail closed: %v", err) +} +if err := verifier.VerifyTxAtHeight(&result.Tx, 0); err == nil || !strings.Contains(err.Error(), "nonzero candidate height") { + t.Fatalf("zero-height verification did not fail closed: %v", err) +} + + var validatorSig, ownershipSig [64]byte + pub := crypto.ValidatorPubKey(alice.Spend.Public[:]) + stakeExtra, err := core.EncodeStakeExtraV3(core.StakeDeposit, pub, supply, validatorSig[:], + owned[0].TxHash, owned[0].OutputIndex, blind, ownershipSig[:]) + if err != nil { + t.Fatal(err) + } + stakeTx := core.Transaction{Version: core.TxVersionStake, Extra: stakeExtra} +if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight - 1}, + Txs: []core.Transaction{result.Tx, stakeTx}, +}); err != nil { + t.Fatalf("pre-activation block rejected under the new rule: %v", err) +} +if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, + Txs: []core.Transaction{result.Tx, stakeTx}, +}); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("same-block spend at activation was not rejected: %v", err) + } + + // Runtime stake transition must prevent the authentic output from being + // selected as either the hidden real member or a decoy. + if err := set.MarkStaked(owned[0].TxHash, owned[0].OutputIndex); err != nil { + t.Fatalf("MarkStaked: %v", err) + } +if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { + t.Fatalf("pre-activation mempool transaction rejected: %v", err) +} +if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("CLSAG transaction using staked collateral was not rejected at activation: %v", err) + } +preActivationPoolConfig := core.DefaultMempoolConfig() +preActivationPoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 2 } +preActivationPoolConfig.RingCTCLSAGActivationHeight = 1 +preActivationPoolConfig.BaseFeePerByte = 1 +preActivationPoolConfig.Verifier = verifier +if err := core.NewMempool(preActivationPoolConfig).Add(result.Tx); err != nil { +t.Fatalf("mempool rejected transaction before activation: %v", err) +} +activePoolConfig := preActivationPoolConfig +activePoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 1 } +if err := core.NewMempool(activePoolConfig).Add(result.Tx); err == nil || !strings.Contains(err.Error(), "staked collateral") { +t.Fatalf("mempool did not enforce activation-height rule: %v", err) +} +if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, + Txs: []core.Transaction{result.Tx}, + }); err == nil { + t.Fatal("canonical block validation accepted a CLSAG transaction using staked collateral") + } + sampledBeforeRestart := set.SampleCLSAGDecoys(crypto.RingSize-1, nil) + if len(sampledBeforeRestart) != crypto.RingSize-1 { + t.Fatalf("staked output reduced available CLSAG decoys: got %d, want %d", + len(sampledBeforeRestart), crypto.RingSize-1) + } + for _, sampled := range sampledBeforeRestart { + if sampled.OneTimePub == owned[0].OneTimePub { + t.Fatal("staked collateral remained eligible for CLSAG decoy sampling") + } + } + + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Reconstruct the normal restart state from the persistent ring index and + // replayed canonical stake event. The retained rm/ record remains available + // for historical compatibility; IsStaked is the consensus exclusion. + restarted := core.NewUTXOSetWithDB(db) + restarted.SetCLSAGActivationHeight(1) + restarted.MarkStakedKnown(owned[0].TxHash, owned[0].OutputIndex, &core.UTXO{ + TxHash: owned[0].TxHash, OutputIndex: owned[0].OutputIndex, + AmountCommit: owned[0].UTXO.AmountCommit, + }) +restartedVerifier := core.NewTxVerifier(restarted) +restartedVerifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) + if got := restarted.GetRingMember(owned[0].OneTimePub); got == nil { + t.Fatal("staked output's canonical ring record was destructively removed") + } +if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("CLSAG transaction using staked collateral was not rejected after restart replay: %v", err) + } + sampledAfterRestart := restarted.SampleCLSAGDecoys(crypto.RingSize-1, nil) + if len(sampledAfterRestart) != crypto.RingSize-1 { + t.Fatalf("replayed staked output reduced available CLSAG decoys: got %d, want %d", + len(sampledAfterRestart), crypto.RingSize-1) + } + for _, sampled := range sampledAfterRestart { + if sampled.OneTimePub == owned[0].OneTimePub { + t.Fatal("replayed staked collateral remained eligible for CLSAG decoy sampling") + } + } + + // Rolling back the stake transition restores the pre-stake canonical state. + restarted.UnmarkStaked(owned[0].TxHash, owned[0].OutputIndex) +if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err != nil { + t.Fatalf("transaction rejected after stake rollback: %v", err) + } +} + func TestRingCTV5BuilderSupportsIntentionalBurn(t *testing.T) { alice, err := crypto.GenerateWalletKeys() if err != nil { diff --git a/core/sale.go b/core/sale.go new file mode 100644 index 0000000..455dc98 --- /dev/null +++ b/core/sale.go @@ -0,0 +1,123 @@ +package core + +import ( + "bytes" + "encoding/binary" + "fmt" + + "github.com/aperod/aperod/crypto" +) + +const TxVersionSale TxVersion = 11 +const saleExtraSize = 280 + +var salePrefix = []byte{'A', 'P', 'S', 'A', 'L', 'E', 1, 0} + +// Recipient addresses are deliberately absent from the public authorization. +// The trusted issuer signs the exact confidential output, and the buyer must +// discover that output locally before an off-chain order can be completed. +type SaleAuthorization struct { + Genesis crypto.Hash32 + Height uint64 + Parent crypto.Hash32 + Order crypto.Hash32 + Payment crypto.Hash32 + Amount uint64 + Opening crypto.ValueAuthorization + Signature [64]byte +} + +func (tx *Transaction) IsSale() bool { return tx != nil && tx.Version == TxVersionSale } + +func (a SaleAuthorization) extra() []byte { + out := make([]byte, saleExtraSize) + copy(out[:8], salePrefix) + copy(out[8:40], a.Genesis[:]) + binary.BigEndian.PutUint64(out[40:48], a.Height) + copy(out[48:80], a.Parent[:]) + copy(out[80:112], a.Order[:]) + copy(out[112:144], a.Payment[:]) + binary.BigEndian.PutUint64(out[144:152], a.Amount) + copy(out[152:216], a.Opening[:]) + copy(out[216:], a.Signature[:]) + return out +} + +func (tx *Transaction) SaleAuthorization() (*SaleAuthorization, error) { + if !tx.IsSale() || len(tx.Extra) != saleExtraSize || !bytes.Equal(tx.Extra[:8], salePrefix) || + len(tx.Inputs) != 0 || len(tx.Outputs) != 1 || tx.Fee != 0 || + tx.FeeCommit != (crypto.Commitment{}) || tx.AVM != nil || + len(tx.RangeProofs) != 0 || len(tx.Signatures) != 0 || len(tx.CLSAGSignatures) != 0 { + return nil, fmt.Errorf("sale: noncanonical transaction structure") + } + var a SaleAuthorization + copy(a.Genesis[:], tx.Extra[8:40]) + a.Height = binary.BigEndian.Uint64(tx.Extra[40:48]) + copy(a.Parent[:], tx.Extra[48:80]) + copy(a.Order[:], tx.Extra[80:112]) + copy(a.Payment[:], tx.Extra[112:144]) + a.Amount = binary.BigEndian.Uint64(tx.Extra[144:152]) + copy(a.Opening[:], tx.Extra[152:216]) + copy(a.Signature[:], tx.Extra[216:]) + if a.Height == 0 || a.Amount == 0 || a.Genesis == (crypto.Hash32{}) || + a.Parent == (crypto.Hash32{}) || a.Order == (crypto.Hash32{}) || a.Payment == (crypto.Hash32{}) { + return nil, fmt.Errorf("sale: empty authorization field") + } + if err := crypto.ValidateAuthorizedOutputPoints(tx.Outputs[0].OneTimePub, tx.Outputs[0].TxPubKey); err != nil { + return nil, err + } + if err := crypto.VerifyValueAuthorization(a.Amount, tx.Outputs[0].AmountCommit, a.Opening); err != nil { + return nil, err + } + return &a, nil +} + +func (tx *Transaction) SaleSigningMessage() crypto.Hash32 { + unsigned := *tx + unsigned.Extra = append([]byte(nil), tx.Extra...) + if len(unsigned.Extra) == saleExtraSize { + clear(unsigned.Extra[216:]) + } + hash := unsigned.Hash() + return crypto.HashBytes([]byte("aperod/sale/confidential-output-authorization/v1"), hash[:]) +} + +func BuildSaleTransaction(a SaleAuthorization, recipient crypto.Address, + network crypto.NetworkByte, key interface { + Sign(crypto.Hash32) ([]byte, error) + }) (*Transaction, error) { + if key == nil { + return nil, fmt.Errorf("sale: authority signer unavailable") + } + actual, _, _, err := crypto.DecodeAddress(recipient) + if err != nil || actual != network { + return nil, fmt.Errorf("sale: recipient network mismatch") + } + output, blind, _, err := txBuildOutputWithEphemeral(recipient, a.Amount, nil) + if err != nil { + return nil, err + } + commit, proof, err := crypto.ProveValueAuthorization(a.Amount, blind) + if err != nil { + return nil, err + } + if commit != output.AmountCommit { + return nil, fmt.Errorf("sale: output opening mismatch") + } + a.Opening = proof + a.Signature = [64]byte{} + tx := &Transaction{Version: TxVersionSale, Outputs: []Output{output}, Extra: a.extra()} + signature, err := key.Sign(tx.SaleSigningMessage()) + if err != nil { + return nil, err + } + if len(signature) != 64 { + return nil, fmt.Errorf("sale: invalid authority signature") + } + copy(a.Signature[:], signature) + tx.Extra = a.extra() + if _, err = tx.SaleAuthorization(); err != nil { + return nil, err + } + return tx, nil +} diff --git a/core/security_completion_test.go b/core/security_completion_test.go new file mode 100644 index 0000000..44f6661 --- /dev/null +++ b/core/security_completion_test.go @@ -0,0 +1,137 @@ +package core_test + +import ( + "strings" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// The valid control is essential: an unfunded ring can otherwise make every +// tampering case pass at the fabricated-input guard, without testing commitments. +func TestSecurityReview_FundedCommitmentTampering(t *testing.T) { + owner, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + recipient, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + const inputAmount, outputAmount, fee = uint64(10_000_000), uint64(9_000_000), uint64(1_000_000) + commit := func(amount uint64) crypto.Commitment { + c, e := crypto.Commit(amount, crypto.BlindFactor{}) + if e != nil { + t.Fatal(e) + } + return c + } + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = owner.Spend.Public + for i := 1; i < len(ring); i++ { + decoy, e := crypto.GenerateWalletKeys() + if e != nil { + t.Fatal(e) + } + ring[i] = decoy.Spend.Public + } + ki, err := crypto.ComputeKeyImage(owner.Spend.Private, owner.Spend.Public) + if err != nil { + t.Fatal(err) + } + proof, err := crypto.ProveRange(outputAmount, crypto.BlindFactor{}) + if err != nil { + t.Fatal(err) + } + tx := core.Transaction{ + Version: core.TxVersionBase, + Inputs: []core.RingInput{{KeyImage: ki, Ring: ring, AmountCommit: commit(inputAmount)}}, + Outputs: []core.Output{{OneTimePub: recipient.Spend.Public, AmountCommit: commit(outputAmount)}}, + Fee: fee, FeeCommit: commit(fee), RangeProofs: []*crypto.RangeProof{proof}, + } + sign := func(v *core.Transaction) { + sig, e := crypto.MLSAGSign(core.RingSignMessage(v.Hash(), 0), ring, 0, owner.Spend.Private) + if e != nil { + t.Fatal(e) + } + v.Signatures = []*crypto.MLSAGSignature{sig} + } + utxos := core.NewUTXOSet() + utxos.Add(&core.UTXO{TxHash: crypto.HashStr("security-review-funded-control"), + OneTimePub: owner.Spend.Public, AmountCommit: commit(inputAmount)}) + verifier := core.NewTxVerifier(utxos) + sign(&tx) + if err := verifier.VerifyTx(&tx); err != nil { + t.Fatalf("valid funded control must pass: %v", err) + } + for _, kind := range []string{"output", "input", "fee"} { + t.Run(kind, func(t *testing.T) { + bad := tx + bad.Inputs = append([]core.RingInput(nil), tx.Inputs...) + bad.Outputs = append([]core.Output(nil), tx.Outputs...) + switch kind { + case "output": + bad.Outputs[0].AmountCommit = commit(outputAmount + 1) + case "input": + bad.Inputs[0].AmountCommit = commit(inputAmount + 1) + case "fee": + bad.FeeCommit = commit(fee + 1) + } + // Re-sign the modified message so an invalid signature cannot + // accidentally substitute for the commitment rejection. + sign(&bad) + err := verifier.VerifyTx(&bad) + if err == nil || (!strings.Contains(err.Error(), "commitment") && + !strings.Contains(err.Error(), "balance") && !strings.Contains(err.Error(), "range proof")) { + t.Fatalf("expected commitment-specific rejection, got %v", err) + } + }) + } +} + +func TestSecurityReview_KeyImageAcrossLevelDBReopen(t *testing.T) { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + ki, err := crypto.ComputeKeyImage(keys.Spend.Private, keys.Spend.Public) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + first := core.NewUTXOSetWithDB(db) + first.MarkSpent(ki) + first.CompactKeyImages() + if !first.IsSpent(ki) { + t.Fatal("spent image missing before close") + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer db.Close() + restarted := core.NewUTXOSetWithDB(db) + if !restarted.IsSpent(ki) { + t.Fatal("spent image lost across actual LevelDB close/reopen") + } + fresh, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + unspent, err := crypto.ComputeKeyImage(fresh.Spend.Private, fresh.Spend.Public) + if err != nil { + t.Fatal(err) + } + if restarted.IsSpent(unspent) { + t.Fatal("fresh unspent image falsely marked spent") + } +} diff --git a/core/staking.go b/core/staking.go index af2383c..72f0f02 100644 --- a/core/staking.go +++ b/core/staking.go @@ -17,6 +17,7 @@ import ( "math" "sort" "sync" +"sync/atomic" "github.com/aperod/aperod/crypto" ) @@ -470,6 +471,8 @@ type ValidatorRegistry struct { utxos *UTXOSet // required for C-1 UTXO-backed deposit check stakeWithdrawalGenesis crypto.Hash32 stakeWithdrawalActivation uint64 +signatureFork atomic.Pointer[ValidatorAuthorityFork] +signatureAdmissionHeight atomic.Uint64 } // ConfigureStakeWithdrawalV2 binds the registry to the quorum-attested LPoD @@ -620,7 +623,7 @@ func (r *ValidatorRegistry) validateWithdrawalV2Locked(a StakeWithdrawalAuthoriz if a.StakeRef != wantRef { return nil, fmt.Errorf("stake withdrawal v2: current stake reference mismatch") } - if !a.PubKey.Verify(StakeWithdrawalSignMsgV2(a), a.Signature) { +if !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return nil, fmt.Errorf("stake withdrawal v2: invalid validator signature") } return entry, nil @@ -682,7 +685,7 @@ func (r *ValidatorRegistry) ProcessStakeTx(tx Transaction, height uint64) error return fmt.Errorf("registry: legacy stake withdrawal disabled after attested v2 activation") } msg := StakeSignMsg(action, pub, amount) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature from %s", pub.ID()) } r.mu.Lock() @@ -719,7 +722,7 @@ func (r *ValidatorRegistry) ProcessStakeTx(tx Transaction, height uint64) error // Verify self-signature — binds the deposit to a specific burn UTXO, // preventing replay attacks with a different UTXO at the same amount. msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v2) from %s", pub.ID()) } @@ -775,7 +778,7 @@ func (r *ValidatorRegistry) ProcessStakeTx(tx Transaction, height uint64) error // Verify validator self-signature (same signing msg as v2). msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v3) from %s", pub.ID()) } @@ -866,7 +869,7 @@ func (r *ValidatorRegistry) applyOneTxLocked(tx Transaction, height uint64) (*UT return nil, fmt.Errorf("legacy stake withdrawal disabled after attested v2 activation") } msg := StakeSignMsg(action, pub, amount) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid stake signature from %s", pub.ID()) } key := pub.Hex() @@ -895,7 +898,7 @@ func (r *ValidatorRegistry) applyOneTxLocked(tx Transaction, height uint64) (*UT return nil, fmt.Errorf("v2 payload only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid v2 stake signature from %s", pub.ID()) } if r.utxos == nil { @@ -942,7 +945,7 @@ func (r *ValidatorRegistry) applyOneTxLocked(tx Transaction, height uint64) (*UT return nil, fmt.Errorf("v3 payload only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid v3 stake signature from %s", pub.ID()) } if r.utxos == nil { @@ -1126,7 +1129,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint return fmt.Errorf("stake tx[%d]: legacy withdrawal disabled after attested v2 activation", i) } msg := StakeSignMsg(action, pub, amount) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid signature from %s", i, pub.ID()) } key := pub.Hex() @@ -1172,7 +1175,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint } if withdrawalActivation == 0 || height < withdrawalActivation || a.Genesis != withdrawalGenesis || a.ExpiryHeight < height || a.ExpiryHeight-height > StakeWithdrawalMaxExpiryBlocks || - !a.PubKey.Verify(StakeWithdrawalSignMsgV2(a), a.Signature) { +!r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return fmt.Errorf("stake tx[%d]: invalid, expired, cross-chain, or pre-activation v2 withdrawal", i) } key := a.PubKey.Hex() @@ -1210,7 +1213,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint return fmt.Errorf("stake tx[%d]: v2 payload only valid for StakeDeposit, got action=%d", i, action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid v2 signature from %s", i, pub.ID()) } // Within-block duplicate burn UTXO. @@ -1285,7 +1288,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint return fmt.Errorf("stake tx[%d]: v3 payload only valid for StakeDeposit, got action=%d", i, action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid v3 signature from %s", i, pub.ID()) } // Within-block duplicate burn UTXO. @@ -1371,6 +1374,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint // - Pedersen commitment match: Commit(amount, blind) == UTXO.AmountCommit (C-1) // - For v3: one-time-key ownership proof verified (F-049 fix) func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { +height := r.signatureAdmissionHeight.Load() if tx.Version != TxVersionStake { return fmt.Errorf("registry: not a stake tx (version=%d)", tx.Version) } @@ -1385,7 +1389,7 @@ func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { return fmt.Errorf("registry: StakeDeposit requires v2 payload (173 bytes) with UTXO burn proof — v1 deposits no longer accepted (C-1 fix)") } msg := StakeSignMsg(action, pub, amount) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature from %s", pub.ID()) } return nil @@ -1399,7 +1403,7 @@ func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { genesis := r.stakeWithdrawalGenesis enabled := r.stakeWithdrawalActivation != 0 r.mu.RUnlock() - if !enabled || a.Genesis != genesis || !a.PubKey.Verify(StakeWithdrawalSignMsgV2(a), a.Signature) { +if !enabled || a.Genesis != genesis || !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return fmt.Errorf("registry: invalid or unconfigured stake withdrawal v2 authorization") } return nil @@ -1413,7 +1417,7 @@ func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { return fmt.Errorf("registry: v2 payload (173 bytes) is only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v2) from %s", pub.ID()) } if r.utxos == nil { @@ -1450,7 +1454,7 @@ func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { return fmt.Errorf("registry: v3 payload (237 bytes) is only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v3) from %s", pub.ID()) } if r.utxos == nil { @@ -1540,7 +1544,7 @@ func (r *ValidatorRegistry) replayOneTxLocked(tx Transaction, height uint64) err return fmt.Errorf("replay: legacy withdrawal after attested v2 activation at height %d", height) } msg := StakeSignMsg(action, pub, amount) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid stake sig from %s at height %d", pub.ID(), height) } key := pub.Hex() @@ -1576,7 +1580,7 @@ func (r *ValidatorRegistry) replayOneTxLocked(tx Transaction, height uint64) err return fmt.Errorf("replay: v2 payload with non-deposit action=%d at height %d", action, height) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid v2 stake sig from %s at height %d", pub.ID(), height) } // Reconstruct the burn-UTXO descriptor from payload data so MarkStakedKnown @@ -1606,7 +1610,7 @@ func (r *ValidatorRegistry) replayOneTxLocked(tx Transaction, height uint64) err return fmt.Errorf("replay: v3 payload with non-deposit action=%d at height %d", action, height) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) - if !pub.Verify(msg, sig) { +if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid v3 stake sig from %s at height %d", pub.ID(), height) } expectedCommit, _ := crypto.Commit(amount, burnBlind) diff --git a/core/transaction.go b/core/transaction.go index bce8eb0..9ee6add 100644 --- a/core/transaction.go +++ b/core/transaction.go @@ -179,7 +179,7 @@ func (tx *Transaction) Hash() crypto.Hash32 { // replay, except for the explicitly distinct Guardian protocol allocation. // Coinbase transactions skip ring signature and range proof requirements. func (tx *Transaction) IsCoinbase() bool { - return tx != nil && len(tx.Inputs) == 0 && !tx.IsGuardianFund() && !tx.IsLPoD() && !tx.IsLPoDPosition() && !tx.IsLPoDPayout() + return tx != nil && len(tx.Inputs) == 0 && !tx.IsGuardianFund() && !tx.IsLPoD() && !tx.IsLPoDPosition() && !tx.IsLPoDPayout() && !tx.IsSale() } // IsStake returns true if this is a validator stake deposit or withdrawal. @@ -207,7 +207,7 @@ func (tx *Transaction) Validate() error { return fmt.Errorf("tx version 0 is invalid") } switch tx.Version { - case TxVersionBase, TxVersionGameAsset, TxVersionStake, TxVersionCommitmentBinding, TxVersionCLSAG, TxVersionAVM, TxVersionGuardianFund, TxVersionLPoD, TxVersionLPoDPosition, TxVersionLPoDPayout: + case TxVersionBase, TxVersionGameAsset, TxVersionStake, TxVersionCommitmentBinding, TxVersionCLSAG, TxVersionAVM, TxVersionGuardianFund, TxVersionLPoD, TxVersionLPoDPosition, TxVersionLPoDPayout, TxVersionSale: default: return fmt.Errorf("unsupported tx version %d", tx.Version) } @@ -215,6 +215,10 @@ func (tx *Transaction) Validate() error { if tx.IsLPoD() { return tx.validateLPoD() } + if tx.IsSale() { + _, err := tx.SaleAuthorization() + return err + } if tx.IsLPoDPosition() { _, err := tx.LPoDPositionAction() return err diff --git a/core/tx_builder.go b/core/tx_builder.go index 0474874..4006b65 100644 --- a/core/tx_builder.go +++ b/core/tx_builder.go @@ -866,13 +866,13 @@ func (b *TxBuilder) MaxSpendable() MaxSpendableResult { prefixSums[k] = prefix } for k := 1; k <= limit; k++ { - fee := txEstimateFee(k, 2, b.feePerByte) +fee := txEstimateFeeVersion(b.txVersion, k, 2, b.feePerByte) if prefixSums[k] <= fee { continue } amount := prefixSums[k] - fee - selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte) - finalFee := txEstimateFee(selCount, 2, b.feePerByte) +selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) +finalFee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) if selTotal >= amount+finalFee && amount > res.MaxAmount { res.MaxAmount = amount res.Fee = finalFee @@ -931,15 +931,15 @@ func (b *TxBuilder) EstimateFeeForAmount(amount uint64) FeeEstimateResult { res.SpendableTotal += u.Amount } - selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte) +selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) if selCount < 1 { selCount = 1 // a tx always has ≥1 input } - fee := txEstimateFee(selCount, 2, b.feePerByte) +fee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) res.Fee = fee res.InputCount = selCount - res.TxSizeBytes = txOverheadBytes + selCount*txBytesPerInput + 2*txBytesPerOutput - res.Sufficient = selTotal >= amount+fee +res.TxSizeBytes = txEstimatedSize(b.txVersion, selCount, 2) +res.Sufficient = amount <= ^uint64(0)-fee && selTotal >= amount+fee return res } @@ -947,9 +947,14 @@ func (b *TxBuilder) EstimateFeeForAmount(amount uint64) FeeEstimateResult { // for a given payment amount and returns the number of inputs it would pick // and their total. available must already be sorted largest-first and // deduplicated by OneTimePub. -func simulateGreedySelection(available []OwnedUTXO, amount, feePerByte uint64) (int, uint64) { +func simulateGreedySelection(available []OwnedUTXO, amount, feePerByte uint64, version TxVersion) (int, uint64) { const maxInputs = 8 - needed := amount + txEstimateFee(1, 2, feePerByte) +fee := txEstimateFeeVersion(version, 1, 2, feePerByte) +// Build rejects an amount that overflows once the initial fee is added. +if amount > ^uint64(0)-fee { +return 0, 0 +} +needed := amount + fee var ( count int totalIn uint64 diff --git a/core/tx_builder_version_quote_test.go b/core/tx_builder_version_quote_test.go new file mode 100644 index 0000000..0603ff2 --- /dev/null +++ b/core/tx_builder_version_quote_test.go @@ -0,0 +1,77 @@ +package core_test + +import ( + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// The wallet's fee subtraction must use the same wire format as Build. +// A legacy MLSAG quote for a CLSAG payment otherwise creates real change. +func TestVersionedFeeQuote_SendAllLeavesNoChange(t *testing.T) { + for _, tc := range []struct { + name string + version core.TxVersion + inputs int + fee uint64 + }{ + {"legacy-single", core.TxVersionCommitmentBinding, 1, 192000}, + {"clsag-single", core.TxVersionCLSAG, 1, 166350}, + {"clsag-two", core.TxVersionCLSAG, 2, 252750}, + } { + t.Run(tc.name, func(t *testing.T) { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + recipient, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + const total = uint64(100 * 100_000_000) + outputs := make([]core.OwnedUTXO, tc.inputs) + for i := range outputs { + hs := crypto.ScalarFromUint64(uint64(i + 1)) + point, err := crypto.ScalarMulBase(hs) + if err != nil { + t.Fatal(err) + } + pub, err := crypto.AddPoints(keys.Spend.Public, point) + if err != nil { + t.Fatal(err) + } + outputs[i] = makeOwnedUTXO(t, pub, total/uint64(tc.inputs), byte(i+1)) + outputs[i].HsScalar = hs + } + decoys := make([]core.DecoyUTXO, tc.inputs*(crypto.RingSize-1)) + for i := range decoys { + output := makeOwnedUTXO(t, newPub(t, uint64(i+101)), uint64(i+1), byte(i+101)) + decoys[i] = core.DecoyUTXO{OneTimePub: output.OneTimePub, AmountCommit: output.AmountCommit} + } + builder := core.NewTxBuilder(keys.Spend.Private, keys.View.Private, keys.Spend.Public, outputs, 50). + WithVersion(tc.version).WithDecoys(decoys) + max := builder.MaxSpendable() + if max.Fee != tc.fee || max.MaxAmount != total-tc.fee || max.InputCount != tc.inputs { + t.Fatalf("unexpected send-all quote: %+v", max) + } + quote := builder.EstimateFeeForAmount(max.MaxAmount) + if !quote.Sufficient || quote.Fee != max.Fee || uint64(quote.TxSizeBytes)*50 != max.Fee { + t.Fatalf("fee estimate disagrees with send-all: %+v versus %+v", quote, max) + } + result, err := builder.Build(max.MaxAmount, + crypto.AddressFromKeys(crypto.TestnetByte, recipient), + crypto.AddressFromKeys(crypto.TestnetByte, keys)) + if err != nil { + t.Fatal(err) + } + if result.TotalFee != quote.Fee || result.ChangeAmount != 0 { + t.Fatalf("send-all retained funds: fee=%d quoted=%d change=%d", + result.TotalFee, quote.Fee, result.ChangeAmount) + } + if builder.EstimateFeeForAmount(^uint64(0)).Sufficient { + t.Fatal("overflowing amount was considered affordable") + } + }) + } +} diff --git a/core/tx_verifier.go b/core/tx_verifier.go index 5079513..921592c 100644 --- a/core/tx_verifier.go +++ b/core/tx_verifier.go @@ -93,8 +93,9 @@ func (vl *VestingLock) lockedAt(pub crypto.Point32, now int64) uint64 { // TxVerifier validates the cryptographic integrity of transactions. // Separate from Validate() (structural) — this checks ring sigs and range proofs. type TxVerifier struct { - utxos *UTXOSet - vestingLock *VestingLock // nil = vesting enforcement disabled + utxos *UTXOSet + vestingLock *VestingLock // nil = vesting enforcement disabled + stakedCollateralRingExclusionActivationHeight uint64 } // NewTxVerifier creates a verifier backed by the given UTXO set. @@ -109,7 +110,16 @@ func (v *TxVerifier) SetVestingLock(vl *VestingLock) { v.vestingLock = vl } -// VerifyTx performs full cryptographic verification of a non-coinbase transaction. +// SetStakedCollateralRingExclusionActivationHeight configures the first block +// height at which CLSAG rings may not reference staked collateral. Zero leaves +// the rule disabled so validators cannot activate it accidentally. +func (v *TxVerifier) SetStakedCollateralRingExclusionActivationHeight(height uint64) { + v.stakedCollateralRingExclusionActivationHeight = height +} + +// VerifyTx performs full cryptographic verification of a transaction when no +// height-gated rules are configured. Call VerifyTxAtHeight for block/mempool +// validation whenever a height-dependent consensus rule is active. // // Checks performed: // 1. Structural validity (Validate) @@ -119,6 +129,23 @@ func (v *TxVerifier) SetVestingLock(vl *VestingLock) { // 5. Range proofs are valid for each output // 6. Pedersen commitment balance: ΣC_in = ΣC_out + C_fee func (v *TxVerifier) VerifyTx(tx *Transaction) error { +if v.stakedCollateralRingExclusionActivationHeight > 0 { +return fmt.Errorf("height-aware transaction verification required") +} + return v.VerifyTxAtHeight(tx, 0) +} + +// VerifyTxAtHeight applies height-dependent transaction rules for the block +// that would include tx. Callers admitting transactions to a mempool must pass +// the next candidate block height. +func (v *TxVerifier) VerifyTxAtHeight(tx *Transaction, height uint64) error { +if v.stakedCollateralRingExclusionActivationHeight > 0 && height == 0 { +return fmt.Errorf("height-aware transaction verification requires a nonzero candidate height") +} + return v.verifyTx(tx, nil, height) +} + +func (v *TxVerifier) verifyTx(tx *Transaction, blockStakeBurns map[UTXOKey]struct{}, height uint64) error { // 1. Structural check if err := tx.Validate(); err != nil { return err @@ -246,6 +273,13 @@ if tx.IsLPoDPosition() { if utxo.AmountCommit != inp.RingCommitments[j] { return fmt.Errorf("tx %x: input %d ring member %d commitment mismatch", txHashPrefix[:8], i, j) } + if activation := v.stakedCollateralRingExclusionActivationHeight; activation > 0 && height >= activation { + _, stakedInBlock := blockStakeBurns[UTXOKey{TxHash: utxo.TxHash, OutputIndex: utxo.OutputIndex}] + if stakedInBlock || v.utxos.IsStaked(utxo.TxHash, utxo.OutputIndex) { + return fmt.Errorf("tx %x: input %d ring member %d is staked collateral", + txHashPrefix[:8], i, j) + } + } } continue } @@ -423,11 +457,39 @@ if tx.IsLPoDPosition() { // reward) are enforced by validateCoinbasePolicy in the consensus engine before // this function is called. func (v *TxVerifier) VerifyBlock(block *Block) error { +if block == nil { +return fmt.Errorf("tx verifier: nil block") +} +if v.stakedCollateralRingExclusionActivationHeight > 0 && block.Header.Height == 0 { +return fmt.Errorf("height-aware block verification requires a nonzero block height") +} + blockStakeBurns := make(map[UTXOKey]struct{}) + enforceStakedCollateralRule := v.stakedCollateralRingExclusionActivationHeight > 0 && + block.Header.Height >= v.stakedCollateralRingExclusionActivationHeight + if enforceStakedCollateralRule { + for _, tx := range block.Txs { + if !tx.IsStake() { + continue + } + switch len(tx.Extra) { + case StakePayloadSizeV2: + action, _, _, _, burnTxHash, burnOutIdx, _, err := DecodeStakeExtraV2(tx.Extra) + if err == nil && action == StakeDeposit { + blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + } + case StakePayloadSizeV3: + action, _, _, _, burnTxHash, burnOutIdx, _, _, err := DecodeStakeExtraV3(tx.Extra) + if err == nil && action == StakeDeposit { + blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + } + } + } + } for i, tx := range block.Txs { if tx.IsCoinbase() || tx.IsGuardianFund() || tx.IsLPoD() || tx.IsLPoDPayout() { continue // no crypto proofs on coinbase; policy checked separately } - if err := v.VerifyTx(&tx); err != nil { + if err := v.verifyTx(&tx, blockStakeBurns, block.Header.Height); err != nil { h := tx.Hash() return fmt.Errorf("block %d tx[%d] %x: %w", block.Header.Height, i, h[:8], err) diff --git a/core/utxo.go b/core/utxo.go index 7c186b1..ecc2538 100644 --- a/core/utxo.go +++ b/core/utxo.go @@ -29,6 +29,13 @@ type RingMemberStore interface { SampleRingMembers(count int, exclude map[crypto.Point32]bool) ([]DecoyUTXO, error) } +// RingMemberFilterStore can exclude retained ring records by canonical output +// identity as well as public key. Output identity is needed for staked records +// reconstructed from legacy payloads that do not carry the one-time public key. +type RingMemberFilterStore interface { + SampleRingMembersExcluding(count int, exclude map[crypto.Point32]bool, excludeUTXOs map[UTXOKey]bool) ([]DecoyUTXO, error) +} + // compactKeyImageSet stores spent key images using a three-tier design: // // 1. recent map[crypto.KeyImage]struct{} — O(1) insert/lookup for key images @@ -947,16 +954,62 @@ func (s *UTXOSet) SampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool) return out } +func isStakedUTXOKey(staked map[UTXOKey]*UTXO, u *UTXO) bool { + if u == nil { + return false + } + _, ok := staked[UTXOKey{TxHash: u.TxHash, OutputIndex: u.OutputIndex}] + return ok +} + func (s *UTXOSet) sampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool, entropy io.Reader) ([]DecoyUTXO, error) { if count <= 0 { return nil, nil } s.mu.RLock() store := s.ringMembers + filtered := make(map[crypto.Point32]bool, len(exclude)+len(s.stakedUTXOs)) + stakedOutpoints := make(map[UTXOKey]bool, len(s.stakedUTXOs)) + for pub, excluded := range exclude { + if excluded { + filtered[pub] = true + } + } + for key, u := range s.stakedUTXOs { + stakedOutpoints[key] = true + if u != nil && u.OneTimePub != (crypto.Point32{}) { + filtered[u.OneTimePub] = true + } + } s.mu.RUnlock() if store != nil { // The persistent index owns its entropy; do not fall back to RAM on error. - return store.SampleRingMembers(count, exclude) + // Exclude staked outputs during sampling, not after it: CLSAG requires a + // complete ring of authentic on-chain members, so post-filtering can + // leave the transaction builder with too few decoys. + var sampled []DecoyUTXO + var err error + if filteredStore, ok := store.(RingMemberFilterStore); ok { + sampled, err = filteredStore.SampleRingMembersExcluding(count, exclude, stakedOutpoints) + } else { + for _, u := range s.stakedUTXOs { + if u == nil || u.OneTimePub == (crypto.Point32{}) { + return nil, fmt.Errorf("ring sampling: staked output identity unavailable") + } + } + sampled, err = store.SampleRingMembers(count, filtered) + } + if err != nil { + return nil, err + } + // Defend against backends that do not honor public-key exclusions. + out := sampled[:0] + for _, candidate := range sampled { + if !filtered[candidate.OneTimePub] { + out = append(out, candidate) + } + } + return out, nil } s.mu.RLock() @@ -968,12 +1021,12 @@ func (s *UTXOSet) sampleCLSAGDecoys(count int, exclude map[crypto.Point32]bool, if _, active := s.byPubKey[pub]; active { continue } - if !exclude[pub] && !isProtocolLockedUTXO(u) { + if !filtered[pub] && !isStakedUTXOKey(s.stakedUTXOs, u) && !isProtocolLockedUTXO(u) { candidates = append(candidates, u) } } for pub, u := range s.byPubKey { - if !exclude[pub] && !isProtocolLockedUTXO(u) { + if !filtered[pub] && !isStakedUTXOKey(s.stakedUTXOs, u) && !isProtocolLockedUTXO(u) { candidates = append(candidates, u) } } diff --git a/core/validator_authority.go b/core/validator_authority.go new file mode 100644 index 0000000..48e0ce4 --- /dev/null +++ b/core/validator_authority.go @@ -0,0 +1,81 @@ +package core + +import ( + "encoding/json" + "fmt" + + "github.com/aperod/aperod/crypto" +) + +// ValidatorAuthorityFork is an operator-approved, future-only consensus fork. +// Identity remains the collateral/accounting identity: it is NOT a current +// signing key after Height. This avoids rewriting stake references, LPoD +// allocation IDs, generation, withdrawal nonces, or historical block hashes. +// Possession proves control of the replacement, not governance authorization. +// Every participating node must independently pin the same approved rule. +type ValidatorAuthorityFork struct { + Version uint32 `json:"version"` + Genesis crypto.Hash32 `json:"genesis"` + Height uint64 `json:"height"` + Parent crypto.Hash32 `json:"parent"` + Identity crypto.ValidatorPubKey `json:"identity"` + Replacement crypto.ValidatorPubKey `json:"replacement"` + RegistrySHA256 crypto.Hash32 `json:"registry_sha256"` + LegacyWitnessSHA256 crypto.Hash32 `json:"legacy_witness_sha256"` + Possession []byte `json:"possession"` +} + +func (f *ValidatorAuthorityFork) Message() crypto.Hash32 { + copy := *f + copy.Possession = nil + raw, _ := json.Marshal(copy) + return crypto.HashBytes([]byte("aperod/validator-authority-fork/v1"), raw) +} + +func (f *ValidatorAuthorityFork) Validate() error { + if f == nil || f.Version != 1 || f.Height < 2 || + f.Genesis == (crypto.Hash32{}) || f.Parent == (crypto.Hash32{}) || + f.RegistrySHA256 == (crypto.Hash32{}) || + len(f.Identity) != 32 || len(f.Replacement) != 32 || + f.Identity.Equals(f.Replacement) { + return fmt.Errorf("validator authority fork: invalid rule") + } + if len(f.Possession) != 64 || !f.Replacement.Verify(f.Message(), f.Possession) { + return fmt.Errorf("validator authority fork: invalid replacement possession") + } + return nil +} + +func (f *ValidatorAuthorityFork) Clone() *ValidatorAuthorityFork { + if f == nil { + return nil + } + copy := *f + copy.Identity = append(crypto.ValidatorPubKey(nil), f.Identity...) + copy.Replacement = append(crypto.ValidatorPubKey(nil), f.Replacement...) + copy.Possession = append([]byte(nil), f.Possession...) + return © +} + +// Authority resolves only the designated identity and only in the new era. +// Historical signatures and unrelated validators retain their original keys. +func (f *ValidatorAuthorityFork) Authority(identity crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { + if f != nil && height >= f.Height && identity.Equals(f.Identity) { + return append(crypto.ValidatorPubKey(nil), f.Replacement...) + } + return append(crypto.ValidatorPubKey(nil), identity...) +} + +func (f *ValidatorAuthorityFork) LocalIdentity(key crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { + if f != nil && height >= f.Height && key.Equals(f.Replacement) { + return append(crypto.ValidatorPubKey(nil), f.Identity...) + } + return append(crypto.ValidatorPubKey(nil), key...) +} + +func (f *ValidatorAuthorityFork) CheckParent(header BlockHeader) error { + if f != nil && header.Height == f.Height && header.PrevHash != f.Parent { + return fmt.Errorf("validator authority fork: activation parent mismatch") + } + return nil +} diff --git a/core/validator_authority_registry.go b/core/validator_authority_registry.go new file mode 100644 index 0000000..f8c644c --- /dev/null +++ b/core/validator_authority_registry.go @@ -0,0 +1,38 @@ +package core + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" +) + +// ConfigureSignatureAuthority binds a rule once; it never changes collateral +// identities or accounting. Configure before replay and before admitting txs. +func (r *ValidatorRegistry) ConfigureSignatureAuthority(f *ValidatorAuthorityFork, nextHeight uint64) error { + if f == nil { + return nil + } + if err := f.Validate(); err != nil { + return err + } + candidate := f.Clone() + if !r.signatureFork.CompareAndSwap(nil, candidate) { + if r.signatureFork.Load().Message() != f.Message() { + return fmt.Errorf("registry: conflicting signature authority fork") + } + } + r.signatureAdmissionHeight.Store(nextHeight) + return nil +} + +func (r *ValidatorRegistry) SetSignatureAdmissionHeight(nextHeight uint64) { + r.signatureAdmissionHeight.Store(nextHeight) +} + +func (r *ValidatorRegistry) SignatureAuthority(identity crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { + return r.signatureFork.Load().Authority(identity, height) +} + +func (r *ValidatorRegistry) verifyValidatorSignature(identity crypto.ValidatorPubKey, height uint64, msg crypto.Hash32, signature []byte) bool { + return r.SignatureAuthority(identity, height).Verify(msg, signature) +} diff --git a/core/validator_authority_test.go b/core/validator_authority_test.go new file mode 100644 index 0000000..4ad4dcf --- /dev/null +++ b/core/validator_authority_test.go @@ -0,0 +1,172 @@ +package core + +import ( + "crypto/sha256" + "encoding/json" + "testing" + + "github.com/aperod/aperod/crypto" +) + +func authorityFixture(t *testing.T) (*ValidatorAuthorityFork, crypto.ValidatorPrivKey, crypto.ValidatorPrivKey) { + t.Helper() + old, oldPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + next, nextPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + f := &ValidatorAuthorityFork{Version: 1, Height: 5, Identity: oldPub, Replacement: nextPub, + Genesis: crypto.HashBytes([]byte("genesis")), Parent: crypto.HashBytes([]byte("parent")), + RegistrySHA256: crypto.HashBytes([]byte("registry"))} + f.Possession = authoritySign(t, next, f.Message()) + if err := f.Validate(); err != nil { + t.Fatal(err) + } + return f, old, next +} + +func authoritySign(t *testing.T, key crypto.ValidatorPrivKey, msg crypto.Hash32) []byte { + t.Helper() + sig, err := key.Sign(msg) + if err != nil { + t.Fatal(err) + } + return sig +} + +func TestAuthorityForkEraAndIdentityBoundaries(t *testing.T) { + f, old, next := authorityFixture(t) + _, unrelated, _ := crypto.GenerateValidatorKey() + msg := crypto.HashBytes([]byte("signed message")) + for _, height := range []uint64{0, 4, 5, 99} { + pub := f.Authority(old.Public(), height) + if pub.Verify(msg, authoritySign(t, old, msg)) != (height < 5) { + t.Fatalf("old signature at %d", height) + } + if pub.Verify(msg, authoritySign(t, next, msg)) != (height >= 5) { + t.Fatalf("new signature at %d", height) + } + if !f.Authority(unrelated, height).Equals(unrelated) { + t.Fatal("changed unrelated identity") + } + } + if !f.LocalIdentity(next.Public(), 5).Equals(old.Public()) { + t.Fatal("accounting identity changed") + } + if f.LocalIdentity(next.Public(), 4).Equals(old.Public()) { + t.Fatal("replacement can impersonate before fork") + } +} + +func TestAuthorityForkPossessionBindsAllRuleFields(t *testing.T) { + f, _, _ := authorityFixture(t) + mutations := []func(*ValidatorAuthorityFork){ + func(x *ValidatorAuthorityFork) { x.Height++ }, + func(x *ValidatorAuthorityFork) { x.Parent[0] ^= 1 }, + func(x *ValidatorAuthorityFork) { x.Genesis[0] ^= 1 }, + func(x *ValidatorAuthorityFork) { x.RegistrySHA256[0] ^= 1 }, + func(x *ValidatorAuthorityFork) { x.LegacyWitnessSHA256[0] ^= 1 }, + func(x *ValidatorAuthorityFork) { x.Identity[0] ^= 1 }, + func(x *ValidatorAuthorityFork) { x.Replacement[0] ^= 1 }, + } + for i, mutate := range mutations { + copy := f.Clone() + mutate(copy) + if copy.Validate() == nil { + t.Fatalf("accepted mutated rule %d", i) + } + } + if f.Validate() != nil { + t.Fatal("clone mutated original") + } +} + +func TestAuthorityForkBlockSignatureAndParent(t *testing.T) { + f, old, next := authorityFixture(t) + for _, height := range []uint64{4, 5, 6} { + b := Block{Header: BlockHeader{Height: height, Timestamp: 1, PrevHash: f.Parent, + ValidatorPub: old.Public(), MerkleRoot: MerkleRoot(nil)}} + b.Header.Signature = authoritySign(t, next, b.Header.Hash()) + if (b.Validate(f.Authority(b.Header.ValidatorPub, height)) == nil) != (height >= 5) { + t.Fatal("incorrect signature era") + } + b.Header.Signature = authoritySign(t, old, b.Header.Hash()) + if (b.Validate(f.Authority(b.Header.ValidatorPub, height)) == nil) != (height < 5) { + t.Fatal("old key retained future authority") + } + } + h := BlockHeader{Height: 5} + if f.CheckParent(h) == nil { + t.Fatal("accepted wrong parent") + } + h.PrevHash = f.Parent + if f.CheckParent(h) != nil { + t.Fatal("rejected approved parent") + } +} + +func TestAuthorityForkDoesNotRewriteCollateral(t *testing.T) { + f, old, next := authorityFixture(t) + reg := NewValidatorRegistry() + reg.InitFromGenesis([]crypto.ValidatorPubKey{old.Public()}, 1_000_000*BaseUnitsPerAPR) + initial, _ := reg.GetEntry(old.Public()) + before, _ := json.Marshal(reg.TakeSnapshot()) + for _, height := range []uint64{4, 5, 6} { + identity := f.LocalIdentity(next.Public(), height) + if height >= 5 { + entry, ok := reg.GetEntry(identity) + if !ok || entry.StakeNAPR != initial.StakeNAPR || entry.StakeAuthNonce != initial.StakeAuthNonce || entry.StakeGeneration != initial.StakeGeneration { + t.Fatal("collateral state changed") + } + } + } + after, _ := json.Marshal(reg.TakeSnapshot()) + if sha256.Sum256(before) != sha256.Sum256(after) { + t.Fatal("registry changed") + } +} + +func TestAuthorityForkWithdrawalV2AllPaths(t *testing.T) { + f, old, next := authorityFixture(t) + for _, signer := range []crypto.ValidatorPrivKey{old, next} { + for _, path := range []string{"admission", "shadow", "apply", "replay"} { + t.Run(signer.Public().Hex()+"/"+path, func(t *testing.T) { + r := NewValidatorRegistry() + r.InitFromGenesis([]crypto.ValidatorPubKey{old.Public()}, 1_000_000*BaseUnitsPerAPR) + if err := r.ConfigureStakeWithdrawalV2(f.Genesis, 2); err != nil { + t.Fatal(err) + } + if err := r.ConfigureSignatureAuthority(f, 5); err != nil { + t.Fatal(err) + } + entry, _ := r.GetEntry(old.Public()) + a := StakeWithdrawalAuthorizationV2{Action: StakePartialWithdraw, PubKey: old.Public(), + Amount: BaseUnitsPerAPR, Genesis: f.Genesis, Nonce: entry.StakeAuthNonce + 1, + Generation: entry.StakeGeneration, ExpiryHeight: 100, + StakeRef: StakeReferenceV2(f.Genesis, old.Public(), entry.StakeGeneration, entry.StakeNAPR)} + a.Signature = authoritySign(t, signer, StakeWithdrawalSignMsgV2(a)) + extra, err := EncodeStakeWithdrawalExtraV2(a) + if err != nil { + t.Fatal(err) + } + tx := Transaction{Version: TxVersionStake, Extra: extra} + switch path { + case "admission": + err = r.ValidateStakeTx(tx) + case "shadow": + err = r.ValidateBlockStakeTxs([]Transaction{tx}, 5) + case "apply": + _, err = r.ApplyBlockStakeTxs([]Transaction{tx}, 5) + case "replay": + err = r.ReplayBlockStakeTxs([]Transaction{tx}, 5) + } + if (err == nil) != signer.Public().Equals(next.Public()) { + t.Fatalf("signature authority not enforced: %v", err) + } + }) + } + } +} diff --git a/crypto/value_authorization.go b/crypto/value_authorization.go new file mode 100644 index 0000000..a2272ee --- /dev/null +++ b/crypto/value_authorization.go @@ -0,0 +1,85 @@ +package crypto + +import ( + "crypto/sha512" + "encoding/binary" + "fmt" + + "filippo.io/edwards25519" +) + +// ValueAuthorization proves C - amount*H = blind*G without disclosing blind. +// It is a value-opening proof, not permission to issue currency. The separate +// protocol authority must sign the exact output and allocation debit. +type ValueAuthorization [64]byte + +func valueAuthorizationChallenge(amount uint64, commit Commitment, nonce []byte) *edwards25519.Scalar { + var n [8]byte + binary.BigEndian.PutUint64(n[:], amount) + h := sha512.New() + h.Write([]byte("aperod/sale/known-value-opening/v1")) + h.Write(n[:]) + h.Write(commit[:]) + h.Write(nonce) + s, _ := new(edwards25519.Scalar).SetUniformBytes(h.Sum(nil)) + return s +} + +func ProveValueAuthorization(amount uint64, blind BlindFactor) (Commitment, ValueAuthorization, error) { + commit, err := Commit(amount, blind) + if err != nil { + return Commitment{}, ValueAuthorization{}, err + } + r, err := ScalarFromBytes(blind[:]) + if err != nil { + return Commitment{}, ValueAuthorization{}, err + } + nonce, err := NewBlindFactor() + if err != nil { + return Commitment{}, ValueAuthorization{}, err + } + k, err := ScalarFromBytes(nonce[:]) + if err != nil { + return Commitment{}, ValueAuthorization{}, err + } + R := new(edwards25519.Point).ScalarBaseMult(k) + challenge := valueAuthorizationChallenge(amount, commit, R.Bytes()) + response := new(edwards25519.Scalar).MultiplyAdd(challenge, r, k) + var proof ValueAuthorization + copy(proof[:32], R.Bytes()) + copy(proof[32:], response.Bytes()) + return commit, proof, nil +} + +func VerifyValueAuthorization(amount uint64, commit Commitment, proof ValueAuthorization) error { + C, err := checkedCLSAGSubgroupPoint(Point32(commit), "sale commitment", true) + if err != nil { + return err + } + var Rbytes Point32 + copy(Rbytes[:], proof[:32]) + R, err := checkedCLSAGPrimePoint(Rbytes, "sale opening nonce") + if err != nil { + return err + } + s, err := ScalarFromBytes(proof[32:]) + if err != nil { + return err + } + difference := new(edwards25519.Point).Subtract(C, + new(edwards25519.Point).ScalarMult(scalarFromUint64(amount), hGenerator)) + challenge := valueAuthorizationChallenge(amount, commit, proof[:32]) + want := new(edwards25519.Point).Add(R, new(edwards25519.Point).ScalarMult(challenge, difference)) + if new(edwards25519.Point).ScalarBaseMult(s).Equal(want) != 1 { + return fmt.Errorf("sale: output value differs from authorized debit") + } + return nil +} + +func ValidateAuthorizedOutputPoints(pub, ephemeral Point32) error { + if _, err := checkedCLSAGPrimePoint(pub, "sale recipient output"); err != nil { + return err + } + _, err := checkedCLSAGPrimePoint(ephemeral, "sale ephemeral output") + return err +} diff --git a/crypto/value_authorization_test.go b/crypto/value_authorization_test.go new file mode 100644 index 0000000..fbedd20 --- /dev/null +++ b/crypto/value_authorization_test.go @@ -0,0 +1,36 @@ +package crypto + +import "testing" + +func TestValueAuthorizationBindsActualDebitWithoutRevealingBlind(t *testing.T) { + blind, err := NewBlindFactor() + if err != nil { + t.Fatal(err) + } + commit, proof, err := ProveValueAuthorization(10000000000, blind) + if err != nil { + t.Fatal(err) + } + if err = VerifyValueAuthorization(10000000000, commit, proof); err != nil { + t.Fatal(err) + } + if VerifyValueAuthorization(9999999999, commit, proof) == nil { + t.Fatal("under-accounted issuance accepted") + } + mutated := proof + mutated[32] ^= 1 + if VerifyValueAuthorization(10000000000, commit, mutated) == nil { + t.Fatal("altered opening accepted") + } + other, _ := Commit(10000000001, blind) + if VerifyValueAuthorization(10000000000, other, proof) == nil { + t.Fatal("other commitment accepted") + } + var malformed Commitment + for i := range malformed { + malformed[i] = 255 + } + if VerifyValueAuthorization(10000000000, malformed, proof) == nil { + t.Fatal("noncanonical commitment accepted") + } +} diff --git a/deploy/fixtures/node-lifecycle/go.mod b/deploy/fixtures/node-lifecycle/go.mod index 7a90488..dd3df23 100644 --- a/deploy/fixtures/node-lifecycle/go.mod +++ b/deploy/fixtures/node-lifecycle/go.mod @@ -1,4 +1,4 @@ // Template assets; the lifecycle generator copies each main into its own command. module fixture.invalid/lifecycle-assets -go 1.21 +go 1.26.9 diff --git a/deploy/publish_public_repo.py b/deploy/publish_public_repo.py index 7244e7b..824c740 100644 --- a/deploy/publish_public_repo.py +++ b/deploy/publish_public_repo.py @@ -5,8 +5,10 @@ import json import os from pathlib import Path +from importlib.metadata import PackageNotFoundError, distribution import shutil import subprocess +import sys import tempfile import time @@ -39,16 +41,46 @@ "Co-authored-by: aperod-network ") -def run(*args, cwd=None): +def run(*args, cwd=None, test_python_userbase=None): # Repository-controlled tests/builds must not inherit publication tokens, # SSH passwords, bot credentials or unrelated application secrets. allowed = {"PATH", "HOME", "TMPDIR", "LANG", "TZ", "GOTOOLCHAIN", "GOPATH", "GOMAXPROCS", "GOSUMDB", "GOCACHE", "GOPROXY", "CGO_ENABLED", "GOROOT", "LD_LIBRARY_PATH", "NIX_LD"} env = {k: v for k, v in os.environ.items() if k in allowed} + if test_python_userbase is not None: + if args[:2] != ("go", "test"): + raise ValueError("isolated Python test dependencies are only for Go tests") + # Do not overwrite PYTHONPATH: Go test fixtures may provide their own + # isolated stubs. Python's user-site path supplies PyYAML as a fallback. + env["PYTHONUSERBASE"] = str(test_python_userbase) subprocess.run(list(args), cwd=cwd, check=True, env=env) +def prepare_test_python_userbase(destination): + """Expose only the trusted PyYAML package to candidate Go tests.""" + try: + dist = distribution("PyYAML") + package = Path(dist.locate_file("yaml")).resolve() + dist_root = Path(dist.locate_file("")).resolve() + except (PackageNotFoundError, OSError) as exc: + raise RuntimeError( + "PyYAML is required for exported deployment tests; " + "refusing to treat incomplete coverage as a pass" + ) from exc + if (not package.is_dir() or not (package / "__init__.py").is_file() + or not package.is_relative_to(dist_root)): + raise RuntimeError("PyYAML resolved outside its installed distribution") + site_packages = ( + Path(destination) / "lib" + / f"python{sys.version_info.major}.{sys.version_info.minor}" + / "site-packages" + ) + site_packages.mkdir(parents=True, exist_ok=True) + shutil.copytree(package, site_packages / "yaml") + return Path(destination) + + def prepare(root, candidate, gate_only=False): source = root / "blockchain" if (root / "blockchain/go.mod").exists() else root # Only runtime artifacts in the disposable clone are removed. This tool @@ -132,7 +164,10 @@ def verify(candidate, tree, go_checks=True, with_tests=False): run("go", "vet", "./...", cwd=export) run("go", "run", "golang.org/x/vuln/cmd/govulncheck@v1.1.4", "./...", cwd=export) if with_tests: - run("go", "test", "./...", cwd=export) + with tempfile.TemporaryDirectory(prefix="aperod-test-python-") as python_temp: + python_userbase = prepare_test_python_userbase(python_temp) + run("go", "test", "./...", cwd=export, + test_python_userbase=python_userbase) if git(candidate, "write-tree").decode().strip() != tree: raise RuntimeError("Candidate changed during verification") print(f"Verified immutable candidate: {checked} files.") diff --git a/deploy/rollout_cleanup/__init__.py b/deploy/rollout_cleanup/__init__.py index 8b0f13e..4d0b3e8 100644 --- a/deploy/rollout_cleanup/__init__.py +++ b/deploy/rollout_cleanup/__init__.py @@ -695,6 +695,15 @@ def complete(job_id: str, expected_binary_sha256: str, *, state_dir: Path | None job = _safe_record(job_path, rt) if job.get("id") != job_id or job.get("status") != "begun": raise CleanupError("job is not incomplete") + proof = _completion_proof(job, expected_binary_sha256, rt) + job.update(proof) + atomic_json(job_path, job) + return {"id": job_id, "status": "completed", "height": proof["completion_height"]} + + +def _completion_proof(job: dict[str, Any], expected_binary_sha256: str, + rt: Runtime) -> dict[str, Any]: + """The same strict completion checks for normal and reconciled registrations.""" _registered_release(job, rt) origin = job["origin"] # A rollout intentionally restarts the node. Bind completion to one stable @@ -721,12 +730,10 @@ def complete(job_id: str, expected_binary_sha256: str, *, state_dir: Path | None or _sha_live_exe(live_pid, rt) != installed_hash): raise CleanupError("post-rollout process changed during completion validation") _verify_process_binding(live_pid, Path(origin["data_dir"]), Path(origin["config"]), rt) - job.update({"status": "completed", "completed_at": int(rt.clock()), + return {"status": "completed", "completed_at": int(rt.clock()), "completion_height": height, "completion_hash": tip, "binary_sha256": installed_hash, "runtime_pid": live_pid, - "runtime_start_ticks": live_ticks, "runtime_exe": str(live_exe)}) - atomic_json(job_path, job) - return {"id": job_id, "status": "completed", "height": height} + "runtime_start_ticks": live_ticks, "runtime_exe": str(live_exe)} def _source_current(context: dict[str, Any], runtime: Runtime | None = None) -> None: diff --git a/deploy/rollout_cleanup/cli.py b/deploy/rollout_cleanup/cli.py index dcdb6d7..4145991 100644 --- a/deploy/rollout_cleanup/cli.py +++ b/deploy/rollout_cleanup/cli.py @@ -4,6 +4,7 @@ import argparse import json +import re import sys from . import ( @@ -20,6 +21,40 @@ state_lock, ) from .retirement import register as register_retirement, scan_retirements +from .transition import reconcile + +_SENSITIVE_OUTPUT_KEY = re.compile( + r"(?:password|passphrase|secret|token|credential|authorization|" + r"api[_-]?key|private[_-]?key|mnemonic|seed|bearer)", + re.IGNORECASE, +) +_INLINE_CREDENTIAL = re.compile( + r"(?i)\b(password|passphrase|secret(?:[_ -]?access[_ -]?key)?|token|" + r"api[_ -]?key|authorization|bearer|private[_ -]?key|" + r"mnemonic(?:\s+phrase)?|seed(?:\s+phrase)?)\b(\s*[:=]\s*|\s+)" + r'("[^"]*"|\'[^\']*\'|[^,;\r\n]+)' +) +_URL_CREDENTIALS = re.compile(r"(?i)(https?://)[^/\s@]+:[^/\s@]+@") + + +def _redact_text(value: str) -> str: + value = _URL_CREDENTIALS.sub(r"\1[REDACTED]@", value) + return _INLINE_CREDENTIAL.sub(r"\1\2[REDACTED]", value) + + +def _safe_output(value): + """Remove credential-shaped fields before emitting operator JSON.""" + if isinstance(value, dict): + return { + str(key): _safe_output(item) + for key, item in value.items() + if not _SENSITIVE_OUTPUT_KEY.search(str(key)) + } + if isinstance(value, (list, tuple)): + return [_safe_output(item) for item in value] + if isinstance(value, str): + return _redact_text(value) + return value def _parser() -> argparse.ArgumentParser: @@ -42,6 +77,12 @@ def _parser() -> argparse.ArgumentParser: finish.add_argument("--id", required=True) finish.add_argument("--expected-binary-sha256", required=True) + transition = commands.add_parser("reconcile-config-transition") + transition.add_argument("--id", required=True) + transition.add_argument("--previous-config", required=True) + transition.add_argument("--expected-config-sha256", required=True) + transition.add_argument("--expected-binary-sha256", required=True) + check = commands.add_parser("scan") check_mode = check.add_mutually_exclusive_group() check_mode.add_argument("--apply", action="store_true", help="delete only artifacts proven safe for removal") @@ -93,6 +134,10 @@ def _dispatch(args: argparse.Namespace) -> dict: if args.command == "complete": with state_lock(): return complete(args.id, args.expected_binary_sha256) + if args.command == "reconcile-config-transition": + with state_lock(): + return reconcile(args.id, args.previous_config, args.expected_config_sha256, + args.expected_binary_sha256) if args.command == "scan": return scan(args.apply) if args.command == "retirement-register": @@ -120,10 +165,10 @@ def main(argv: list[str] | None = None) -> int: try: args = _parser().parse_args(argv) result = _dispatch(args) - sys.stdout.write(json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n") + sys.stdout.write(json.dumps(_safe_output(result), sort_keys=True, separators=(",", ":")) + "\n") return 1 if result.get("refused") or result.get("status") == "refused" else 0 except CleanupError as exc: - sys.stdout.write(json.dumps({"error": str(exc)}, sort_keys=True, separators=(",", ":")) + "\n") + sys.stdout.write(json.dumps({"error": _redact_text(str(exc))}, sort_keys=True, separators=(",", ":")) + "\n") return 1 except (OSError, ValueError, KeyError, TypeError) as exc: # Deliberately avoid emitting exception details from credential/network code. diff --git a/deploy/rollout_cleanup/retirement.py b/deploy/rollout_cleanup/retirement.py index ad8627f..1f6a413 100644 --- a/deploy/rollout_cleanup/retirement.py +++ b/deploy/rollout_cleanup/retirement.py @@ -15,7 +15,7 @@ import urllib.request import uuid from pathlib import Path -from typing import Any +from typing import Any, Callable from . import ( CleanupError, _assert_not_mountpoint, _assert_root_dir, _capture_origin, @@ -32,6 +32,22 @@ MIN_FREE_SPACE = 5 * 1024 * 1024 * 1024 +class _StagingHeadroomError(CleanupError): + """Only this typed refusal is eligible for a bounded space-progress retry.""" + + def __init__(self, required: int, available: int): + super().__init__("insufficient staging disk headroom for historical archive") + self.required, self.available = required, available + + +def _staging_free_bytes(state_dir: Path) -> int | None: + try: + return shutil.disk_usage(state_dir).free + except OSError: + # Space telemetry never overrides a successful durable deletion receipt. + return None + + def _retirement_path(state_dir: Path, retirement_id: str) -> Path: if not re.fullmatch(r"[0-9a-fA-F-]{36}", retirement_id): raise CleanupError("invalid retirement id") @@ -134,8 +150,9 @@ def _ensure_staging_headroom(work: Path, targets: list[dict[str, Any]]) -> None: if any(type(target.get("bytes")) is not int or target["bytes"] < 0 for target in targets): raise CleanupError("invalid historical source size") required = source_bytes * 4 + MIN_FREE_SPACE - if shutil.disk_usage(work).free < required: - raise CleanupError("insufficient staging disk headroom for historical archive") + available = shutil.disk_usage(work).free + if available < required: + raise _StagingHeadroomError(required, available) def _safe_tar_name(name: str) -> bool: @@ -657,7 +674,8 @@ def _target_current_manifest(target: dict[str, Any], runtime: Any) -> list[dict[ def _delete_targets(record: dict[str, Any], targets: list[dict[str, Any]], - runtime: Any, settings: dict[str, Any]) -> int: + runtime: Any, settings: dict[str, Any], *, + on_space_freed: Callable[[], None] | None = None) -> int: removed = 0 config = Path(record["origin"]["config"]) for target in targets: @@ -684,13 +702,19 @@ def _delete_targets(record: dict[str, Any], targets: list[dict[str, Any]], raise CleanupError("historical target changed immediately before deletion") _proc_references(record["origin"]["pid"], path, config, runtime=runtime, manifest=target["manifest"]) - removed += _delete_manifest(target, runtime) + free_before = _staging_free_bytes(Path(runtime.state_dir)) if on_space_freed else None + target_removed = _delete_manifest(target, runtime) + removed += target_removed record.setdefault("receipts", []).append({ "path": target["path"], "manifest_sha256": _target_manifest_hash(target), "deleted_at": int(runtime.clock()), }) record["status"] = "archived" atomic_json(_retirement_path(Path(runtime.state_dir), record["id"]), record) + if on_space_freed and target_removed > 0 and free_before is not None: + free_after = _staging_free_bytes(Path(runtime.state_dir)) + if free_after is not None and free_after > free_before: + on_space_freed() return removed @@ -715,11 +739,16 @@ def scan_retirements(apply: bool = False, runtime: Any = None) -> dict[str, Any] except (OSError, ValueError, KeyError, TypeError): outcomes.append({"id": entry.name[11:-5], "status": "refused", "error": "operation refused: unsafe record or host I/O failure"}) - for record in records: - if record["status"] == "completed": - outcomes.append({"id": record["id"], "status": "completed", "removed_bytes": 0}) - continue + space_freed = False + + def note_space_freed() -> None: + nonlocal space_freed + space_freed = True + + def attempt(record: dict[str, Any]) -> tuple[dict[str, Any], _StagingHeadroomError | None]: try: + if record["status"] == "completed": + return {"id": record["id"], "status": "completed", "removed_bytes": 0}, None stopped, targets = _revalidate_record(record, rt) _origin_unchanged(record["origin"], rt) for target in targets: @@ -727,9 +756,8 @@ def scan_retirements(apply: bool = False, runtime: Any = None) -> dict[str, Any] Path(record["origin"]["config"]), runtime=rt, manifest=target["manifest"]) if not apply: - outcomes.append({"id": record["id"], "status": "ready", - "targets": len(targets), "bytes": sum(t["bytes"] for t in targets)}) - continue + return {"id": record["id"], "status": "ready", + "targets": len(targets), "bytes": sum(t["bytes"] for t in targets)}, None settings = _settings(rt) if record.get("archive") is None: record["archive"] = _archive_and_verify(record, stopped, targets, rt, settings) @@ -743,17 +771,49 @@ def scan_retirements(apply: bool = False, runtime: Any = None) -> dict[str, Any] # verification; no backup chain coverage is inferred here. _origin_unchanged(record["origin"], rt) _revalidate_record(record, rt) - removed_bytes = _delete_targets(record, targets, rt, settings) + removed_bytes = _delete_targets(record, targets, rt, settings, + on_space_freed=note_space_freed) record["status"] = "completed" record["completed_at"] = int(rt.clock()) atomic_json(_retirement_path(state_dir, record["id"]), record) - outcomes.append({"id": record["id"], "status": "completed", - "removed_bytes": removed_bytes}) + return {"id": record["id"], "status": "completed", + "removed_bytes": removed_bytes}, None + except _StagingHeadroomError as exc: + return {"id": record.get("id"), "status": "refused", "error": str(exc)}, exc except CleanupError as exc: - outcomes.append({"id": record.get("id"), "status": "refused", "error": str(exc)}) + return {"id": record.get("id"), "status": "refused", "error": str(exc)}, None except (OSError, ValueError, KeyError, TypeError): - outcomes.append({"id": record.get("id"), "status": "refused", - "error": "operation refused: unsafe record or host I/O failure"}) + return {"id": record.get("id"), "status": "refused", + "error": "operation refused: unsafe record or host I/O failure"}, None + + deferred: list[tuple[int, str, _StagingHeadroomError]] = [] + for record in records: + outcome, headroom = attempt(record) + if headroom is not None: + deferred.append((len(outcomes), record["id"], headroom)) + outcomes.append(outcome) + + # One additional pass, at most one retry per space-refused record. + # Smallest staging requirement first so a later eligible copy can make + # room for a larger one without needing another loop or timer launch. + if apply and space_freed: + for index, retirement_id, failure in sorted(deferred, key=lambda item: item[2].required): + available = _staging_free_bytes(state_dir) + if (available is None or available <= failure.available + or available < failure.required): + continue + try: + fresh = _safe_record(_retirement_path(state_dir, retirement_id), rt) + if fresh.get("type") != "historical-retirement" or fresh.get("id") != retirement_id: + raise CleanupError("invalid historical retirement record") + # Reload durable archive/partial receipts; never reuse an + # earlier in-memory authorization or skip archive readback. + outcomes[index], _ = attempt(fresh) + except CleanupError as exc: + outcomes[index] = {"id": retirement_id, "status": "refused", "error": str(exc)} + except (OSError, ValueError, KeyError, TypeError): + outcomes[index] = {"id": retirement_id, "status": "refused", + "error": "operation refused: unsafe record or host I/O failure"} return { "apply": bool(apply), "status": "refused" if any( row.get("status") == "refused" for row in outcomes diff --git a/deploy/rollout_cleanup/transition.py b/deploy/rollout_cleanup/transition.py new file mode 100644 index 0000000..ac0d530 --- /dev/null +++ b/deploy/rollout_cleanup/transition.py @@ -0,0 +1,113 @@ +"""Explicit configuration-transition reconciliation; never rewrite an origin.""" +from __future__ import annotations + +import copy +import hashlib +import os +import stat +import uuid +from pathlib import Path +from typing import Any + + +def reconcile(job_id: str, previous_config: str, expected_config_sha256: str, + expected_binary_sha256: str, *, runtime=None) -> dict[str, Any]: + # Import at call time to avoid a package initialization cycle. + from . import ( + CleanupError, HEX64, _rt, _validate_job_id, _safe_record, + _registered_release, _identity, _config_hash, _owns_root, + _block, _revalidate_artifact, _capture_origin, _completion_proof, + _origin_static_unchanged, _binary_unchanged, atomic_json, _state_json_paths, + ) + rt = _rt(runtime) + _validate_job_id(job_id) + if not HEX64.fullmatch(expected_config_sha256) or not HEX64.fullmatch(expected_binary_sha256): + raise CleanupError("invalid transition hashes") + path = rt.state_dir / (job_id + ".json") + job = _safe_record(path, rt) + if job.get("id") != job_id: + raise CleanupError("transition job identity mismatch") + if job.get("status") == "superseded": + successor_id = job.get("replaced_by", "") + _validate_job_id(successor_id) + successor = _safe_record(rt.state_dir / (successor_id + ".json"), rt) + if (successor.get("status") != "completed" or successor.get("replaces") != job_id + or successor.get("origin", {}).get("config_sha256") != expected_config_sha256 + or successor.get("binary_sha256") != expected_binary_sha256): + raise CleanupError("transition successor binding failed") + return {"id": job_id, "status": "superseded", "replacement_id": successor_id} + if job.get("status") != "begun": + raise CleanupError("only an incomplete registration can be reconciled") + release = _registered_release(job, rt) + origin = job["origin"] + previous = Path(previous_config) + if not previous.is_absolute() or previous.parent != release: + raise CleanupError("previous configuration must be retained in the registered release") + info = os.lstat(previous) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or not _owns_root(info, rt) + or stat.S_IMODE(info.st_mode) & 0o077): + raise CleanupError("previous configuration must be a protected, root-owned regular file") + previous_identity = _identity(previous) + if _config_hash(previous) != origin["config_sha256"]: + raise CleanupError("retained previous configuration does not match original receipt") + if (expected_config_sha256 == origin["config_sha256"] + or _config_hash(Path(origin["config"])) != expected_config_sha256): + raise CleanupError("explicit approved configuration hash does not match the transition") + + def verify_previous_evidence(): + if (_identity(previous) != previous_identity + or _config_hash(previous) != origin["config_sha256"]): + raise CleanupError("previous configuration changed during reconciliation") + if _identity(Path(origin["data_dir"]) / "chain.db", allow_dir=True) != origin["chain_db"]: + raise CleanupError("live chain.db identity changed") + for height, expected in ((0, origin["genesis_hash"]), (origin["height"], origin["hash"])): + if _block(origin["api_url"], height, rt)[1] != expected: + raise CleanupError("original canonical transition evidence changed") + for item in job["marks"]: + _revalidate_artifact(item, job, rt) + + verify_previous_evidence() + original_digest = hashlib.sha256(path.read_bytes()).hexdigest() + binding = { + "previous_config_sha256": origin["config_sha256"], + "approved_config_sha256": expected_config_sha256, + "previous_receipt_sha256": original_digest, + } + # Resume safely if the process died between the two atomic journal writes. + successor = None + for candidate_path in _state_json_paths(rt.state_dir): + candidate = _safe_record(candidate_path, rt) + if candidate.get("replaces") == job_id: + if candidate.get("config_transition") != binding or successor is not None: + raise CleanupError("ambiguous or mismatched transition successor") + successor = candidate + if successor is None: + fresh = _capture_origin(Path(origin["data_dir"]), Path(origin["config"]), + origin["service"], origin["api_url"], rt) + successor = { + "schema": 1, "id": str(uuid.uuid4()), "created_at": int(rt.clock()), + "status": "begun", "release": job["release"], + "release_identity": copy.deepcopy(job["release_identity"]), + "origin": fresh, "marks": copy.deepcopy(job["marks"]), + "replaces": job_id, "config_transition": binding, + } + # No successor is persisted until all normal completion guards pass. + successor.update(_completion_proof(successor, expected_binary_sha256, rt)) + else: + if (successor.get("status") != "completed" + or successor.get("origin", {}).get("config_sha256") != expected_config_sha256 + or successor.get("binary_sha256") != expected_binary_sha256): + raise CleanupError("transition successor has not completed") + _origin_static_unchanged(successor["origin"], rt) + _binary_unchanged(successor, rt) + verify_previous_evidence() + if hashlib.sha256(path.read_bytes()).hexdigest() != original_digest: + raise CleanupError("original receipt changed during reconciliation") + atomic_json(rt.state_dir / (successor["id"] + ".json"), successor) + # Preserve the entire original origin and marks, with an honest terminal state. + job.update({"status": "superseded", "closed_at": int(rt.clock()), + "replaced_by": successor["id"], "closure_reason": "verified_config_transition", + "config_transition": binding}) + atomic_json(path, job) + return {"id": job_id, "status": "superseded", "replacement_id": successor["id"], + "replacement_status": "completed"} \ No newline at end of file diff --git a/deploy/rollout_cleanup_installer_test.go b/deploy/rollout_cleanup_installer_test.go index 08fd1dd..d970604 100644 --- a/deploy/rollout_cleanup_installer_test.go +++ b/deploy/rollout_cleanup_installer_test.go @@ -49,6 +49,7 @@ func TestRolloutCleanupHooksStandaloneLayout(t *testing.T) { "aperod-historical-retirement.service", "aperod-historical-retirement.timer", "rollout_cleanup/__init__.py", "rollout_cleanup/cli.py", "rollout_cleanup/runtime.py", "rollout_cleanup/provider.py", "rollout_cleanup/retirement.py", + "rollout_cleanup/transition.py", } { content, err := os.ReadFile(name) if err != nil { diff --git a/deploy/sale-upgrade-guard.sh b/deploy/sale-upgrade-guard.sh new file mode 100644 index 0000000..86c19a0 --- /dev/null +++ b/deploy/sale-upgrade-guard.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Sourced by update entrypoints. No installed root tool is replaced by this file. +# The candidate must implement the read-only maintenance mode. No opt-out. +sale_upgrade_guard() ( + set -euo pipefail + local binary="$1" config="$2" result + command -v python3 >/dev/null || { echo "Sale preflight: python3 missing; service NOT stopped." >&2; exit 1; } + [[ -x "$binary" && -f "$config" ]] || { + echo "Sale preflight: candidate/config unavailable; service NOT stopped." >&2 + exit 1 + } + # Legacy nodes ignore unknown flags. Probe with their safe validation mode + # before asking for the new command, so they cannot accidentally start. + result=$("$binary" --sale-upgrade-preflight-capability --validate-config --config "$config") || exit 1 + printf '%s' "$result" | python3 -c ' +import json,sys +x=json.load(sys.stdin) +assert x=={"schema":1,"command":"sale-upgrade-preflight"} and type(x["schema"]) is int +' || { echo "Sale preflight unsupported; service NOT stopped." >&2; exit 1; } + result=$("$binary" --sale-upgrade-preflight --config "$config") || { + echo "Sale preflight refused; service NOT stopped. Preserve the refused backup copy." >&2 + exit 1 + } + # Unsupported flags, arbitrary exit-0 output and malformed receipts fail. + printf '%s' "$result" | python3 -c ' +import json, sys +x=json.load(sys.stdin) +assert set(x)=={"schema","status","mode","tip_height","tip_hash"} +assert type(x["schema"]) is int and x["schema"]==1 and x["status"]=="ok" +assert x["mode"] in ("inactive","activation_pending","active") +assert type(x["tip_height"]) is int and 0<=x["tip_height"]<2**64 +assert isinstance(x["tip_hash"],str) and len(x["tip_hash"])==64 and x["tip_hash"]!="0"*64 +assert all(c in "0123456789abcdef" for c in x["tip_hash"]) +' || { echo "Sale preflight receipt invalid; service NOT stopped." >&2; exit 1; } + echo "Sale preflight: signed configuration and canonical copy consistent." +) diff --git a/deploy/sale_upgrade_guard_test.go b/deploy/sale_upgrade_guard_test.go new file mode 100644 index 0000000..13258d7 --- /dev/null +++ b/deploy/sale_upgrade_guard_test.go @@ -0,0 +1,21 @@ +package deploy + +import ( + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestSaleUpgradeGuard(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires bash") + } + _, file, _, _ := runtime.Caller(0) + cmd := exec.Command("bash", filepath.Join(filepath.Dir(file), "test-sale-upgrade-guard.sh")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sale upgrade guard: %v\n%s", err, output) + } + t.Log(string(output)) +} diff --git a/deploy/setup-backup.sh b/deploy/setup-backup.sh index 3fe6563..589d8d7 100755 --- a/deploy/setup-backup.sh +++ b/deploy/setup-backup.sh @@ -57,7 +57,7 @@ trap cleanup_verify_tmp EXIT if ! ( cd "${BLOCKCHAIN_DIR}" - CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.25.13+auto \ + CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.9+auto \ go build -o "${VERIFY_TMP}" ./cmd/backup-verify ); then die "Не удалось собрать aperod-backup-verify; существующий бинарный файл не изменён" diff --git a/deploy/test-avm-stress.sh b/deploy/test-avm-stress.sh index b6b8ed3..4d7ea6c 100755 --- a/deploy/test-avm-stress.sh +++ b/deploy/test-avm-stress.sh @@ -21,5 +21,5 @@ APEROD_RUN_AVM_STRESS=1 \ APEROD_AVM_STRESS_DURATION="${duration}" \ APEROD_AVM_STRESS_WORKERS="${workers}" \ GOSUMDB=sum.golang.org \ -GOTOOLCHAIN=go1.25.13+auto \ +GOTOOLCHAIN=go1.26.9+auto \ go test ./consensus -run '^TestAVMSpamDoesNotDelayBlockProduction$' -count=1 -v -timeout=7m \ No newline at end of file diff --git a/deploy/test-avm-two-validator.sh b/deploy/test-avm-two-validator.sh index dc1eb2b..b5f570f 100755 --- a/deploy/test-avm-two-validator.sh +++ b/deploy/test-avm-two-validator.sh @@ -13,4 +13,4 @@ fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "${script_dir}/.." -GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.25.13+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file +GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.9+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file diff --git a/deploy/test-historical-retirement.py b/deploy/test-historical-retirement.py index 0502298..5ad31d4 100644 --- a/deploy/test-historical-retirement.py +++ b/deploy/test-historical-retirement.py @@ -11,6 +11,10 @@ import tarfile import tempfile import unittest +import itertools +import json +import signal +from types import SimpleNamespace from pathlib import Path from unittest import mock @@ -32,11 +36,11 @@ def test_refused_scan_cannot_report_service_success(self): class RetirementFixture: - def __init__(self, root: Path): + def __init__(self, root: Path, name="legacy-1", table=None): self.root = root self.state = root / "state" self.releases = root / "releases" - self.release = self.releases / "legacy-1" + self.release = self.releases / name self.stopped = self.release / "rollback" / "stopped-data" self.data = root / "live-data" self.config = root / "node.conf" @@ -49,7 +53,8 @@ def __init__(self, root: Path): path.chmod(0o700) (self.stopped / "chain.db" / "CURRENT").write_text("MANIFEST-000001\n") (self.stopped / "chain.db" / "MANIFEST-000001").write_bytes(b"db manifest") - (self.stopped / "chain.db" / "000001.ldb").write_bytes(b"historical leveldb") + (self.stopped / "chain.db" / "000001.ldb").write_bytes( + b"historical leveldb" if table is None else table) (self.stopped / "snapshot-legacy.gz").write_bytes(b"snapshot bytes") (self.stopped / "snapshot-legacy.gz.sha256").write_text("a" * 64) (self.stopped / "p2p_identity.key").write_bytes(b"preserve secret") @@ -71,7 +76,7 @@ def __init__(self, root: Path): clock=lambda: 100, proc_root=root / "proc", ) self.proc = self.runtime.proc_root - self.proc.mkdir() + self.proc.mkdir(exist_ok=True) self.registration = self.register() @property @@ -91,6 +96,415 @@ def record_path(self): return self.state / ("retirement-" + self.registration["id"] + ".json") +class HeadroomRetryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + import uuid + # Atomic metadata writes also consume uuid4 for temporary filenames. + ids = itertools.count(1) + with mock.patch.object(retirement.uuid, "uuid4", + side_effect=lambda: uuid.UUID(int=next(ids))): + self.big = RetirementFixture(root, "large", b"x" * 4096) + self.small = RetirementFixture(root, "small", b"x" * 2048) + self.big_id, self.small_id = (f.registration["id"] for f in (self.big, self.small)) + self.big_record = cleanup.read_json(self.big.record_path()) + self.small_record = cleanup.read_json(self.small.record_path()) + self.required = 4 * sum(t["bytes"] for t in self.big_record["targets"]) + self.free = self.required - 512 + self.calls = [] + self.real_delete = retirement._delete_manifest + for target, name, value in ( + (retirement, "MIN_FREE_SPACE", 0), + (retirement, "_settings", mock.Mock(return_value={"s3backup": {}})), + (retirement, "_origin_unchanged", mock.Mock()), + (retirement, "_proc_references", mock.Mock()), + (retirement, "_confirm_archive_proof", mock.Mock()), + ): + patcher = mock.patch.object(target, name, value) + patcher.start() + self.addCleanup(patcher.stop) + for name, effect in ( + ("_archive_and_verify", self.archive), + ("_delete_manifest", self.delete), + ): + patcher = mock.patch.object(retirement, name, side_effect=effect) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch.object(retirement.shutil, "disk_usage", + side_effect=lambda _path: SimpleNamespace(free=self.free)) + patcher.start() + self.addCleanup(patcher.stop) + + def proof(self, record): + return { + "sha256": "a" * 64, "size": 99, "file_id": "immutable-" + record["id"], + "bucket_id": "bucket", "bucket": "vault", "endpoint": "https://example", + "provider_fingerprint": "b" * 64, "manifest_sha256": record["manifest_sha256"], + "verified_at": 123, + "object_name": "historical-retirement/" + record["id"] + "/archive.tar.gz.gpg", + } + + def archive(self, record, stopped, targets, runtime, settings): + self.calls.append(record["id"]) + retirement._ensure_staging_headroom(Path(runtime.state_dir), targets) + return self.proof(record) + + def delete(self, target, runtime): + removed = self.real_delete(target, runtime) + self.free += removed + return removed + + def scan(self): + return retirement.scan_retirements(apply=True, runtime=self.big.runtime) + + def test_big_then_small_then_big_completes_in_one_scan(self): + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id, self.big_id]) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["retirements"]), 2) + for fixture in (self.big, self.small): + record = cleanup.read_json(fixture.record_path()) + self.assertEqual(record["status"], "completed") + self.assertEqual(len(record["receipts"]), 3) + self.assertFalse((fixture.stopped / "chain.db").exists()) + self.assertTrue((fixture.stopped / "p2p_identity.key").exists()) + self.assertTrue((fixture.release / "node.previous").exists()) + + def test_no_deletion_means_no_retry(self): + self.small.record_path().unlink() + result = self.scan() + self.assertEqual(self.calls, [self.big_id]) + self.assertEqual(result["status"], "refused") + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_logical_removed_bytes_without_physical_headroom_do_not_retry(self): + retirement._delete_manifest.side_effect = self.real_delete + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id]) + self.assertEqual(result["status"], "refused") + self.assertEqual(cleanup.read_json(self.small.record_path())["status"], "completed") + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_non_typed_headroom_message_is_not_retried(self): + def archive(record, *args): + if record["id"] == self.big_id: + self.calls.append(record["id"]) + raise cleanup.CleanupError("insufficient staging disk headroom for historical archive") + return self.archive(record, *args) + retirement._archive_and_verify.side_effect = archive + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id]) + self.assertEqual(result["status"], "refused") + + def permanent_failure(self, error): + def archive(record, *args): + if record["id"] == self.big_id: + self.calls.append(record["id"]) + raise error + return self.archive(record, *args) + retirement._archive_and_verify.side_effect = archive + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id]) + self.assertEqual(result["status"], "refused") + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_network_failure_not_retried_after_other_copy_frees_space(self): + self.permanent_failure(cleanup.CleanupError("historical archive upload failed")) + + def test_access_failure_not_retried_after_other_copy_frees_space(self): + self.permanent_failure(PermissionError("access denied")) + + def test_two_deferred_copies_retry_smallest_requirement_first(self): + root = self.big.root + import uuid + ids = itertools.count(100) + with mock.patch.object(retirement.uuid, "uuid4", + side_effect=lambda: uuid.UUID(int=next(ids))): + medium = RetirementFixture(root, "medium", b"x" * 3072) + # Place the small successful seed last in the first pass; the deferred + # records must then be reconsidered in ascending staging requirement. + old = self.small.record_path() + record = cleanup.read_json(old) + record["id"] = str(uuid.UUID(int=200)) + cleanup.atomic_json(self.small.state / ("retirement-" + record["id"] + ".json"), record) + old.unlink() + self.small.registration["id"] = record["id"] + self.small_id = record["id"] + medium_id = medium.registration["id"] + self.free = 4 * sum(t["bytes"] for t in cleanup.read_json(medium.record_path())["targets"]) - 512 + result = self.scan() + self.assertEqual(result["status"], "ok") + self.assertEqual(self.calls, [self.big_id, medium_id, self.small_id, medium_id, self.big_id]) + + def test_new_process_reference_on_retry_keeps_copy(self): + def references(_pid, path, *_args, **_kwargs): + if self.small_id in self.calls and str(self.big.stopped) in str(path): + raise cleanup.CleanupError("new process reference") + retirement._proc_references.side_effect = references + result = self.scan() + self.assertEqual(result["status"], "refused") + self.assertEqual(self.calls, [self.big_id, self.small_id]) + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_external_disk_growth_does_not_bypass_reserve_or_loop(self): + def archive(record, *args): + if self.calls.count(self.big_id) == 1 and record["id"] == self.big_id: + self.free = 0 + return self.archive(record, *args) + retirement._archive_and_verify.side_effect = archive + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id, self.big_id]) + self.assertEqual(result["status"], "refused") + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_changed_copy_is_revalidated_before_retry_archive(self): + def delete(target, runtime): + removed = self.delete(target, runtime) + if str(self.small.stopped) in target["path"]: + (self.big.stopped / "chain.db/000001.ldb").write_bytes(b"mutated") + return removed + retirement._delete_manifest.side_effect = delete + result = self.scan() + self.assertEqual(self.calls, [self.big_id, self.small_id]) + self.assertEqual(result["status"], "refused") + self.assertIn("changed", result["retirements"][0]["error"]) + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_preexisting_archive_gets_full_readback_again_not_reuploaded(self): + record = self.big_record + record["archive"] = self.proof(record) + record["status"] = "archived" + cleanup.atomic_json(self.big.record_path(), record) + reads = [] + def confirm(value, settings, runtime, *, readback=True): + if value["id"] == self.big_id and readback: + reads.append(value["archive"]["file_id"]) + retirement._ensure_staging_headroom(Path(runtime.state_dir), value["targets"]) + retirement._confirm_archive_proof.side_effect = confirm + result = self.scan() + self.assertEqual(result["status"], "ok") + self.assertEqual(self.calls, [self.small_id]) + self.assertEqual(reads, ["immutable-" + self.big_id] * 2) + self.assertEqual(cleanup.read_json(self.big.record_path())["archive"], record["archive"]) + + def test_missing_immutable_version_on_retry_keeps_source_and_old_proof(self): + record = self.big_record + record["archive"] = self.proof(record) + record["status"] = "archived" + cleanup.atomic_json(self.big.record_path(), record) + reads = [] + def confirm(value, settings, runtime, *, readback=True): + if value["id"] == self.big_id and readback: + reads.append(value["id"]) + if len(reads) == 1: + retirement._ensure_staging_headroom(Path(runtime.state_dir), value["targets"]) + raise cleanup.CleanupError("verified immutable B2 fileId is no longer present") + retirement._confirm_archive_proof.side_effect = confirm + result = self.scan() + self.assertEqual(result["status"], "refused") + self.assertEqual(reads, [self.big_id] * 2) + self.assertTrue((self.big.stopped / "chain.db").exists()) + self.assertEqual(cleanup.read_json(self.big.record_path())["archive"], record["archive"]) + + def test_durable_partial_deletion_can_free_space_without_retrying_its_error(self): + def confirm(record, settings, runtime, *, readback=True): + if record["id"] == self.small_id and record["receipts"]: + raise cleanup.CleanupError("network failure after first durable deletion") + retirement._confirm_archive_proof.side_effect = confirm + result = self.scan() + self.assertEqual(result["status"], "refused") + self.assertEqual(self.calls, [self.big_id, self.small_id, self.big_id]) + self.assertEqual(cleanup.read_json(self.big.record_path())["status"], "completed") + partial = cleanup.read_json(self.small.record_path()) + self.assertEqual(partial["status"], "archived") + self.assertEqual(len(partial["receipts"]), 1) + self.assertTrue((self.small.stopped / "snapshot-legacy.gz").exists()) + self.assertTrue((self.small.stopped / "p2p_identity.key").exists()) + + def test_unreadable_free_space_does_not_hide_completed_receipts(self): + retirement.shutil.disk_usage.side_effect = OSError("statvfs denied") + retirement._archive_and_verify.side_effect = lambda r, *_args: self.proof(r) + result = self.scan() + self.assertEqual(result["status"], "ok") + self.assertEqual(cleanup.read_json(self.small.record_path())["status"], "completed") + + +@unittest.skipUnless(hasattr(os, "fork"), "requires isolated POSIX workers") +class WorkerInterruptionTests(unittest.TestCase): + """Real worker death/state reopening; external adapters are modeled here. + + deploy/retirement-interruption-lab.py separately exercises the same boundaries + with genuine process checks, statvfs and immutable off-host archive readback. + """ + + setUp = HeadroomRetryTests.setUp + proof = HeadroomRetryTests.proof + archive = HeadroomRetryTests.archive + delete = HeadroomRetryTests.delete + + def trace_path(self): + return self.big.state / "worker-trace.json" + + def spawn(self, fault=None, guard=None): + pid = os.fork() + if pid == 0: + events, deleted, reads = [], [], [] + real_atomic = retirement.atomic_json + real_revalidate = retirement._revalidate_record + + def trace(**values): + real_atomic(self.trace_path(), { + "pid": os.getpid(), "events": events, "deleted": deleted, + "readbacks": reads, **values, + }) + + def commit(path, record): + real_atomic(path, record) + if record.get("id") == self.small_id and ( + fault == "between" and record.get("status") == "completed" + or fault == "partial" and len(record.get("receipts", [])) == 1 + ): + trace(fault=fault) + os.kill(os.getpid(), signal.SIGKILL) + + def revalidate(record, runtime): + events.append("source") + return real_revalidate(record, runtime) + + def origin(*_args, **_kwargs): + events.append("origin") + if guard == "origin": + raise cleanup.CleanupError("changed origin authority") + + def proc(*_args, **_kwargs): + events.append("process") + if guard == "process": + raise cleanup.CleanupError("source is referenced by a process") + + def confirm(record, _settings, runtime, *, readback=True): + events.append("archive-version") + if readback: + reads.append(record["archive"]["file_id"]) + if guard == "archive": + raise cleanup.CleanupError("immutable archive version unavailable") + events.append("space") + retirement._ensure_staging_headroom(Path(runtime.state_dir), record["targets"]) + + def delete(target, runtime): + deleted.append(target["path"]) + return self.delete(target, runtime) + + # Only this unit fixture models physical capacity; the integration + # companion uses an actual limited filesystem and unchanged reserve. + for fixture in (self.big, self.small): + record = cleanup.read_json(fixture.record_path()) + receipted = {r["path"] for r in record["receipts"]} + self.free += sum(t["bytes"] for t in record["targets"] if t["path"] in receipted) + if guard == "space": + self.free = 0 + retirement._origin_unchanged.side_effect = origin + retirement._proc_references.side_effect = proc + retirement._confirm_archive_proof.side_effect = confirm + retirement._delete_manifest.side_effect = delete + try: + with mock.patch.object(retirement, "atomic_json", side_effect=commit), \ + mock.patch.object(retirement, "_revalidate_record", side_effect=revalidate), \ + mock.patch.object(cleanup, "DEFAULT_RUNTIME", self.big.runtime), \ + contextlib.redirect_stdout(io.StringIO()) as output: + code = cli.main(["retirement-scan", "--apply"]) + trace(cli_exit=code, result=json.loads(output.getvalue())) + os._exit(code) + except BaseException: + os._exit(90) + _, status = os.waitpid(pid, 0) + trace = cleanup.read_json(self.trace_path()) + self.assertEqual(trace["pid"], pid) + if fault: + self.assertTrue(os.WIFSIGNALED(status)) + self.assertEqual(os.WTERMSIG(status), signal.SIGKILL) + self.assertEqual(trace["fault"], fault) + else: + self.assertTrue(os.WIFEXITED(status)) + self.assertEqual(os.WEXITSTATUS(status), trace["cli_exit"]) + return trace + + def interrupt_between(self): + record = cleanup.read_json(self.big.record_path()) + record.update(status="archived", archive=self.proof(record)) + cleanup.atomic_json(self.big.record_path(), record) + first = self.spawn(fault="between") + self.assertEqual(cleanup.read_json(self.small.record_path())["status"], "completed") + self.assertEqual(cleanup.read_json(self.big.record_path()), record) + return first + + def test_fresh_worker_restores_durable_proofs_not_in_memory_retry_list(self): + first = self.interrupt_between() + small = self.small.record_path().read_bytes() + large = cleanup.read_json(self.big.record_path()) + second = self.spawn() + self.assertNotEqual(first["pid"], second["pid"]) + self.assertEqual(second["cli_exit"], 0) + self.assertEqual(self.small.record_path().read_bytes(), small) + self.assertTrue(all(str(self.big.stopped) in path for path in second["deleted"])) + self.assertIn(large["archive"]["file_id"], second["readbacks"]) + self.assertEqual(cleanup.read_json(self.big.record_path())["archive"], large["archive"]) + for check in ("source", "origin", "process", "space", "archive-version"): + self.assertIn(check, second["events"]) + third = self.spawn() + self.assertEqual(third["cli_exit"], 0) + self.assertEqual(third["deleted"], []) + self.assertEqual(third["readbacks"], []) + + def test_restarted_worker_rechecks_each_authorization_boundary(self): + self.interrupt_between() + baseline = [f.record_path().read_bytes() for f in (self.big, self.small)] + for guard in ("origin", "process", "space", "archive"): + with self.subTest(guard=guard): + result = self.spawn(guard=guard) + self.assertEqual(result["cli_exit"], 1) + self.assertEqual(result["deleted"], []) + self.assertEqual([f.record_path().read_bytes() for f in (self.big, self.small)], baseline) + + def test_changed_source_after_worker_death_is_retained(self): + self.interrupt_between() + (self.big.stopped / "chain.db/000001.ldb").write_bytes(b"changed fixture") + result = self.spawn() + self.assertEqual(result["cli_exit"], 1) + self.assertEqual(result["deleted"], []) + self.assertTrue((self.big.stopped / "chain.db").exists()) + + def test_partial_receipt_survives_death_and_new_worker_readback(self): + self.spawn(fault="partial") + partial = cleanup.read_json(self.small.record_path()) + self.assertEqual(len(partial["receipts"]), 1) + self.assertEqual(partial["status"], "archived") + refused = self.spawn(guard="archive") + self.assertEqual(refused["cli_exit"], 1) + self.assertTrue(all(str(self.small.stopped) not in path for path in refused["deleted"])) + self.assertEqual(cleanup.read_json(self.small.record_path()), partial) + result = self.spawn() + self.assertEqual(result["cli_exit"], 0) + completed = cleanup.read_json(self.small.record_path()) + self.assertEqual(completed["archive"], partial["archive"]) + self.assertEqual(completed["receipts"][:1], partial["receipts"]) + self.assertNotIn(partial["receipts"][0]["path"], result["deleted"]) + self.assertIn(partial["archive"]["file_id"], result["readbacks"]) + + def test_partial_missing_receipt_never_infers_deleted_target_success(self): + self.spawn(fault="partial") + partial = cleanup.read_json(self.small.record_path()) + partial["receipts"] = [] + cleanup.atomic_json(self.small.record_path(), partial) + result = self.spawn() + small_outcome = next(r for r in result["result"]["retirements"] if r["id"] == self.small_id) + self.assertEqual(small_outcome["status"], "refused") + self.assertIn("disappeared unexpectedly", small_outcome["error"]) + self.assertEqual(cleanup.read_json(self.small.record_path()), partial) + self.assertTrue((self.small.stopped / "snapshot-legacy.gz").exists()) + + class ArchiveValidationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() diff --git a/deploy/test-rollout-cleanup.py b/deploy/test-rollout-cleanup.py index 8b624fd..93063bf 100644 --- a/deploy/test-rollout-cleanup.py +++ b/deploy/test-rollout-cleanup.py @@ -22,6 +22,7 @@ import rollout_cleanup as cleanup # noqa: E402 from rollout_cleanup import cli # noqa: E402 from rollout_cleanup import runtime as cleanup_runtime # noqa: E402 +from rollout_cleanup.transition import reconcile # noqa: E402 def root_stat(real): @@ -661,6 +662,85 @@ def test_restart_backup_pin_publish_dry_run_apply_and_neighbor_preservation(self ) self.assertEqual(cleanup.read_json(next_anchors)["anchors"], []) + def prepare_config_transition(self): + fixture = self.fixture + fixture.begin_and_mark() + previous = fixture.release / "baseline.yaml" + shutil.copyfile(fixture.config, previous) + previous.chmod(0o600) + fixture.config.write_text('{"network":"fixture","authority":"replacement"}') + fixture.current_pid[0] = 202 + fixture.add_process(202) + fixture.height[0] = 105 + fixture.runtime.sleep = lambda _seconds: fixture.height.__setitem__(0, fixture.height[0] + 1) + return (str(previous), cleanup._config_hash(fixture.config), + hashlib.sha256(fixture.installed.read_bytes()).hexdigest()) + + def test_config_transition_preserves_old_origin_and_completes_a_fresh_registration(self): + fixture = self.fixture + args = self.prepare_config_transition() + before = cleanup.read_json(fixture.state / (fixture.job_id + ".json")) + with self.assertRaisesRegex(cleanup.CleanupError, "live config"): + cleanup.complete(fixture.job_id, args[2], runtime=fixture.runtime) + result = reconcile(fixture.job_id, *args, runtime=fixture.runtime) + old = cleanup.read_json(fixture.state / (fixture.job_id + ".json")) + new = cleanup.read_json(fixture.state / (result["replacement_id"] + ".json")) + self.assertEqual(old["origin"], before["origin"]) + self.assertEqual(old["marks"], before["marks"]) + self.assertEqual(old["status"], "superseded") + self.assertEqual(new["status"], "completed") + self.assertEqual(new["origin"]["config_sha256"], args[1]) + self.assertTrue(fixture.candidate.exists()) + self.assertTrue(fixture.stopped.exists()) + self.assertEqual(reconcile(fixture.job_id, *args, runtime=fixture.runtime)["replacement_id"], + result["replacement_id"]) + + def test_config_transition_fails_closed_on_each_invalid_binding(self): + cases = ("old_config", "approved_hash", "binary", "artifact", "chain", "symlink", "permissions") + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp: + self.fixture = LifecycleFixture(Path(tmp)) + fixture = self.fixture + previous, approved, binary = self.prepare_config_transition() + if case == "old_config": + Path(previous).write_text("substituted") + elif case == "approved_hash": + approved = "a" * 64 + elif case == "binary": + binary = "b" * 64 + elif case == "artifact": + fixture.candidate.write_bytes(b"changed") + elif case == "chain": + fixture.genesis[0] = "c" * 64 + elif case == "symlink": + Path(previous).unlink() + Path(previous).symlink_to(fixture.config) + else: + Path(previous).chmod(0o644) + before = (fixture.state / (fixture.job_id + ".json")).read_bytes() + with self.assertRaises(cleanup.CleanupError): + reconcile(fixture.job_id, previous, approved, binary, runtime=fixture.runtime) + self.assertEqual((fixture.state / (fixture.job_id + ".json")).read_bytes(), before) + self.assertTrue(fixture.candidate.exists()) + self.assertEqual(len(list(fixture.state.glob("*.json"))), 1) + + def test_config_transition_resumes_after_interrupted_terminal_write(self): + fixture = self.fixture + args = self.prepare_config_transition() + real_write = cleanup.atomic_json + original_path = fixture.state / (fixture.job_id + ".json") + def interrupted(path, document): + if path == original_path and document.get("status") == "superseded": + raise OSError("simulated power loss") + return real_write(path, document) + with mock.patch.object(cleanup, "atomic_json", side_effect=interrupted): + with self.assertRaises(OSError): + reconcile(fixture.job_id, *args, runtime=fixture.runtime) + self.assertEqual(cleanup.read_json(original_path)["status"], "begun") + self.assertEqual(len(list(fixture.state.glob("*.json"))), 2) + self.assertEqual(reconcile(fixture.job_id, *args, runtime=fixture.runtime)["status"], "superseded") + self.assertEqual(len(list(fixture.state.glob("*.json"))), 2) + def test_missing_stale_and_malformed_receipts_refuse_cleanup(self): fixture = self.fixture fixture.begin_and_mark() diff --git a/deploy/test-sale-upgrade-guard.sh b/deploy/test-sale-upgrade-guard.sh new file mode 100644 index 0000000..fe82b02 --- /dev/null +++ b/deploy/test-sale-upgrade-guard.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +source "$here/sale-upgrade-guard.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +touch "$tmp/config.yaml" +cat > "$tmp/candidate" <<'SH' +#!/usr/bin/env bash +if [[ "$1" == --sale-upgrade-preflight-capability ]]; then + [[ "$2" == --validate-config && "$3" == --config && -f "$4" ]] || exit 19 + printf '%s' '{"schema":1,"command":"sale-upgrade-preflight"}' + exit 0 +fi +[[ "$1" == --sale-upgrade-preflight && "$2" == --config && -f "$3" ]] || exit 19 +printf '%s' "$RECEIPT" +exit "${RESULT:-0}" +SH +chmod 755 "$tmp/candidate" +valid='{"schema":1,"status":"ok","mode":"active","tip_height":42,"tip_hash":"1111111111111111111111111111111111111111111111111111111111111111"}' +for receipt in "$valid" '{"status":"ok"}' 'config OK' '{"schema":true,"status":"ok"}' \ + '{"schema":1,"status":"ok","mode":"active","tip_height":true,"tip_hash":"1111111111111111111111111111111111111111111111111111111111111111"}'; do + export RECEIPT="$receipt" + rm -f "$tmp/stopped" + if sale_upgrade_guard "$tmp/candidate" "$tmp/config.yaml" > /dev/null 2>&1; then + touch "$tmp/stopped" + fi + if [[ "$receipt" == "$valid" ]]; then + test -f "$tmp/stopped" + else + test ! -f "$tmp/stopped" + fi +done +export RECEIPT="$valid" RESULT=77 +if sale_upgrade_guard "$tmp/candidate" "$tmp/config.yaml" >/dev/null 2>&1; then + echo "unexpected checker exit accepted" >&2; exit 1 +fi +cat > "$tmp/legacy-candidate" <<'SH' +#!/usr/bin/env bash +for arg in "$@"; do + if [[ "$arg" == --validate-config ]]; then echo "config OK"; exit 0; fi +done +touch "$LEGACY_STARTED" +exit 0 +SH +chmod 755 "$tmp/legacy-candidate" +export LEGACY_STARTED="$tmp/legacy-started" +if sale_upgrade_guard "$tmp/legacy-candidate" "$tmp/config.yaml" >/dev/null 2>&1; then + echo "legacy candidate accepted" >&2; exit 1 +fi +test ! -e "$LEGACY_STARTED" +python3 - "$here" <<'PY' +from pathlib import Path +import sys +d=Path(sys.argv[1]) +public_paths=[d/'update-node.sh'] +private_paths=[d.parent.parent/'deploy/deploy-server.sh',d.parent.parent/'deploy/deploy.sh'] +for path in public_paths+[path for path in private_paths if path.is_file()]: + text=path.read_text() + calls=[i for i,l in enumerate(text.splitlines()) if 'sale_upgrade_guard ' in l and not l.lstrip().startswith('#')] + stops=[i for i,l in enumerate(text.splitlines()) if 'systemctl stop ' in l and not l.lstrip().startswith('#')] + assert calls and stops and min(calls) "$tmp/bin/sudo" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$SYSTEM_EVENTS" +exit 99 +SH +chmod 755 "$tmp/bin/sudo" +cat > "$tmp/bin/ssh" <<'SH' +#!/usr/bin/env bash +# Model a deploy account permitting only a normal shell, never sudo bash. +[[ "${*: -1}" == "bash -s" ]] || exit 91 +exec bash -s +SH +chmod 755 "$tmp/bin/ssh" +{ declare -f sale_upgrade_guard; cat "$tmp/remote-body.sh"; } > "$tmp/remote-script.sh" +export SYSTEM_EVENTS="$tmp/events" +for receipt in '{"status":"blocked"}' 'unsupported command'; do + export RECEIPT="$receipt" + rm -f "$SYSTEM_EVENTS" + if PATH="$tmp/bin:$PATH" ssh "aperod@restricted-validator" "bash -s" < "$tmp/remote-script.sh" >/dev/null 2>&1; then + echo "remote refusal unexpectedly succeeded" >&2; exit 1 + fi + test ! -e "$SYSTEM_EVENTS" +done +export RECEIPT="$valid" +rm -f "$SYSTEM_EVENTS" +PATH="$tmp/bin:$PATH" ssh "aperod@restricted-validator" "bash -s" < "$tmp/remote-script.sh" >/dev/null 2>&1 || true +grep -q 'systemctl stop' "$SYSTEM_EVENTS" +echo "PASS: each validator refuses before remote stop; aligned control reaches stop" +echo "PASS: strict refusal, success, unexpected exit and pre-stop integration" diff --git a/deploy/test_publish_public_repo.py b/deploy/test_publish_public_repo.py index b86ce39..a1da9ac 100644 --- a/deploy/test_publish_public_repo.py +++ b/deploy/test_publish_public_repo.py @@ -37,12 +37,52 @@ def test_candidate_symlink_cannot_overwrite_external_file(self): def test_build_processes_do_not_receive_publication_credentials(self): with patch.dict(publisher.os.environ, {"PUBLIC_GITHUB_TOKEN": "TEST_ONLY", - "SSH_PASSWORD": "TEST_ONLY"}), \ + "SSH_PASSWORD": "TEST_ONLY", + "PYTHONPATH": "/untrusted/module/path"}), \ patch.object(publisher.subprocess, "run") as child: publisher.run("go", "vet", "./...") env = child.call_args.kwargs["env"] self.assertNotIn("PUBLIC_GITHUB_TOKEN", env) self.assertNotIn("SSH_PASSWORD", env) + self.assertNotIn("PYTHONPATH", env) + + def test_go_tests_receive_only_the_pyyaml_userbase(self): + python_userbase = Path("/trusted/python-userbase") + with patch.dict(publisher.os.environ, {"PUBLIC_GITHUB_TOKEN": "TEST_ONLY", + "SSH_PASSWORD": "TEST_ONLY", + "PYTHONPATH": "/untrusted/module/path"}), \ + patch.object(publisher.subprocess, "run") as child: + publisher.run("go", "test", "./...", + test_python_userbase=python_userbase) + env = child.call_args.kwargs["env"] + self.assertEqual(env["PYTHONUSERBASE"], str(python_userbase)) + self.assertNotIn("PYTHONPATH", env) + self.assertNotIn("PUBLIC_GITHUB_TOKEN", env) + self.assertNotIn("SSH_PASSWORD", env) + + def test_test_python_userbase_copies_only_trusted_pyyaml(self): + with tempfile.TemporaryDirectory() as temp: + base = Path(temp) / "installed" + package = base / "yaml" + package.mkdir(parents=True) + (package / "__init__.py").write_text("SOURCE = 'installed'\n") + (package / "scanner.py").write_text("SAFE = True\n") + userbase = Path(temp) / "isolated" + fake_distribution = Mock() + fake_distribution.locate_file.side_effect = ( + lambda rel: base if str(rel) == "" else package + ) + with patch.object(publisher, "distribution", + return_value=fake_distribution): + publisher.prepare_test_python_userbase(userbase) + target = ( + userbase / "lib" + / f"python{publisher.sys.version_info.major}.{publisher.sys.version_info.minor}" + / "site-packages" / "yaml" + ) + self.assertEqual((target / "__init__.py").read_text(), + "SOURCE = 'installed'\n") + self.assertTrue((target / "scanner.py").is_file()) def test_failed_required_check_never_merges(self): methods = [] diff --git a/deploy/test_rollout_cli_redaction.py b/deploy/test_rollout_cli_redaction.py new file mode 100644 index 0000000..35ef128 --- /dev/null +++ b/deploy/test_rollout_cli_redaction.py @@ -0,0 +1,46 @@ +import contextlib +import io +import json +import unittest +from unittest.mock import patch + +from rollout_cleanup import cli + + +class RolloutCliRedactionTests(unittest.TestCase): + def test_sensitive_fields_and_inline_credentials_are_not_written(self): + canary = "test-only-not-a-real-secret" + payload = { + "status": "ok", + "password": canary, + "dbPassword": canary, + "walletMnemonicPhrase": "alpha beta gamma", + "nested": { + "api_key": canary, + "message": f"request failed token={canary}; mnemonic phrase: alpha beta gamma", + "url": "https://operator:secret@example.invalid/path", + "safe": "read-only", + }, + } + output = io.StringIO() + with patch.object(cli, "_dispatch", return_value=payload): + with contextlib.redirect_stdout(output): + status = cli.main(["scan", "--dry-run"]) + + self.assertEqual(status, 0) + rendered = output.getvalue() + self.assertNotIn(canary, rendered) + parsed = json.loads(rendered) + self.assertEqual(parsed["status"], "ok") + self.assertEqual(parsed["nested"]["safe"], "read-only") + self.assertNotIn("password", parsed) + self.assertNotIn("dbPassword", parsed) + self.assertNotIn("walletMnemonicPhrase", parsed) + self.assertNotIn("api_key", parsed["nested"]) + self.assertIn("[REDACTED]", parsed["nested"]["message"]) + self.assertNotIn("alpha beta gamma", parsed["nested"]["message"]) + self.assertNotIn("operator:secret@", parsed["nested"]["url"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/update-node.sh b/deploy/update-node.sh index 5075fd5..928215f 100755 --- a/deploy/update-node.sh +++ b/deploy/update-node.sh @@ -913,6 +913,8 @@ node_source_baseline_guard "${BINARY_DST}" || { echo "✗ Running-binary approval changed during the build — service NOT stopped." >&2 exit 1 } +source "${DEPLOY_DIR}/sale-upgrade-guard.sh" +sale_upgrade_guard "${BINARY_SRC}" "${APEROD_CONFIG_FILE:-/etc/aperod/node.yaml}" || 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 diff --git a/deploy/update-validator.sh b/deploy/update-validator.sh index 0785598..f31a450 100755 --- a/deploy/update-validator.sh +++ b/deploy/update-validator.sh @@ -243,6 +243,7 @@ echo "" # Process each validator. # --------------------------------------------------------------------------- FAILED=() +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/sale-upgrade-guard.sh" for TARGET in "${VALIDATORS[@]}"; do # Normalise: if no '@' in target, prepend the default SSH user. @@ -286,6 +287,10 @@ HEALTH_WAIT_SECS="__HEALTH_WAIT_SECS__" SKIP_HEALTH_CHECK="__SKIP_HEALTH_CHECK__" HEALTH_URL="http://127.0.0.1:8545/api/v1/status" +echo " [preflight] Checking this validator's own completed copy and signed configuration..." +# Run as the node's account (the documented default SSH_USER=aperod). Its +# config and private socket are owned by that account. No root shell required. +sale_upgrade_guard "${BINARY_SRC}" "__NODE_CONFIG__" || exit 1 echo " [2/5] Stopping ${SERVICE_NAME}..." sudo systemctl stop "${SERVICE_NAME}" || true sleep 1 @@ -329,6 +334,9 @@ REMOTE_EOF ) # Substitute the configuration values into the remote script. + REMOTE_SCRIPT="$(declare -f sale_upgrade_guard) +${REMOTE_SCRIPT}" + REMOTE_SCRIPT="${REMOTE_SCRIPT//__NODE_CONFIG__/${APEROD_CONFIG_FILE:-/etc/aperod/node.yaml}}" REMOTE_SCRIPT="${REMOTE_SCRIPT//__BINARY_DST__/${BINARY_DST}}" REMOTE_SCRIPT="${REMOTE_SCRIPT//__SERVICE_NAME__/${SERVICE_NAME}}" REMOTE_SCRIPT="${REMOTE_SCRIPT//__HEALTH_MAX_ATTEMPTS__/${HEALTH_MAX_ATTEMPTS}}" diff --git a/explorer/indexer.go b/explorer/indexer.go index ff43d43..95544ab 100644 --- a/explorer/indexer.go +++ b/explorer/indexer.go @@ -288,7 +288,7 @@ func (idx *Indexer) IndexBlock(block BlockData, txs []TxData, addr []AddrTxData) INSERT INTO blocks (height, hash, prev_hash, timestamp_ns, validator_pub, merkle_root, tx_count) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (height) DO NOTHING`, - blockHeight, block.Hash, block.PrevHash, + blockHeight, block.Hash, block.PrevHash, block.TimestampNs, block.ValidatorPub, block.MerkleRoot, block.TxCount, ) if err != nil { @@ -313,8 +313,8 @@ func (idx *Indexer) IndexBlock(block BlockData, txs []TxData, addr []AddrTxData) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (hash) DO NOTHING RETURNING 1`, - tx.Hash, tx.BlockHash, txBlockHeight, tx.TxIndex, - tx.IsCoinbase, tx.Inputs, tx.Outputs, txFee, tx.SizeBytes, tx.Version, + tx.Hash, tx.BlockHash, txBlockHeight, tx.TxIndex, + tx.IsCoinbase, tx.Inputs, tx.Outputs, txFee, tx.SizeBytes, tx.Version, tx.IsBurn, tx.BurnedNAPRO, tx.BurnAddress, ).Scan(&inserted) if err == nil { @@ -346,7 +346,7 @@ func (idx *Indexer) IndexBlock(block BlockData, txs []TxData, addr []AddrTxData) INSERT INTO address_txs (address, tx_hash, block_height, tx_index, output_index) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (address, tx_hash, block_height, tx_index, output_index) DO NOTHING`, - a.Address, a.TxHash, addressBlockHeight, a.TxIndex, a.OutputIndex, + a.Address, a.TxHash, addressBlockHeight, a.TxIndex, a.OutputIndex, ) if err != nil { return fmt.Errorf("indexer: insert addr_tx: %w", err) @@ -362,7 +362,7 @@ func (idx *Indexer) IndexBlock(block BlockData, txs []TxData, addr []AddrTxData) total_txs = total_txs + $2, updated_at = NOW() WHERE id = 1`, - blockHeight, newlyInsertedTxs, + blockHeight, newlyInsertedTxs, ) if err != nil { return fmt.Errorf("indexer: update stats: %w", err) diff --git a/explorer/indexer_checked_int_test.go b/explorer/indexer_checked_int_test.go index 62f2d07..4e98b6e 100644 --- a/explorer/indexer_checked_int_test.go +++ b/explorer/indexer_checked_int_test.go @@ -3,14 +3,14 @@ package explorer import "testing" func TestCheckedInt64FromUint64(t *testing.T) { -const max = uint64(1<<63 - 1) + const max = uint64(1<<63 - 1) -got, err := checkedInt64FromUint64("height", max) -if err != nil || got != int64(max) { -t.Fatalf("max signed value: got %d, err %v", got, err) -} + got, err := checkedInt64FromUint64("height", max) + if err != nil || got != int64(max) { + t.Fatalf("max signed value: got %d, err %v", got, err) + } -if _, err := checkedInt64FromUint64("height", max+1); err == nil { -t.Fatal("expected values above PostgreSQL BIGINT range to be rejected") -} + if _, err := checkedInt64FromUint64("height", max+1); err == nil { + t.Fatal("expected values above PostgreSQL BIGINT range to be rejected") + } } diff --git a/go.mod b/go.mod index 512266e..24ccac0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/lib/pq v1.12.3 github.com/spf13/cobra v1.8.1 github.com/syndtr/goleveldb v1.0.0 + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 + github.com/tetratelabs/wazero v1.12.0 golang.org/x/net v0.60.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 @@ -18,6 +20,4 @@ require ( github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect - github.com/tetratelabs/wazero v1.12.0 // indirect ) diff --git a/go.sum b/go.sum index d8d46bf..4ae01c2 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= @@ -31,11 +33,15 @@ github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= @@ -47,26 +53,19 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= diff --git a/node/node.go b/node/node.go index 91ab326..f7d8538 100644 --- a/node/node.go +++ b/node/node.go @@ -47,6 +47,7 @@ type Config struct { BlockTime time.Duration APIAddr string APIEnabled bool +StakedCollateralRingExclusionActivationHeight uint64 } // New creates a fully initialized (but not yet started) node. @@ -66,7 +67,9 @@ func New(cfg *Config, log *slog.Logger) (*Node, error) { utxos.OnUTXODeleted = func(txHash crypto.Hash32, outIdx uint32) error { return db.DeleteUTXO(txHash, outIdx) } - mempool := core.NewMempool(core.DefaultMempoolConfig()) +mempoolCfg := core.DefaultMempoolConfig() +mempoolCfg.CurrentHeight = chain.Height +mempool := core.NewMempool(mempoolCfg) // ── Genesis ─────────────────────────────────────────────────────────────── genesisCfg, err := core.LoadGenesis(cfg.GenesisFile) @@ -123,6 +126,7 @@ func New(cfg *Config, log *slog.Logger) (*Node, error) { // ── Block/Tx verifiers ──────────────────────────────────────────────────── txV := core.NewTxVerifier(utxos) +txV.SetStakedCollateralRingExclusionActivationHeight(cfg.StakedCollateralRingExclusionActivationHeight) // Wire vesting enforcement using the actual genesis block timestamp. // chain.Genesis() is always non-nil here because both fresh-start and resume // paths above call chain.SetGenesis before this point. diff --git a/p2p/badblock_ban_test.go b/p2p/badblock_ban_test.go index 6d485fe..49c7cf1 100644 --- a/p2p/badblock_ban_test.go +++ b/p2p/badblock_ban_test.go @@ -1832,6 +1832,7 @@ func TestBadBlockBan_TLSAuthenticatedPeer(t *testing.T) { if err != nil { t.Fatalf("generate peer TLS config: %v", err) } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -1853,7 +1854,6 @@ func TestBadBlockBan_TLSAuthenticatedPeer(t *testing.T) { // ── Step 2: TLS dial + Aperod Ping/Pong handshake ───────────────────────── - cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) tlsConn, err := tls.DialWithDialer( &net.Dialer{Timeout: 2 * time.Second}, "tcp", hostAddr, cfgPeer, @@ -2032,6 +2032,7 @@ func TestBadBlockBan_TLSAllConnectionsEvicted(t *testing.T) { if err != nil { t.Fatalf("generate peer TLS config: %v", err) } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -2053,7 +2054,6 @@ func TestBadBlockBan_TLSAllConnectionsEvicted(t *testing.T) { // dialTLS opens a TLS connection, completes the Aperod Ping/Pong handshake, // and returns the open *tls.Conn. - cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) dialTLS := func(nodeID string) *tls.Conn { t.Helper() c, err := tls.DialWithDialer( @@ -2165,6 +2165,7 @@ func TestBadBlockBan_TLSWhitelistedPeerNotBanned(t *testing.T) { if err != nil { t.Fatalf("generate peer TLS config: %v", err) } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -2188,7 +2189,6 @@ func TestBadBlockBan_TLSWhitelistedPeerNotBanned(t *testing.T) { // ── Step 2: TLS dial + Aperod Ping/Pong handshake ──────────────────────── - cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) tlsConn, err := tls.DialWithDialer( &net.Dialer{Timeout: 2 * time.Second}, "tcp", hostAddr, cfgPeer, @@ -2305,6 +2305,7 @@ func TestSlowHandshakeFlood(t *testing.T) { if err != nil { t.Fatalf("generate peer TLS config: %v", err) } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -2321,7 +2322,6 @@ func TestSlowHandshakeFlood(t *testing.T) { defer h.Stop() hostAddr := h.ListenAddr() - cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) // ── Step 1: open maxPending raw TCP connections — hold all pending slots ── // The host wraps the TCP listener with tls.NewListener, so each accepted @@ -2489,6 +2489,7 @@ func TestConcurrentHandshakeFlood(t *testing.T) { if err != nil { t.Fatalf("generate peer TLS config: %v", err) } + cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -2505,7 +2506,6 @@ func TestConcurrentHandshakeFlood(t *testing.T) { defer h.Stop() hostAddr := h.ListenAddr() - cfgPeer = pinTLSConfig(t, cfgPeer, fpHost) // concurrentDial launches n goroutines that all dial hostAddr at the same // instant. Each goroutine signals readyCh when it is blocked at the start diff --git a/p2p/host.go b/p2p/host.go index df2297b..ad3f4dd 100644 --- a/p2p/host.go +++ b/p2p/host.go @@ -39,11 +39,11 @@ type Config struct { MinOutbound int NodeID string // hex-encoded public key or random ID UserAgent string -// TLSConfig enables authenticated encrypted transport. + // TLSConfig enables authenticated encrypted transport. // When non-nil, the listener is wrapped with tls.NewListener and // outbound dials use tls.DialWithDialer. Both sides must present a -// certificate; AllowedPeers is mandatory and each peer fingerprint is -// checked during the TLS handshake. + // certificate; AllowedPeers is mandatory and each peer fingerprint is + // checked during the TLS handshake. // nil = plain TCP (unit tests only — never use nil in production). TLSConfig *tls.Config // SelfFingerprint is the hex-encoded SHA-256 SPKI fingerprint of this @@ -54,10 +54,10 @@ type Config struct { // present identical certificates and silently fail to peer. // Set by main.go from the fingerprint returned by LoadOrSaveP2PIdentity. SelfFingerprint string -// AllowedPeers is the required list of hex-encoded SHA-256 SPKI -// fingerprints permitted to connect when TLSConfig is enabled. -// The list is enforced during the TLS handshake; an empty or malformed -// list prevents Start from opening the listener. + // AllowedPeers is the required list of hex-encoded SHA-256 SPKI + // fingerprints permitted to connect when TLSConfig is enabled. + // The list is enforced during the TLS handshake; an empty or malformed + // list prevents Start from opening the listener. AllowedPeers []string // PeerWhitelist is an optional list of IP addresses and/or CIDR // ranges (e.g. "1.2.3.4", "10.0.0.0/8") that are allowed to @@ -1520,13 +1520,13 @@ func (h *Host) banTxFlooder(peerIP string, peer *Peer, violations int) { // Start binds the listener and begins accepting connections. func (h *Host) Start() error { -if h.cfg.TLSConfig != nil { -pinnedConfig, err := TLSConfigWithAllowedPeerFingerprints(h.cfg.TLSConfig, h.cfg.AllowedPeers) -if err != nil { -return fmt.Errorf("p2p TLS peer verification: %w", err) -} -h.cfg.TLSConfig = pinnedConfig -} + if h.cfg.TLSConfig != nil { + pinnedConfig, err := TLSConfigWithAllowedPeerFingerprints(h.cfg.TLSConfig, h.cfg.AllowedPeers) + if err != nil { + return fmt.Errorf("p2p TLS peer verification: %w", err) + } + h.cfg.TLSConfig = pinnedConfig + } // Restore persisted bans from disk before accepting any connections so // previously-banned peers are blocked immediately on restart — without @@ -2726,12 +2726,12 @@ func (h *Host) handleConn(conn net.Conn, outbound bool, dialID, pendingID uint64 return } -// Defense in depth: the same allow-list is enforced in VerifyConnection -// during the TLS handshake, before application traffic is accepted. -if h.cfg.TLSConfig != nil { + // Defense in depth: the same allow-list is enforced in VerifyConnection + // during the TLS handshake, before application traffic is accepted. + if h.cfg.TLSConfig != nil { allowed := false for _, a := range h.cfg.AllowedPeers { -if strings.EqualFold(strings.TrimSpace(a), fp) { + if strings.EqualFold(strings.TrimSpace(a), fp) { allowed = true break } diff --git a/p2p/security_test.go b/p2p/security_test.go index d44d422..f4927ef 100644 --- a/p2p/security_test.go +++ b/p2p/security_test.go @@ -10,19 +10,19 @@ package p2p_test // 3.5.6 Peer count limit: MaxPeers=1 causes the second connection to be dropped. import ( - "bytes" - "crypto/tls" - "encoding/json" - "log/slog" - "net" - "strings" - "sync" - "testing" - "time" - - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/p2p" + "bytes" + "crypto/tls" + "encoding/json" + "log/slog" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/p2p" ) // ─── 3.5.1 Eclipse attack ──────────────────────────────────────────────────── @@ -30,80 +30,80 @@ import ( // TestEclipseAttack_MaxPeers verifies that once MaxPeers slots are filled, // additional inbound connections are rejected (no eclipse attack surface). func TestEclipseAttack_MaxPeers(t *testing.T) { - h2 := &stubHandler{} - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 2, - NodeID: "eclipse-host", - UserAgent: "aperod/test", - }, h2, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - addr := host.ListenAddr() - - // Establish MaxPeers (2) legitimate connections. - var conns []net.Conn - for i := 0; i < 2; i++ { - c, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - t.Fatalf("dial %d: %v", i, err) - } - c.SetDeadline(time.Now().Add(2 * time.Second)) - // Asymmetric handshake: dialer sends Ping, host replies with Pong. - if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ - NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - c.Close() - continue - } - if _, _, err := p2p.ReadMsg(c); err != nil { - c.Close() - continue - } - conns = append(conns, c) - } - defer func() { - for _, c := range conns { - c.Close() - } - }() - - time.Sleep(60 * time.Millisecond) - - // A 3rd attacker connection should be rejected or dropped promptly. - attacker, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - // Connection refused means MaxPeers fully enforced — pass. - t.Log("3.5.1 ✓ 3rd connection refused by OS (MaxPeers enforced)") - return - } - defer attacker.Close() - attacker.SetDeadline(time.Now().Add(500 * time.Millisecond)) - - // Try to handshake — host may drop the conn immediately if MaxPeers exceeded. - if err := p2p.WriteMsg(attacker, p2p.MsgPing, p2p.PingMsg{ - NodeID: "attacker", Height: 0, UserAgent: "evil", Timestamp: time.Now().Unix(), - }); err != nil { - t.Log("3.5.1 ✓ 3rd connection dropped during handshake (MaxPeers enforced)") - return - } - msgType, _, err := p2p.ReadMsg(attacker) - if err != nil { - // Connection closed before host replied with Pong — MaxPeers enforced. - t.Log("3.5.1 ✓ 3rd connection dropped during/before handshake (MaxPeers enforced)") - return - } - _ = msgType - time.Sleep(80 * time.Millisecond) - - // Verify total peer count hasn't exceeded MaxPeers. - if host.PeerCount() > 2 { - t.Errorf("3.5.1: peer count %d exceeds MaxPeers=2 — eclipse vulnerability", host.PeerCount()) - } else { - t.Logf("3.5.1 ✓ peer count=%d ≤ MaxPeers=2 after 3 connect attempts", host.PeerCount()) - } + h2 := &stubHandler{} + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 2, + NodeID: "eclipse-host", + UserAgent: "aperod/test", + }, h2, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + addr := host.ListenAddr() + + // Establish MaxPeers (2) legitimate connections. + var conns []net.Conn + for i := 0; i < 2; i++ { + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + c.SetDeadline(time.Now().Add(2 * time.Second)) + // Asymmetric handshake: dialer sends Ping, host replies with Pong. + if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ + NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + c.Close() + continue + } + if _, _, err := p2p.ReadMsg(c); err != nil { + c.Close() + continue + } + conns = append(conns, c) + } + defer func() { + for _, c := range conns { + c.Close() + } + }() + + time.Sleep(60 * time.Millisecond) + + // A 3rd attacker connection should be rejected or dropped promptly. + attacker, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + // Connection refused means MaxPeers fully enforced — pass. + t.Log("3.5.1 ✓ 3rd connection refused by OS (MaxPeers enforced)") + return + } + defer attacker.Close() + attacker.SetDeadline(time.Now().Add(500 * time.Millisecond)) + + // Try to handshake — host may drop the conn immediately if MaxPeers exceeded. + if err := p2p.WriteMsg(attacker, p2p.MsgPing, p2p.PingMsg{ + NodeID: "attacker", Height: 0, UserAgent: "evil", Timestamp: time.Now().Unix(), + }); err != nil { + t.Log("3.5.1 ✓ 3rd connection dropped during handshake (MaxPeers enforced)") + return + } + msgType, _, err := p2p.ReadMsg(attacker) + if err != nil { + // Connection closed before host replied with Pong — MaxPeers enforced. + t.Log("3.5.1 ✓ 3rd connection dropped during/before handshake (MaxPeers enforced)") + return + } + _ = msgType + time.Sleep(80 * time.Millisecond) + + // Verify total peer count hasn't exceeded MaxPeers. + if host.PeerCount() > 2 { + t.Errorf("3.5.1: peer count %d exceeds MaxPeers=2 — eclipse vulnerability", host.PeerCount()) + } else { + t.Logf("3.5.1 ✓ peer count=%d ≤ MaxPeers=2 after 3 connect attempts", host.PeerCount()) + } } // ─── 3.5.2 Sybil resistance ────────────────────────────────────────────────── @@ -111,26 +111,26 @@ func TestEclipseAttack_MaxPeers(t *testing.T) { // TestSybilResistance_BanFlood verifies the PeerMgr ban list scales to 100+ // addresses without memory corruption or data loss. func TestSybilResistance_BanFlood(t *testing.T) { - // The ban list is part of the Host's BanPeer method; test via PeerMgr-like - // logic by using a Host as the entry point. - h := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "sybil-host", - UserAgent: "aperod/test", - }, &stubHandler{}, newTestLogger()) - if err := h.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(h.Stop) - - const n = 100 - for i := 0; i < n; i++ { - addr := net.JoinHostPort("192.168.100."+string(rune('0'+i%10)), "9000") - h.BanPeer(addr, "sybil flood test", time.Hour) - } - - t.Logf("3.5.2 ✓ BanPeer accepted %d sybil addresses without panic", n) + // The ban list is part of the Host's BanPeer method; test via PeerMgr-like + // logic by using a Host as the entry point. + h := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "sybil-host", + UserAgent: "aperod/test", + }, &stubHandler{}, newTestLogger()) + if err := h.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(h.Stop) + + const n = 100 + for i := 0; i < n; i++ { + addr := net.JoinHostPort("192.168.100."+string(rune('0'+i%10)), "9000") + h.BanPeer(addr, "sybil flood test", time.Hour) + } + + t.Logf("3.5.2 ✓ BanPeer accepted %d sybil addresses without panic", n) } // ─── 3.5.3 Replay attack ───────────────────────────────────────────────────── @@ -138,45 +138,45 @@ func TestSybilResistance_BanFlood(t *testing.T) { // TestReplayAttack_GossipDedup verifies that the gossip filter suppresses // relay of a block that has already been processed (replay protection). func TestReplayAttack_GossipDedup(t *testing.T) { - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 5, - NodeID: "replay-host", - UserAgent: "aperod/test", - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - // Build a dummy block. - valPriv, valPub, _ := crypto.GenerateValidatorKey() - alice, _ := crypto.GenerateWalletKeys() - cb := core.CoinbaseTx(alice.Spend.Public, 50_000_000) - txs := []core.Transaction{cb} - hdr := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: valPub, - MerkleRoot: core.MerkleRoot(txs), - } - _ = hdr.Sign(valPriv) - block := &core.Block{Header: hdr, Txs: txs} - - // Use the Gossip layer to test dedup (RelayBlock is on *Gossip, not *Host). - gossip := p2p.NewGossip(host) - - // First relay: must return true (block is new). - if !gossip.RelayBlock(block, "peer-1") { - t.Error("3.5.3: first relay should return true (block is new)") - } - - // Second relay of same block: must return false (replay suppressed). - if gossip.RelayBlock(block, "peer-1") { - t.Error("3.5.3: second relay should return false (gossip dedup active)") - } - - t.Log("3.5.3 ✓ gossip dedup correctly suppresses block replay") + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 5, + NodeID: "replay-host", + UserAgent: "aperod/test", + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + // Build a dummy block. + valPriv, valPub, _ := crypto.GenerateValidatorKey() + alice, _ := crypto.GenerateWalletKeys() + cb := core.CoinbaseTx(alice.Spend.Public, 50_000_000) + txs := []core.Transaction{cb} + hdr := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: valPub, + MerkleRoot: core.MerkleRoot(txs), + } + _ = hdr.Sign(valPriv) + block := &core.Block{Header: hdr, Txs: txs} + + // Use the Gossip layer to test dedup (RelayBlock is on *Gossip, not *Host). + gossip := p2p.NewGossip(host) + + // First relay: must return true (block is new). + if !gossip.RelayBlock(block, "peer-1") { + t.Error("3.5.3: first relay should return true (block is new)") + } + + // Second relay of same block: must return false (replay suppressed). + if gossip.RelayBlock(block, "peer-1") { + t.Error("3.5.3: second relay should return false (gossip dedup active)") + } + + t.Log("3.5.3 ✓ gossip dedup correctly suppresses block replay") } // ─── 3.5.4 Rate limiting / banned peer reconnect ───────────────────────────── @@ -184,55 +184,55 @@ func TestReplayAttack_GossipDedup(t *testing.T) { // TestRateLimiting_BannedPeerDropped verifies that a peer banned for bad // behaviour is rejected during the handshake phase. func TestRateLimiting_BannedPeerDropped(t *testing.T) { - handler := &stubHandler{} - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "ratelimit-host", - UserAgent: "aperod/test", - }, handler, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - addr := host.ListenAddr() - - // Connect once and complete the handshake (asymmetric: dialer sends Ping, host replies Pong). - conn, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - t.Fatalf("dial: %v", err) - } - conn.SetDeadline(time.Now().Add(2 * time.Second)) - if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "bad-peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - conn.Close() - t.Fatalf("write ping: %v", err) - } - if _, _, err := p2p.ReadMsg(conn); err != nil { - conn.Close() - t.Fatalf("read pong: %v", err) - } - time.Sleep(50 * time.Millisecond) - - // Ban the peer address. - host.BanPeer(conn.LocalAddr().String(), "bad behaviour", time.Hour) - conn.Close() - - // A subsequent connection from the same address should be handled; the ban - // is checked at dial/accept time. This test verifies BanPeer does not panic - // and the host continues operating correctly. - conn2, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - t.Log("3.5.4 ✓ reconnect from banned address refused at OS level") - return - } - defer conn2.Close() - // Ban check runs before the Ping wait; the host closes the conn immediately. - conn2.SetDeadline(time.Now().Add(500 * time.Millisecond)) - _, _, _ = p2p.ReadMsg(conn2) // expect EOF / connection closed - - t.Log("3.5.4 ✓ ban registered; host operational after ban") + handler := &stubHandler{} + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "ratelimit-host", + UserAgent: "aperod/test", + }, handler, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + addr := host.ListenAddr() + + // Connect once and complete the handshake (asymmetric: dialer sends Ping, host replies Pong). + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn.SetDeadline(time.Now().Add(2 * time.Second)) + if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "bad-peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + conn.Close() + t.Fatalf("write ping: %v", err) + } + if _, _, err := p2p.ReadMsg(conn); err != nil { + conn.Close() + t.Fatalf("read pong: %v", err) + } + time.Sleep(50 * time.Millisecond) + + // Ban the peer address. + host.BanPeer(conn.LocalAddr().String(), "bad behaviour", time.Hour) + conn.Close() + + // A subsequent connection from the same address should be handled; the ban + // is checked at dial/accept time. This test verifies BanPeer does not panic + // and the host continues operating correctly. + conn2, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Log("3.5.4 ✓ reconnect from banned address refused at OS level") + return + } + defer conn2.Close() + // Ban check runs before the Ping wait; the host closes the conn immediately. + conn2.SetDeadline(time.Now().Add(500 * time.Millisecond)) + _, _, _ = p2p.ReadMsg(conn2) // expect EOF / connection closed + + t.Log("3.5.4 ✓ ban registered; host operational after ban") } // ─── 3.5.5 Message validation: oversized payload ───────────────────────────── @@ -241,30 +241,30 @@ func TestRateLimiting_BannedPeerDropped(t *testing.T) { // message either causes the connection to be dropped by the host or // the error is surfaced — the host must remain stable. func TestMsgValidation_OversizedBlock(t *testing.T) { - host, _, addr := startHost(t) - - connectAndHandshake(t, addr, func(conn net.Conn) { - conn.SetDeadline(time.Now().Add(2 * time.Second)) - - // Build a block payload with an unrealistically large Txs slice. - type fakeBlock struct { - Payload []byte `json:"payload"` - } - // 4 MB payload — well above any reasonable block size. - oversized := fakeBlock{Payload: make([]byte, 4*1024*1024)} - data, _ := json.Marshal(oversized) - - // Send as MsgBlock — host may reject or close, but must not crash. - err := p2p.WriteMsg(conn, p2p.MsgBlock, json.RawMessage(data)) - if err != nil { - t.Logf("3.5.5: write error (connection pre-emptively closed): %v", err) - return - } - - // Give the host a moment to process and optionally close the connection. - time.Sleep(100 * time.Millisecond) - t.Logf("3.5.5 ✓ host stable after receiving oversized block payload (peers=%d)", host.PeerCount()) - }) + host, _, addr := startHost(t) + + connectAndHandshake(t, addr, func(conn net.Conn) { + conn.SetDeadline(time.Now().Add(2 * time.Second)) + + // Build a block payload with an unrealistically large Txs slice. + type fakeBlock struct { + Payload []byte `json:"payload"` + } + // 4 MB payload — well above any reasonable block size. + oversized := fakeBlock{Payload: make([]byte, 4*1024*1024)} + data, _ := json.Marshal(oversized) + + // Send as MsgBlock — host may reject or close, but must not crash. + err := p2p.WriteMsg(conn, p2p.MsgBlock, json.RawMessage(data)) + if err != nil { + t.Logf("3.5.5: write error (connection pre-emptively closed): %v", err) + return + } + + // Give the host a moment to process and optionally close the connection. + time.Sleep(100 * time.Millisecond) + t.Logf("3.5.5 ✓ host stable after receiving oversized block payload (peers=%d)", host.PeerCount()) + }) } // ─── 3.5.6 Peer count limit enforced ───────────────────────────────────────── @@ -272,56 +272,56 @@ func TestMsgValidation_OversizedBlock(t *testing.T) { // TestPeerCountLimit_MaxPeers1 starts a host with MaxPeers=1 and verifies // that exactly one peer occupies the slot, keeping total ≤ MaxPeers. func TestPeerCountLimit_MaxPeers1(t *testing.T) { - h2 := &stubHandler{} - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 1, - NodeID: "limit-host", - UserAgent: "aperod/test", - }, h2, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - addr := host.ListenAddr() - - handshake := func(id string) (net.Conn, bool) { - c, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - return nil, false - } - c.SetDeadline(time.Now().Add(2 * time.Second)) - // Asymmetric handshake: dialer sends Ping, host replies with Pong. - if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ - NodeID: id, Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - c.Close() - return nil, false - } - if _, _, err := p2p.ReadMsg(c); err != nil { - c.Close() - return nil, false - } - return c, true - } - - c1, ok1 := handshake("peer1") - if ok1 { - defer c1.Close() - } - time.Sleep(60 * time.Millisecond) - - c2, ok2 := handshake("peer2") - if ok2 { - defer c2.Close() - } - time.Sleep(80 * time.Millisecond) - - if host.PeerCount() > 1 { - t.Errorf("3.5.6: peer count %d exceeds MaxPeers=1", host.PeerCount()) - } else { - t.Logf("3.5.6 ✓ peer count=%d ≤ MaxPeers=1", host.PeerCount()) - } + h2 := &stubHandler{} + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 1, + NodeID: "limit-host", + UserAgent: "aperod/test", + }, h2, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + addr := host.ListenAddr() + + handshake := func(id string) (net.Conn, bool) { + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + return nil, false + } + c.SetDeadline(time.Now().Add(2 * time.Second)) + // Asymmetric handshake: dialer sends Ping, host replies with Pong. + if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ + NodeID: id, Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + c.Close() + return nil, false + } + if _, _, err := p2p.ReadMsg(c); err != nil { + c.Close() + return nil, false + } + return c, true + } + + c1, ok1 := handshake("peer1") + if ok1 { + defer c1.Close() + } + time.Sleep(60 * time.Millisecond) + + c2, ok2 := handshake("peer2") + if ok2 { + defer c2.Close() + } + time.Sleep(80 * time.Millisecond) + + if host.PeerCount() > 1 { + t.Errorf("3.5.6: peer count %d exceeds MaxPeers=1", host.PeerCount()) + } else { + t.Logf("3.5.6 ✓ peer count=%d ≤ MaxPeers=1", host.PeerCount()) + } } // ─── 3.5.7 Malicious packet — node stays alive (#414) ──────────────────────── @@ -330,39 +330,39 @@ func TestPeerCountLimit_MaxPeers1(t *testing.T) { // broken (non-JSON, binary-garbage) message body does not crash the host process. // The defer/recover in handleConn should catch the panic and drop only the peer. func TestMaliciousPacket_NodeStaysAlive(t *testing.T) { - host, _, addr := startHost(t) - - connectAndHandshake(t, addr, func(conn net.Conn) { - conn.SetDeadline(time.Now().Add(2 * time.Second)) - - // Craft a raw TCP frame that passes the length prefix but carries - // pure binary garbage instead of valid JSON. This triggers a JSON - // unmarshal panic inside handleConn's message loop. - // - // Frame layout: [4-byte big-endian body-length][msgType byte][body...] - // body = 0xFF repeated 256 bytes — not valid JSON for any message type. - garbage := make([]byte, 256) - for i := range garbage { - garbage[i] = 0xFF - } - // prepend msgType = MsgBlock (0x13) so it reaches the unmarshal path - frame := make([]byte, 4+1+len(garbage)) - bodyLen := uint32(1 + len(garbage)) - frame[0] = byte(bodyLen >> 24) - frame[1] = byte(bodyLen >> 16) - frame[2] = byte(bodyLen >> 8) - frame[3] = byte(bodyLen) - frame[4] = 0x13 // MsgBlock - copy(frame[5:], garbage) - - conn.Write(frame) //nolint:errcheck - time.Sleep(150 * time.Millisecond) - }) - - // Host must still be alive and operational — PeerCount() would panic if the - // Host's internal map had been left in a corrupted state. - pc := host.PeerCount() - t.Logf("3.5.7 ✓ host alive after malicious packet, peerCount=%d", pc) + host, _, addr := startHost(t) + + connectAndHandshake(t, addr, func(conn net.Conn) { + conn.SetDeadline(time.Now().Add(2 * time.Second)) + + // Craft a raw TCP frame that passes the length prefix but carries + // pure binary garbage instead of valid JSON. This triggers a JSON + // unmarshal panic inside handleConn's message loop. + // + // Frame layout: [4-byte big-endian body-length][msgType byte][body...] + // body = 0xFF repeated 256 bytes — not valid JSON for any message type. + garbage := make([]byte, 256) + for i := range garbage { + garbage[i] = 0xFF + } + // prepend msgType = MsgBlock (0x13) so it reaches the unmarshal path + frame := make([]byte, 4+1+len(garbage)) + bodyLen := uint32(1 + len(garbage)) + frame[0] = byte(bodyLen >> 24) + frame[1] = byte(bodyLen >> 16) + frame[2] = byte(bodyLen >> 8) + frame[3] = byte(bodyLen) + frame[4] = 0x13 // MsgBlock + copy(frame[5:], garbage) + + conn.Write(frame) //nolint:errcheck + time.Sleep(150 * time.Millisecond) + }) + + // Host must still be alive and operational — PeerCount() would panic if the + // Host's internal map had been left in a corrupted state. + pc := host.PeerCount() + t.Logf("3.5.7 ✓ host alive after malicious packet, peerCount=%d", pc) } // ─── Peer IP whitelist ──────────────────────────────────────────────────────── @@ -370,164 +370,164 @@ func TestMaliciousPacket_NodeStaysAlive(t *testing.T) { // TestPeerWhitelist_EmptyAllowsAll verifies that an empty peer_whitelist leaves // the network open: connections from any IP are accepted (existing behaviour). func TestPeerWhitelist_EmptyAllowsAll(t *testing.T) { - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "wl-open-host", - UserAgent: "aperod/test", - // PeerWhitelist intentionally empty - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) - if err != nil { - t.Fatalf("dial failed (should be open): %v", err) - } - defer conn.Close() - - // Perform the Aperod inbound handshake so the connection is accepted into - // the peer table. - conn.SetDeadline(time.Now().Add(2 * time.Second)) - if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - t.Fatalf("write ping: %v", err) - } - if _, _, err := p2p.ReadMsg(conn); err != nil { - t.Fatalf("read pong: %v", err) - } - - time.Sleep(50 * time.Millisecond) - if host.PeerCount() == 0 { - t.Error("peer_whitelist=empty: expected connection to be accepted, but PeerCount=0") - } else { - t.Logf("whitelist=empty ✓ connection accepted, peerCount=%d", host.PeerCount()) - } + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "wl-open-host", + UserAgent: "aperod/test", + // PeerWhitelist intentionally empty + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) + if err != nil { + t.Fatalf("dial failed (should be open): %v", err) + } + defer conn.Close() + + // Perform the Aperod inbound handshake so the connection is accepted into + // the peer table. + conn.SetDeadline(time.Now().Add(2 * time.Second)) + if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + t.Fatalf("write ping: %v", err) + } + if _, _, err := p2p.ReadMsg(conn); err != nil { + t.Fatalf("read pong: %v", err) + } + + time.Sleep(50 * time.Millisecond) + if host.PeerCount() == 0 { + t.Error("peer_whitelist=empty: expected connection to be accepted, but PeerCount=0") + } else { + t.Logf("whitelist=empty ✓ connection accepted, peerCount=%d", host.PeerCount()) + } } // TestPeerWhitelist_ExactIPAccepted verifies that a whitelisted IP can connect. func TestPeerWhitelist_ExactIPAccepted(t *testing.T) { - // 127.0.0.1 is the loopback — our test dialer always comes from this IP. - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "wl-exact-host", - UserAgent: "aperod/test", - PeerWhitelist: []string{"127.0.0.1"}, - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) - if err != nil { - t.Fatalf("dial failed: %v", err) - } - defer conn.Close() - - conn.SetDeadline(time.Now().Add(2 * time.Second)) - if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - t.Fatalf("write ping: %v", err) - } - if _, _, err := p2p.ReadMsg(conn); err != nil { - t.Fatalf("read pong: %v", err) - } - - time.Sleep(50 * time.Millisecond) - if host.PeerCount() == 0 { - t.Error("whitelisted IP should be accepted, but PeerCount=0") - } else { - t.Logf("whitelist=127.0.0.1 ✓ whitelisted IP accepted, peerCount=%d", host.PeerCount()) - } + // 127.0.0.1 is the loopback — our test dialer always comes from this IP. + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "wl-exact-host", + UserAgent: "aperod/test", + PeerWhitelist: []string{"127.0.0.1"}, + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(2 * time.Second)) + if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + t.Fatalf("write ping: %v", err) + } + if _, _, err := p2p.ReadMsg(conn); err != nil { + t.Fatalf("read pong: %v", err) + } + + time.Sleep(50 * time.Millisecond) + if host.PeerCount() == 0 { + t.Error("whitelisted IP should be accepted, but PeerCount=0") + } else { + t.Logf("whitelist=127.0.0.1 ✓ whitelisted IP accepted, peerCount=%d", host.PeerCount()) + } } // TestPeerWhitelist_CIDRAccepted verifies that an IP within a whitelisted CIDR // range is accepted. func TestPeerWhitelist_CIDRAccepted(t *testing.T) { - // 127.0.0.0/8 covers all loopback addresses including 127.0.0.1. - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "wl-cidr-host", - UserAgent: "aperod/test", - PeerWhitelist: []string{"127.0.0.0/8"}, - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) - if err != nil { - t.Fatalf("dial failed: %v", err) - } - defer conn.Close() - - conn.SetDeadline(time.Now().Add(2 * time.Second)) - if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), - }); err != nil { - t.Fatalf("write ping: %v", err) - } - if _, _, err := p2p.ReadMsg(conn); err != nil { - t.Fatalf("read pong: %v", err) - } - - time.Sleep(50 * time.Millisecond) - if host.PeerCount() == 0 { - t.Error("CIDR-whitelisted IP should be accepted, but PeerCount=0") - } else { - t.Logf("whitelist=127.0.0.0/8 ✓ CIDR-whitelisted IP accepted, peerCount=%d", host.PeerCount()) - } + // 127.0.0.0/8 covers all loopback addresses including 127.0.0.1. + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "wl-cidr-host", + UserAgent: "aperod/test", + PeerWhitelist: []string{"127.0.0.0/8"}, + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(2 * time.Second)) + if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), + }); err != nil { + t.Fatalf("write ping: %v", err) + } + if _, _, err := p2p.ReadMsg(conn); err != nil { + t.Fatalf("read pong: %v", err) + } + + time.Sleep(50 * time.Millisecond) + if host.PeerCount() == 0 { + t.Error("CIDR-whitelisted IP should be accepted, but PeerCount=0") + } else { + t.Logf("whitelist=127.0.0.0/8 ✓ CIDR-whitelisted IP accepted, peerCount=%d", host.PeerCount()) + } } // TestPeerWhitelist_NonMatchingIPRejected verifies that an inbound connection // from an IP not on the whitelist is rejected before any handshake. func TestPeerWhitelist_NonMatchingIPRejected(t *testing.T) { - // Whitelist an IP that our loopback dialer will NOT be coming from. - // The dialer uses 127.0.0.1; we whitelist a different, non-loopback IP. - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "wl-reject-host", - UserAgent: "aperod/test", - PeerWhitelist: []string{"192.0.2.1"}, // TEST-NET-1 — never comes from loopback - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - // Connect from 127.0.0.1 — not on the whitelist. - conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) - if err != nil { - // Connection refused counts as rejection — pass. - t.Logf("whitelist reject ✓ connection refused by OS") - return - } - defer conn.Close() - - // The host should close the connection immediately without replying. - conn.SetDeadline(time.Now().Add(500 * time.Millisecond)) - _ = p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "intruder", Height: 0, UserAgent: "evil", Timestamp: time.Now().Unix(), - }) - _, _, readErr := p2p.ReadMsg(conn) - - time.Sleep(50 * time.Millisecond) - - if readErr == nil && host.PeerCount() > 0 { - t.Errorf("non-whitelisted IP should be rejected, but PeerCount=%d", host.PeerCount()) - } else { - t.Logf("whitelist reject ✓ non-whitelisted IP rejected (readErr=%v, peers=%d)", - readErr, host.PeerCount()) - } + // Whitelist an IP that our loopback dialer will NOT be coming from. + // The dialer uses 127.0.0.1; we whitelist a different, non-loopback IP. + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "wl-reject-host", + UserAgent: "aperod/test", + PeerWhitelist: []string{"192.0.2.1"}, // TEST-NET-1 — never comes from loopback + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + // Connect from 127.0.0.1 — not on the whitelist. + conn, err := net.DialTimeout("tcp", host.ListenAddr(), 2*time.Second) + if err != nil { + // Connection refused counts as rejection — pass. + t.Logf("whitelist reject ✓ connection refused by OS") + return + } + defer conn.Close() + + // The host should close the connection immediately without replying. + conn.SetDeadline(time.Now().Add(500 * time.Millisecond)) + _ = p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "intruder", Height: 0, UserAgent: "evil", Timestamp: time.Now().Unix(), + }) + _, _, readErr := p2p.ReadMsg(conn) + + time.Sleep(50 * time.Millisecond) + + if readErr == nil && host.PeerCount() > 0 { + t.Errorf("non-whitelisted IP should be rejected, but PeerCount=%d", host.PeerCount()) + } else { + t.Logf("whitelist reject ✓ non-whitelisted IP rejected (readErr=%v, peers=%d)", + readErr, host.PeerCount()) + } } // ─── 3.5.8 Duplicate P2P identity (copied key) rejected ───────────────────── @@ -542,92 +542,92 @@ func TestPeerWhitelist_NonMatchingIPRejected(t *testing.T) { // directory. The host's SelfFingerprint is set to the shared fingerprint so // the identity-conflict guard fires. func TestDuplicateP2PIdentity_Rejected(t *testing.T) { - // Generate a single TLS identity that will be shared by both "nodes". - sharedCfg, sharedFP, err := p2p.GenerateNodeTLSConfig() - if err != nil { - t.Fatalf("GenerateNodeTLSConfig: %v", err) - } - - // Host: TLSConfig uses the shared identity; SelfFingerprint is set so - // the identity-conflict guard in handleConn can trigger. - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 5, - NodeID: "host-node", - UserAgent: "aperod/test", - TLSConfig: sharedCfg, - AllowedPeers: []string{sharedFP}, - SelfFingerprint: sharedFP, - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - // Peer: dials using the SAME TLS config (identical cert → identical - // fingerprint), mimicking a second node that was cloned from the same - // data directory without regenerating its p2p_identity.key. - conn, err := tls.DialWithDialer( - &net.Dialer{Timeout: 2 * time.Second}, - "tcp", host.ListenAddr(), pinTLSConfig(t, sharedCfg, sharedFP), - ) - if err != nil { - // Host closed the connection at the TLS layer — also acceptable. - t.Logf("3.5.8 ✓ duplicate-identity connection rejected at TLS level: %v", err) - if host.PeerCount() != 0 { - t.Errorf("3.5.8: peer count = %d after rejected TLS dial, want 0", host.PeerCount()) - } - return - } - defer conn.Close() - - // TLS handshake completed. The host's SelfFingerprint guard fires inside - // handleConn after Handshake(); the host must close the connection without - // ever sending a MsgPong back to the dialer. - conn.SetDeadline(time.Now().Add(2 * time.Second)) //nolint:errcheck - - // Attempt the Aperod application handshake — expect the host to close - // the conn before or immediately after it receives our Ping. - _ = p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ - NodeID: "clone-node", Height: 0, UserAgent: "aperod/test", - Timestamp: time.Now().Unix(), - }) - _, _, readErr := p2p.ReadMsg(conn) - - time.Sleep(100 * time.Millisecond) - - // The connection must have been closed (readErr non-nil) AND the peer - // must not have been registered in the host's peer table. - if readErr == nil { - t.Error("3.5.8: host replied to a duplicate-identity peer — identity-conflict guard did not fire") - } - if host.PeerCount() != 0 { - t.Errorf("3.5.8: peer count = %d after identity-conflict rejection, want 0", host.PeerCount()) - } - - // Task #1905: the guard must also record a DuplicateIdentityEvent in the - // ring buffer so the API server can surface it in the Admin Panel. - evts := host.GetDuplicateIdentityEvents(time.Time{}) - if len(evts) == 0 { - t.Error("3.5.8: GetDuplicateIdentityEvents returned no events after identity-conflict rejection") - } else { - if evts[0].Fingerprint != sharedFP { - t.Errorf("3.5.8: event fingerprint = %s, want %s", evts[0].Fingerprint, sharedFP) - } - if evts[0].Addr == "" { - t.Error("3.5.8: event addr is empty") - } - if evts[0].At.IsZero() { - t.Error("3.5.8: event timestamp is zero") - } - // A since-filter in the future must exclude the event. - if got := host.GetDuplicateIdentityEvents(time.Now().Add(time.Hour)); len(got) != 0 { - t.Errorf("3.5.8: since-filter returned %d events, want 0", len(got)) - } - } - - t.Logf("3.5.8 ✓ duplicate P2P identity rejected: readErr=%v peerCount=%d fp=%s…", - readErr, host.PeerCount(), sharedFP[:8]) + // Generate a single TLS identity that will be shared by both "nodes". + sharedCfg, sharedFP, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("GenerateNodeTLSConfig: %v", err) + } + + // Host: TLSConfig uses the shared identity; SelfFingerprint is set so + // the identity-conflict guard in handleConn can trigger. + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 5, + NodeID: "host-node", + UserAgent: "aperod/test", + TLSConfig: sharedCfg, + SelfFingerprint: sharedFP, + AllowedPeers: []string{sharedFP}, + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + // Peer: dials using the SAME TLS config (identical cert → identical + // fingerprint), mimicking a second node that was cloned from the same + // data directory without regenerating its p2p_identity.key. + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", host.ListenAddr(), pinTLSConfig(t, sharedCfg, sharedFP), + ) + if err != nil { + // Host closed the connection at the TLS layer — also acceptable. + t.Logf("3.5.8 ✓ duplicate-identity connection rejected at TLS level: %v", err) + if host.PeerCount() != 0 { + t.Errorf("3.5.8: peer count = %d after rejected TLS dial, want 0", host.PeerCount()) + } + return + } + defer conn.Close() + + // TLS handshake completed. The host's SelfFingerprint guard fires inside + // handleConn after Handshake(); the host must close the connection without + // ever sending a MsgPong back to the dialer. + conn.SetDeadline(time.Now().Add(2 * time.Second)) //nolint:errcheck + + // Attempt the Aperod application handshake — expect the host to close + // the conn before or immediately after it receives our Ping. + _ = p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ + NodeID: "clone-node", Height: 0, UserAgent: "aperod/test", + Timestamp: time.Now().Unix(), + }) + _, _, readErr := p2p.ReadMsg(conn) + + time.Sleep(100 * time.Millisecond) + + // The connection must have been closed (readErr non-nil) AND the peer + // must not have been registered in the host's peer table. + if readErr == nil { + t.Error("3.5.8: host replied to a duplicate-identity peer — identity-conflict guard did not fire") + } + if host.PeerCount() != 0 { + t.Errorf("3.5.8: peer count = %d after identity-conflict rejection, want 0", host.PeerCount()) + } + + // Task #1905: the guard must also record a DuplicateIdentityEvent in the + // ring buffer so the API server can surface it in the Admin Panel. + evts := host.GetDuplicateIdentityEvents(time.Time{}) + if len(evts) == 0 { + t.Error("3.5.8: GetDuplicateIdentityEvents returned no events after identity-conflict rejection") + } else { + if evts[0].Fingerprint != sharedFP { + t.Errorf("3.5.8: event fingerprint = %s, want %s", evts[0].Fingerprint, sharedFP) + } + if evts[0].Addr == "" { + t.Error("3.5.8: event addr is empty") + } + if evts[0].At.IsZero() { + t.Error("3.5.8: event timestamp is zero") + } + // A since-filter in the future must exclude the event. + if got := host.GetDuplicateIdentityEvents(time.Now().Add(time.Hour)); len(got) != 0 { + t.Errorf("3.5.8: since-filter returned %d events, want 0", len(got)) + } + } + + t.Logf("3.5.8 ✓ duplicate P2P identity rejected: readErr=%v peerCount=%d fp=%s…", + readErr, host.PeerCount(), sharedFP[:8]) } // ─── 3.5.9 Duplicate P2P identity — outbound dial path ─────────────────────── @@ -635,20 +635,20 @@ func TestDuplicateP2PIdentity_Rejected(t *testing.T) { // syncLogBuffer is a concurrency-safe bytes.Buffer for capturing slog output // from the Host's background goroutines. type syncLogBuffer struct { - mu sync.Mutex - buf bytes.Buffer + mu sync.Mutex + buf bytes.Buffer } func (b *syncLogBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) } func (b *syncLogBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() } // TestDuplicateP2PIdentity_RejectedOutbound verifies that the identity-conflict @@ -657,85 +657,86 @@ func (b *syncLogBuffer) String() string { // other" scenario), the connection must be closed after the TLS handshake // without registering the peer, and the identity-conflict ERROR must be logged. func TestDuplicateP2PIdentity_RejectedOutbound(t *testing.T) { - // One shared TLS identity for both "nodes" — simulates two nodes cloned - // from the same data directory (copied p2p_identity.key). - sharedCfg, sharedFP, err := p2p.GenerateNodeTLSConfig() - if err != nil { - t.Fatalf("GenerateNodeTLSConfig: %v", err) - } - - // Capture ERROR-level logs so the identity-conflict message can be asserted. - logBuf := &syncLogBuffer{} - logger := slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelError})) - - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 5, - NodeID: "outbound-host", - UserAgent: "aperod/test", - TLSConfig: sharedCfg, - AllowedPeers: []string{sharedFP}, - SelfFingerprint: sharedFP, - }, &stubHandler{}, logger) - if err := host.Start(); err != nil { - t.Fatalf("host.Start: %v", err) - } - t.Cleanup(host.Stop) - - // Remote "clone" node: a bare TLS listener presenting the SAME cert. - ln, err := tls.Listen("tcp", "127.0.0.1:0", pinTLSConfig(t, sharedCfg, sharedFP)) - if err != nil { - t.Fatalf("tls.Listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - // remoteClosed is signalled when the host closes the connection (the - // listener side observes EOF / reset on its first read). - remoteClosed := make(chan error, 1) - go func() { - c, err := ln.Accept() - if err != nil { - remoteClosed <- err - return - } - defer c.Close() - // The server-side TLS handshake completes lazily on first Read. - // If the host's identity-conflict guard fires, it closes the conn - // right after the handshake, so this Read must fail — and it must - // NOT return a valid Aperod Ping frame first. - c.SetDeadline(time.Now().Add(3 * time.Second)) //nolint:errcheck - buf := make([]byte, 1) - _, readErr := c.Read(buf) - remoteClosed <- readErr - }() - - // Host acts as the outbound dialer toward the cloned listener. - host.DialPeer(ln.Addr().String()) - - // Wait for the remote side to observe the disconnect. - select { - case readErr := <-remoteClosed: - if readErr == nil { - t.Error("3.5.9: host sent application data to a duplicate-identity peer — outbound guard did not fire before the Aperod handshake") - } - case <-time.After(4 * time.Second): - t.Fatal("3.5.9: timed out waiting for the host to close the duplicate-identity connection") - } - - // Give the host a moment to finish any teardown bookkeeping. - time.Sleep(100 * time.Millisecond) - - // The peer must never be registered. - if host.PeerCount() != 0 { - t.Errorf("3.5.9: peer count = %d after outbound identity-conflict rejection, want 0", host.PeerCount()) - } - - // The identity-conflict ERROR log must be present. - logs := logBuf.String() - if !strings.Contains(logs, "identity conflict") { - t.Errorf("3.5.9: identity-conflict ERROR log not found; captured logs:\n%s", logs) - } - - t.Logf("3.5.9 ✓ outbound duplicate P2P identity rejected: peerCount=%d fp=%s…", - host.PeerCount(), sharedFP[:8]) + // One shared TLS identity for both "nodes" — simulates two nodes cloned + // from the same data directory (copied p2p_identity.key). + sharedCfg, sharedFP, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("GenerateNodeTLSConfig: %v", err) + } + + // Capture ERROR-level logs so the identity-conflict message can be asserted. + logBuf := &syncLogBuffer{} + logger := slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelError})) + + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 5, + NodeID: "outbound-host", + UserAgent: "aperod/test", + TLSConfig: sharedCfg, + SelfFingerprint: sharedFP, + AllowedPeers: []string{sharedFP}, + }, &stubHandler{}, logger) + if err := host.Start(); err != nil { + t.Fatalf("host.Start: %v", err) + } + t.Cleanup(host.Stop) + + // Remote "clone" node: a bare TLS listener presenting the SAME cert. + remoteTLSConfig := pinTLSConfig(t, sharedCfg, sharedFP) + ln, err := tls.Listen("tcp", "127.0.0.1:0", remoteTLSConfig) + if err != nil { + t.Fatalf("tls.Listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + // remoteClosed is signalled when the host closes the connection (the + // listener side observes EOF / reset on its first read). + remoteClosed := make(chan error, 1) + go func() { + c, err := ln.Accept() + if err != nil { + remoteClosed <- err + return + } + defer c.Close() + // The server-side TLS handshake completes lazily on first Read. + // If the host's identity-conflict guard fires, it closes the conn + // right after the handshake, so this Read must fail — and it must + // NOT return a valid Aperod Ping frame first. + c.SetDeadline(time.Now().Add(3 * time.Second)) //nolint:errcheck + buf := make([]byte, 1) + _, readErr := c.Read(buf) + remoteClosed <- readErr + }() + + // Host acts as the outbound dialer toward the cloned listener. + host.DialPeer(ln.Addr().String()) + + // Wait for the remote side to observe the disconnect. + select { + case readErr := <-remoteClosed: + if readErr == nil { + t.Error("3.5.9: host sent application data to a duplicate-identity peer — outbound guard did not fire before the Aperod handshake") + } + case <-time.After(4 * time.Second): + t.Fatal("3.5.9: timed out waiting for the host to close the duplicate-identity connection") + } + + // Give the host a moment to finish any teardown bookkeeping. + time.Sleep(100 * time.Millisecond) + + // The peer must never be registered. + if host.PeerCount() != 0 { + t.Errorf("3.5.9: peer count = %d after outbound identity-conflict rejection, want 0", host.PeerCount()) + } + + // The identity-conflict ERROR log must be present. + logs := logBuf.String() + if !strings.Contains(logs, "identity conflict") { + t.Errorf("3.5.9: identity-conflict ERROR log not found; captured logs:\n%s", logs) + } + + t.Logf("3.5.9 ✓ outbound duplicate P2P identity rejected: peerCount=%d fp=%s…", + host.PeerCount(), sharedFP[:8]) } diff --git a/p2p/transport.go b/p2p/transport.go index 91b830b..b0b287d 100644 --- a/p2p/transport.go +++ b/p2p/transport.go @@ -25,7 +25,7 @@ import ( "net" "os" "path/filepath" -"strings" + "strings" "time" ) @@ -98,38 +98,38 @@ func NodeTLSConfigFromKey(priv ed25519.PrivateKey, pub ed25519.PublicKey) (*tls. // authenticates the remote certificate against an explicit SPKI pin set. // An empty or malformed list is rejected; there is no open-network fallback. func TLSConfigWithAllowedPeerFingerprints(base *tls.Config, allowedPeers []string) (*tls.Config, error) { -if base == nil { -return nil, fmt.Errorf("p2p TLS config is required") -} -if len(allowedPeers) == 0 { -return nil, fmt.Errorf("p2p TLS requires at least one allowed peer fingerprint") -} + if base == nil { + return nil, fmt.Errorf("p2p TLS config is required") + } + if len(allowedPeers) == 0 { + return nil, fmt.Errorf("p2p TLS requires at least one allowed peer fingerprint") + } -pinned := make(map[string]struct{}, len(allowedPeers)) -for _, value := range allowedPeers { -fingerprint := strings.ToLower(strings.TrimSpace(value)) -decoded, err := hex.DecodeString(fingerprint) -if err != nil || len(decoded) != sha256.Size { -return nil, fmt.Errorf("invalid allowed peer fingerprint") -} -pinned[fingerprint] = struct{}{} -} + pinned := make(map[string]struct{}, len(allowedPeers)) + for _, value := range allowedPeers { + fingerprint := strings.ToLower(strings.TrimSpace(value)) + decoded, err := hex.DecodeString(fingerprint) + if err != nil || len(decoded) != sha256.Size { + return nil, fmt.Errorf("invalid allowed peer fingerprint") + } + pinned[fingerprint] = struct{}{} + } -cfg := base.Clone() -cfg.VerifyConnection = func(state tls.ConnectionState) error { -if len(state.PeerCertificates) == 0 { -return fmt.Errorf("p2p TLS peer did not present a certificate") -} -fingerprint, err := spkiFingerprint(state.PeerCertificates[0].PublicKey) -if err != nil { -return fmt.Errorf("p2p TLS peer fingerprint: %w", err) -} -if _, ok := pinned[fingerprint]; !ok { -return fmt.Errorf("p2p TLS peer fingerprint is not in allowed_peers") -} -return nil -} -return cfg, nil + cfg := base.Clone() + cfg.VerifyConnection = func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("p2p TLS peer did not present a certificate") + } + fingerprint, err := spkiFingerprint(state.PeerCertificates[0].PublicKey) + if err != nil { + return fmt.Errorf("p2p TLS peer fingerprint: %w", err) + } + if _, ok := pinned[fingerprint]; !ok { + return fmt.Errorf("p2p TLS peer fingerprint is not in allowed_peers") + } + return nil + } + return cfg, nil } func nodeConfigFromKey(priv ed25519.PrivateKey, pub ed25519.PublicKey) (*tls.Config, string, error) { @@ -147,13 +147,13 @@ func nodeConfigFromKey(priv ed25519.PrivateKey, pub ed25519.PublicKey) (*tls.Con // RequireAnyClientCert means the cert must be sent but the chain is // not verified — appropriate for self-signed peer certs. ClientAuth: tls.RequireAnyClientCert, -// Self-signed certificates have no CA chain. The callback rejects all -// peers until an explicit pin set is installed by the P2P host. + // Self-signed certificates have no CA chain. The callback rejects all + // peers until an explicit pin set is installed by the P2P host. InsecureSkipVerify: true, //nolint:gosec // intentional — fingerprint auth -MinVersion: tls.VersionTLS13, -VerifyConnection: func(tls.ConnectionState) error { -return fmt.Errorf("p2p TLS peer pins are not configured") -}, + MinVersion: tls.VersionTLS13, + VerifyConnection: func(tls.ConnectionState) error { + return fmt.Errorf("p2p TLS peer pins are not configured") + }, } return cfg, fingerprint, nil } diff --git a/p2p/transport_test.go b/p2p/transport_test.go index cdfd169..485ff7f 100644 --- a/p2p/transport_test.go +++ b/p2p/transport_test.go @@ -14,7 +14,7 @@ package p2p_test import ( "crypto/tls" "net" -"strings" + "strings" "testing" "time" @@ -26,12 +26,12 @@ import ( const testUnmatchedPeerFingerprint = "0000000000000000000000000000000000000000000000000000000000000000" func pinTLSConfig(t *testing.T, cfg *tls.Config, fingerprints ...string) *tls.Config { -t.Helper() -pinned, err := p2p.TLSConfigWithAllowedPeerFingerprints(cfg, fingerprints) -if err != nil { -t.Fatalf("TLSConfigWithAllowedPeerFingerprints: %v", err) -} -return pinned + t.Helper() + pinned, err := p2p.TLSConfigWithAllowedPeerFingerprints(cfg, fingerprints) + if err != nil { + t.Fatalf("TLSConfigWithAllowedPeerFingerprints: %v", err) + } + return pinned } // ─── T-1: Config generation ─────────────────────────────────────────────────── @@ -76,20 +76,20 @@ func TestTLS_MutualAuth_HostAndRawPeer(t *testing.T) { if err != nil { t.Fatalf("gen config B: %v", err) } -cfgB = pinTLSConfig(t, cfgB, fpA) - -hostA := p2p.NewHost(p2p.Config{ -ListenAddr: "127.0.0.1:0", -MaxPeers: 5, -NodeID: "node-a", -UserAgent: "aperod/test", -TLSConfig: cfgA, -AllowedPeers: []string{fpB}, -}, &stubHandler{}, newTestLogger()) -if err := hostA.Start(); err != nil { -t.Fatalf("hostA.Start: %v", err) -} -t.Cleanup(hostA.Stop) + cfgB = pinTLSConfig(t, cfgB, fpA) + + hostA := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 5, + NodeID: "node-a", + UserAgent: "aperod/test", + TLSConfig: cfgA, + AllowedPeers: []string{fpB}, + }, &stubHandler{}, newTestLogger()) + if err := hostA.Start(); err != nil { + t.Fatalf("hostA.Start: %v", err) + } + t.Cleanup(hostA.Stop) // Dial using the raw TLS dialer so the full TLS handshake (including // mutual certificate exchange) completes before we send any Aperod data. @@ -146,17 +146,17 @@ func TestTLS_PlaintextClientRejected(t *testing.T) { if err != nil { t.Fatalf("gen config: %v", err) } -_, peerFingerprint, err := p2p.GenerateNodeTLSConfig() -if err != nil { -t.Fatalf("gen peer fingerprint: %v", err) -} + _, peerFingerprint, err := p2p.GenerateNodeTLSConfig() + if err != nil { + t.Fatalf("gen peer fingerprint: %v", err) + } host := p2p.NewHost(p2p.Config{ -ListenAddr: "127.0.0.1:0", -MaxPeers: 5, -NodeID: "tls-node", -UserAgent: "aperod/test", -TLSConfig: tlsCfg, -AllowedPeers: []string{peerFingerprint}, + ListenAddr: "127.0.0.1:0", + MaxPeers: 5, + NodeID: "tls-node", + UserAgent: "aperod/test", + TLSConfig: tlsCfg, + AllowedPeers: []string{peerFingerprint}, }, &stubHandler{}, newTestLogger()) if err := host.Start(); err != nil { t.Fatalf("host.Start: %v", err) @@ -230,8 +230,8 @@ func TestTLS_AllowedPeers_UnlistedRejected(t *testing.T) { t.Fatalf("gen config good: %v", err) } -// Host identity. -cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() + // Host identity. + cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() if err != nil { t.Fatalf("gen config host: %v", err) } @@ -256,7 +256,7 @@ cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() if err != nil { t.Fatalf("gen config bad: %v", err) } -cfgBad = pinTLSConfig(t, cfgBad, fpHost) + cfgBad = pinTLSConfig(t, cfgBad, fpHost) badConn, err := tls.DialWithDialer( &net.Dialer{Timeout: 2 * time.Second}, "tcp", addr, cfgBad, @@ -287,7 +287,7 @@ cfgBad = pinTLSConfig(t, cfgBad, fpHost) // ── Attempt 2: connect with the LISTED identity ─────────────────────────── goodConn, err := tls.DialWithDialer( &net.Dialer{Timeout: 2 * time.Second}, -"tcp", addr, pinTLSConfig(t, cfgGood, fpHost), + "tcp", addr, pinTLSConfig(t, cfgGood, fpHost), ) if err != nil { t.Fatalf("T-6: allowed peer TLS dial failed: %v", err) @@ -319,7 +319,7 @@ cfgBad = pinTLSConfig(t, cfgBad, fpHost) // ─── T-7: An empty pin list fails closed ────────────────────────────────────── func TestTLS_AllowedPeers_EmptyRejectedAtStartup(t *testing.T) { -cfgHost, _, err := p2p.GenerateNodeTLSConfig() + cfgHost, _, err := p2p.GenerateNodeTLSConfig() if err != nil { t.Fatalf("gen config host: %v", err) } @@ -330,7 +330,6 @@ cfgHost, _, err := p2p.GenerateNodeTLSConfig() UserAgent: "aperod/test", TLSConfig: cfgHost, }, &stubHandler{}, newTestLogger()) - err = host.Start() if err == nil { host.Stop() @@ -443,11 +442,11 @@ func TestTLS_BannedIPRejectedDespiteValidCert(t *testing.T) { } // Host identity. -cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() + cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() if err != nil { t.Fatalf("T-10: gen host config: %v", err) } -cfgAttacker = pinTLSConfig(t, cfgAttacker, fpHost) + cfgAttacker = pinTLSConfig(t, cfgAttacker, fpHost) host := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -581,7 +580,7 @@ func TestTLS_HandshakeFlood(t *testing.T) { } // A timeout means the connection was NOT rejected — it just hung. if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() { - t.Errorf("T-11: (limit+1)th connection timed out instead of being rejected immediately — "+ + t.Errorf("T-11: (limit+1)th connection timed out instead of being rejected immediately — " + "MaxPendingHandshakes semaphore not working (timeout after 500 ms)") return } @@ -695,7 +694,7 @@ func TestTLS_ForgedCert_CannotInjectBlock(t *testing.T) { } // Host identity. - cfgHost, _, err := p2p.GenerateNodeTLSConfig() + cfgHost, fpHost, err := p2p.GenerateNodeTLSConfig() if err != nil { t.Fatalf("gen config host: %v", err) } @@ -719,6 +718,7 @@ func TestTLS_ForgedCert_CannotInjectBlock(t *testing.T) { if err != nil { t.Fatalf("gen config attacker: %v", err) } + cfgAttacker = pinTLSConfig(t, cfgAttacker, fpHost) attackConn, err := tls.DialWithDialer( &net.Dialer{Timeout: 2 * time.Second}, diff --git a/sale/accounting.go b/sale/accounting.go new file mode 100644 index 0000000..a4b2950 --- /dev/null +++ b/sale/accounting.go @@ -0,0 +1,217 @@ +// Package sale is an INACTIVE, deterministic accounting prototype for a future +// consensus-authorized crypto-to-APRO sale protocol. It creates no UTXOs, does +// not validate a block, and does not grant an API permission to accept payments. +// A signed nominal checkpoint is a trust assumption, NOT historical issuance +// proof. The live legacy admin mint must remain disabled. +package sale + +import ( + "encoding/binary" + "fmt" + "math" + + "github.com/aperod/aperod/crypto" +) + +const CapNAPRO uint64 = 10_000_000_000 * 100_000_000 + +var zeroHash crypto.Hash32 + +// Attestation is from one of the authorities in the node's locally configured, +// immutable trusted set. Never load that set from the checkpoint itself. +type Attestation struct { + Validator crypto.ValidatorPubKey + Signature []byte +} + +// Checkpoint explicitly commits to a *nominal*, authority-attested starting +// state. HistoricIssued and OtherReserved are declarations, not proof derived +// from incomplete pre-activation blocks. SaleRemaining is distinct from the +// explorer's "available allocation" presentation figure. +type Checkpoint struct { + Genesis crypto.Hash32 + Network crypto.NetworkByte + Height uint64 + Parent crypto.Hash32 + SnapshotRoot crypto.Hash32 + HistoricIssued uint64 + OtherReserved uint64 + SaleRemaining uint64 + SaleAuthority crypto.ValidatorPubKey + Attestations []Attestation +} + +func u64(n uint64) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], n) + return b[:] +} + +func (c Checkpoint) message() crypto.Hash32 { + return crypto.HashBytes( + []byte("aperod/sale/forward-checkpoint/authorization/v1"), + c.Genesis[:], []byte{byte(c.Network)}, u64(c.Height), c.Parent[:], c.SnapshotRoot[:], + u64(c.HistoricIssued), u64(c.OtherReserved), u64(c.SaleRemaining), + []byte(c.SaleAuthority), + ) +} + +// Message lets authorities attest the exact fields under a domain-separated +// signature. Signed data cannot authorize any other checkpoint version. +func (c Checkpoint) Message() crypto.Hash32 { return c.message() } + +func nonzeroPub(key crypto.ValidatorPubKey) bool { + if len(key) != 32 { + return false + } + for _, b := range key { + if b != 0 { + return true + } + } + return false +} + +func (c Checkpoint) Verify(genesis, parent crypto.Hash32, height uint64, trusted []crypto.ValidatorPubKey) error { + if height == 0 || c.Height != height || c.Parent != parent || parent == zeroHash || + c.Genesis != genesis || genesis == zeroHash || c.SnapshotRoot == zeroHash || + !nonzeroPub(c.SaleAuthority) { + return fmt.Errorf("sale: checkpoint is not bound to the canonical activation parent and authority") + } + if c.Network != crypto.MainnetByte && c.Network != crypto.TestnetByte && + c.Network != crypto.DevnetByte { + return fmt.Errorf("sale: unsupported address network") + } + if c.SaleRemaining == 0 || c.HistoricIssued > CapNAPRO || + c.OtherReserved > CapNAPRO-c.HistoricIssued || + c.SaleRemaining > CapNAPRO-c.HistoricIssued-c.OtherReserved { + return fmt.Errorf("sale: declared nominal budget exceeds the 10B cap") + } + allowed := make(map[string]bool, len(trusted)) + for _, key := range trusted { + if !nonzeroPub(key) || allowed[string(key)] { + return fmt.Errorf("sale: invalid trusted authority configuration") + } + allowed[string(key)] = true + } + if len(allowed) == 0 { + return fmt.Errorf("sale: missing trusted authorities") + } + seen := make(map[string]bool, len(c.Attestations)) + for _, a := range c.Attestations { + key := string(a.Validator) + if !allowed[key] || seen[key] || !a.Validator.Verify(c.message(), a.Signature) { + return fmt.Errorf("sale: forged, duplicate, or untrusted checkpoint attestation") + } + seen[key] = true + } + // Use division to avoid overflow for unusually large untrusted key sets. + if len(seen) <= 2*len(allowed)/3 { + return fmt.Errorf("sale: checkpoint requires greater than two-thirds trusted authority signatures") + } + return nil +} + +// Purchase is a proposed signed purchase commitment, NOT a coinbase. A future +// block validator must additionally verify the exact credited output and +// persist/rollback state atomically with the block before using this ledger. +type Purchase struct { + Genesis crypto.Hash32 + Height uint64 + Parent crypto.Hash32 + OrderHash crypto.Hash32 + PaymentHash crypto.Hash32 + Recipient crypto.Address + Amount uint64 + Signature []byte +} + +func (p Purchase) Message() crypto.Hash32 { + return crypto.HashBytes( + []byte("aperod/sale/purchase/authorization/v1"), + p.Genesis[:], u64(p.Height), p.Parent[:], p.OrderHash[:], + p.PaymentHash[:], u64(p.Amount), []byte(p.Recipient), + ) +} + +// Ledger is immutable to callers; Apply returns a successor or an error, +// leaving the parent intact for reorg rollback. The seen-order index must be +// made durable alongside the canonical block in the eventual integration. +type Ledger struct { + checkpoint Checkpoint + height uint64 + hash crypto.Hash32 + remaining uint64 + sold uint64 + seen map[crypto.Hash32]struct{} + paid map[crypto.Hash32]struct{} +} + +func Begin(c Checkpoint, genesis, parent crypto.Hash32, height uint64, trusted []crypto.ValidatorPubKey) (*Ledger, error) { + if err := c.Verify(genesis, parent, height, trusted); err != nil { + return nil, err + } + // The caller owns the witness and can mutate its byte slices afterwards. + // Retain only immutable fields and our independent authority copy. + c.SaleAuthority = append(crypto.ValidatorPubKey(nil), c.SaleAuthority...) + c.Attestations = nil + return &Ledger{checkpoint: c, height: height - 1, hash: parent, + remaining: c.SaleRemaining, seen: make(map[crypto.Hash32]struct{}), + paid: make(map[crypto.Hash32]struct{})}, nil +} + +// ApplyAccountedPurchases checks signatures and the nominal debit. It is NOT +// the validator or a usable mint: no UTXO is created or verified here. +func (s *Ledger) ApplyAccountedPurchases(height uint64, parent, blockHash crypto.Hash32, purchases []Purchase) (*Ledger, error) { + if s == nil || s.seen == nil || s.paid == nil || height == 0 || height != s.height+1 || + parent != s.hash || blockHash == zeroHash || blockHash == parent { + return nil, fmt.Errorf("sale: missing or noncanonical ledger parent") + } + next := &Ledger{checkpoint: s.checkpoint, height: height, hash: blockHash, + remaining: s.remaining, sold: s.sold, + seen: make(map[crypto.Hash32]struct{}, len(s.seen)+len(purchases)), + paid: make(map[crypto.Hash32]struct{}, len(s.paid)+len(purchases))} + for hash := range s.seen { + next.seen[hash] = struct{}{} + } + for hash := range s.paid { + next.paid[hash] = struct{}{} + } + for _, p := range purchases { + addressNetwork, _, _, addressErr := crypto.DecodeAddress(p.Recipient) + if p.Genesis != s.checkpoint.Genesis || p.Height != height || p.Parent != parent || + p.OrderHash == zeroHash || p.PaymentHash == zeroHash || p.Amount == 0 || + addressErr != nil || addressNetwork != s.checkpoint.Network || + !s.checkpoint.SaleAuthority.Verify(p.Message(), p.Signature) { + return nil, fmt.Errorf("sale: invalid signed purchase commitment") + } + if _, exists := next.seen[p.OrderHash]; exists { + return nil, fmt.Errorf("sale: purchase order already consumed") + } + if _, exists := next.paid[p.PaymentHash]; exists { + return nil, fmt.Errorf("sale: payment already consumed") + } + if p.Amount > next.remaining || p.Amount > math.MaxUint64-next.sold { + return nil, fmt.Errorf("sale: insufficient remaining nominal allocation") + } + next.seen[p.OrderHash] = struct{}{} + next.paid[p.PaymentHash] = struct{}{} + next.remaining -= p.Amount + next.sold += p.Amount + } + return next, nil +} + +// Seen does not expose the mutable backing map. +func (s *Ledger) Seen(order crypto.Hash32) bool { + if s == nil { + return false + } + _, ok := s.seen[order] + return ok +} + +func (s *Ledger) Remaining() uint64 { return s.remaining } +func (s *Ledger) Sold() uint64 { return s.sold } +func (s *Ledger) Height() uint64 { return s.height } +func (s *Ledger) Hash() crypto.Hash32 { return s.hash } diff --git a/sale/accounting_test.go b/sale/accounting_test.go new file mode 100644 index 0000000..0fd7a69 --- /dev/null +++ b/sale/accounting_test.go @@ -0,0 +1,208 @@ +package sale + +import ( + "testing" + + "github.com/aperod/aperod/crypto" +) + +type fixture struct { + c Checkpoint + keys []crypto.ValidatorPrivKey + trusted []crypto.ValidatorPubKey + merchant crypto.ValidatorPrivKey + address crypto.Address +} + +func setup(t *testing.T) fixture { + t.Helper() + var f fixture + for i := 0; i < 4; i++ { + key, pub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + f.keys = append(f.keys, key) + f.trusted = append(f.trusted, pub) + } + key, pub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + f.merchant = key + wallet, err := crypto.GenerateWalletKeys() + if err != nil { t.Fatal(err) } + f.address = crypto.AddressFromKeys(crypto.MainnetByte, wallet) + f.c = Checkpoint{ + Genesis: crypto.HashBytes([]byte("sale-test-genesis")), Network: crypto.MainnetByte, Height: 101, + Parent: crypto.HashBytes([]byte("sale-test-parent")), + SnapshotRoot: crypto.HashBytes([]byte("sale-test-nominal-snapshot")), + HistoricIssued: CapNAPRO/2, OtherReserved: CapNAPRO/4, + SaleRemaining: CapNAPRO/4, SaleAuthority: pub, + } + f.signCheckpoint(t) + return f +} + +func (f *fixture) signCheckpoint(t *testing.T) { + t.Helper() + f.c.Attestations = nil + for i := 0; i < 3; i++ { + sig, err := f.keys[i].Sign(f.c.Message()) + if err != nil { t.Fatal(err) } + f.c.Attestations = append(f.c.Attestations, Attestation{Validator: f.trusted[i], Signature: sig}) + } +} + +func (f fixture) ledger(t *testing.T) *Ledger { + t.Helper() + s, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height, f.trusted) + if err != nil { t.Fatal(err) } + return s +} + +func (f fixture) purchase(t *testing.T, order, payment string, amount uint64, height uint64, parent crypto.Hash32) Purchase { + t.Helper() + p := Purchase{Genesis:f.c.Genesis, Height:height, Parent:parent, + OrderHash:crypto.HashBytes([]byte(order)), PaymentHash:crypto.HashBytes([]byte(payment)), + Recipient:f.address, Amount:amount} + sig, err := f.merchant.Sign(p.Message()) + if err != nil { t.Fatal(err) } + p.Signature = sig + return p +} + +func TestForwardCheckpointQuorumAndCap(t *testing.T) { + for _, tc := range []struct{ + name string + change func(*fixture) + }{ + {"two_of_four_is_not_quorum", func(f *fixture){ f.c.Attestations=f.c.Attestations[:2] }}, + {"duplicate_signer", func(f *fixture){ f.c.Attestations[2]=f.c.Attestations[0] }}, + {"empty_quorum", func(f *fixture){ f.c.Attestations=nil }}, + {"untrusted_signer", func(f *fixture){ f.trusted=f.trusted[1:] }}, + {"duplicate_trusted_authority", func(f *fixture){ f.trusted=append(f.trusted,f.trusted[0]) }}, + {"missing_trust_set", func(f *fixture){ f.trusted=nil }}, + {"signature_tampering", func(f *fixture){ f.c.Attestations[0].Signature[0]^=1 }}, + {"budget_changed_after_signing", func(f *fixture){ f.c.SaleRemaining-- }}, + {"authority_changed_after_signing", func(f *fixture){ f.c.SaleAuthority=f.trusted[0] }}, + {"network_changed_after_signing", func(f *fixture){ f.c.Network=crypto.TestnetByte }}, + {"signed_cap_violation", func(f *fixture){ f.c.SaleRemaining++; f.signCheckpoint(t) }}, + {"signed_reserved_overflow", func(f *fixture){ f.c.OtherReserved=^uint64(0); f.signCheckpoint(t) }}, + {"signed_zero_budget", func(f *fixture){ f.c.SaleRemaining=0; f.signCheckpoint(t) }}, + {"signed_zero_snapshot", func(f *fixture){ f.c.SnapshotRoot=zeroHash; f.signCheckpoint(t) }}, + }{ + t.Run(tc.name,func(t *testing.T){ + f:=setup(t); tc.change(&f) + if _,err:=Begin(f.c,f.c.Genesis,f.c.Parent,f.c.Height,f.trusted); err==nil { + t.Fatal("invalid checkpoint accepted") + } + }) + } + f:=setup(t) + if _,err:=Begin(f.c,f.c.Genesis,crypto.HashBytes([]byte("other-parent")),f.c.Height,f.trusted);err==nil { t.Fatal("wrong parent accepted") } + if _,err:=Begin(f.c,crypto.HashBytes([]byte("other-chain")),f.c.Parent,f.c.Height,f.trusted);err==nil { t.Fatal("wrong chain accepted") } + if _,err:=Begin(f.c,f.c.Genesis,f.c.Parent,f.c.Height+1,f.trusted);err==nil { t.Fatal("wrong activation height accepted") } +} + +func TestExplorerNominalHeadlineCannotBeUsedAsSpendableSaleBudget(t *testing.T) { + f := setup(t) + // The live LPoD v3 witness declares 6.23B nominal pre-funding circulation. + // A 6.864B APRO explorer available-allocation snapshot cannot *also* be + // declared newly spendable, even before accounting for other reserves. + f.c.HistoricIssued = 6_230_000_000 * 100_000_000 + f.c.OtherReserved = 0 + f.c.SaleRemaining = 6_864_595_139 * 100_000_000 + f.signCheckpoint(t) + if _, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height, f.trusted); err == nil { + t.Fatal("nominal explorer headline double-counted against already declared circulation") + } +} + +func TestIndependentReplayRestoresSameDebitAndReplayIndex(t *testing.T) { + f:=setup(t) + a:=f.ledger(t); b:=f.ledger(t) + for i,order:=range []string{"one","two"} { + height:=uint64(101+i) + if a.Hash()!=b.Hash() {t.Fatal("nodes diverged before replay") } + p:=f.purchase(t,order,"payment-"+order,100,height,a.Hash()) + hash:=crypto.HashBytes([]byte("block-"+order)) + var err error + a,err=a.ApplyAccountedPurchases(height,a.Hash(),hash,[]Purchase{p}) + if err!=nil {t.Fatal(err)} + b,err=b.ApplyAccountedPurchases(height,b.Hash(),hash,[]Purchase{p}) + if err!=nil {t.Fatal(err)} + if a.Remaining()!=b.Remaining()||a.Sold()!=b.Sold()||!b.Seen(p.OrderHash) {t.Fatal("independent replay mismatch")} + } + duplicate:=f.purchase(t,"one","new-payment",50,103,a.Hash()) + if _,err:=b.ApplyAccountedPurchases(103,b.Hash(),crypto.HashBytes([]byte("block-103")),[]Purchase{duplicate});err==nil {t.Fatal("replayed node lost spent-order index")} +} + +func TestPurchaseDebitRollbackAndReplay(t *testing.T) { + f:=setup(t); s:=f.ledger(t) + p:=f.purchase(t,"order-a","payment-a",100,101,s.Hash()) + h:=crypto.HashBytes([]byte("block-101")) + next,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{p}) + if err!=nil { t.Fatal(err) } + if next.Remaining()!=f.c.SaleRemaining-100 || next.Sold()!=100 || !next.Seen(p.OrderHash) { t.Fatal("incorrect debit") } + if s.Seen(p.OrderHash) || s.Sold()!=0 || s.Remaining()!=f.c.SaleRemaining { t.Fatal("parent mutated") } + replay:=f.purchase(t,"order-a","another-payment",100,102,next.Hash()) + if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("block-102")),[]Purchase{replay});err==nil { t.Fatal("order replay accepted") } + duplicatePayment:=f.purchase(t,"different-order","payment-a",100,102,next.Hash()) + if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("block-102")),[]Purchase{duplicatePayment});err==nil { t.Fatal("payment replay accepted") } + // A branch rolled back to its immutable parent may consume the orphaned + // order again. Neither the order nor payment index leaks from that branch. + branch,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("alternate-101")),[]Purchase{p}) + if err!=nil || branch.Remaining()!=next.Remaining() { t.Fatalf("rollback branch failed: %v",err) } + if _,err:=next.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{p});err==nil { t.Fatal("canonical block replay accepted") } +} + +func TestBatchIsAtomicAndBounded(t *testing.T) { + f:=setup(t); s:=f.ledger(t); h:=crypto.HashBytes([]byte("batch")) + a:=f.purchase(t,"a","pa",100,101,s.Hash()) + b:=f.purchase(t,"b","pb",f.c.SaleRemaining,101,s.Hash()) + if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,b});err==nil { t.Fatal("overspend accepted") } + if s.Seen(a.OrderHash)||s.Sold()!=0 { t.Fatal("failed batch mutated parent") } + b.Amount=f.c.SaleRemaining-100; b.Signature,_=f.merchant.Sign(b.Message()) + next,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,b}) + if err!=nil||next.Remaining()!=0||next.Sold()!=f.c.SaleRemaining { t.Fatalf("exact exhaustion failed: %v",err) } + c:=f.purchase(t,"c","pc",1,102,next.Hash()) + if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("next")),[]Purchase{c});err==nil { t.Fatal("empty allocation spent") } + if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,a});err==nil { t.Fatal("duplicate within batch accepted") } +} + +func TestPurchaseAuthorizationBindings(t *testing.T) { + for _,tc:=range []struct{ + name string + change func(*Purchase) + }{ + {"amount",func(p *Purchase){p.Amount++}}, + {"recipient",func(p *Purchase){p.Recipient="not-an-address"}}, + {"height",func(p *Purchase){p.Height++}}, + {"parent",func(p *Purchase){p.Parent=crypto.HashBytes([]byte("wrong"))}}, + {"genesis",func(p *Purchase){p.Genesis=crypto.HashBytes([]byte("other"))}}, + {"order",func(p *Purchase){p.OrderHash=crypto.HashBytes([]byte("other"))}}, + {"payment",func(p *Purchase){p.PaymentHash=crypto.HashBytes([]byte("other"))}}, + {"signature",func(p *Purchase){p.Signature[0]^=1}}, + {"unsigned",func(p *Purchase){p.Signature=nil}}, + }{ + t.Run(tc.name,func(t *testing.T){ + f:=setup(t); s:=f.ledger(t); p:=f.purchase(t,"a","p",100,101,s.Hash());tc.change(&p) + if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err==nil {t.Fatal("tampered purchase accepted")} + }) + } +} + +func TestRecipientMustBeOnAttestedNetwork(t *testing.T) { + f:=setup(t); s:=f.ledger(t) + wallet,err:=crypto.GenerateWalletKeys() + if err!=nil {t.Fatal(err)} + p:=f.purchase(t,"order","payment",100,101,s.Hash()) + p.Recipient=crypto.AddressFromKeys(crypto.TestnetByte,wallet) + p.Signature,err=f.merchant.Sign(p.Message()) + if err!=nil {t.Fatal(err)} + if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err==nil {t.Fatal("signed payout to another network accepted")} +} + +func TestWitnessAuthorityIsCopied(t *testing.T) { + f:=setup(t); s:=f.ledger(t) + p:=f.purchase(t,"a","p",100,101,s.Hash()) + for i:=range f.c.SaleAuthority {f.c.SaleAuthority[i]=0} + if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err!=nil {t.Fatalf("caller mutated internal authority: %v",err)} +} diff --git a/sale/allocation_epoch.go b/sale/allocation_epoch.go new file mode 100644 index 0000000..b8208d8 --- /dev/null +++ b/sale/allocation_epoch.go @@ -0,0 +1,45 @@ +package sale + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" +) + +// AllocationEpochBasis opens an operator-attested NOMINAL allocation ledger. +// It is not the net-holdings basis and does not assert that all legacy and new +// coins together fit under CapNAPRO. Legacy holdings remain unknown, not zero. +// Funding identifies the existing canonical LPoD epoch. Principal is protected +// unchanged and recorded explicitly, not newly minted or charged twice. +type AllocationEpochBasis struct { + FundingHeight uint64 `json:"funding_height"` + FundingHash crypto.Hash32 `json:"funding_hash"` + Burned uint64 `json:"burned_napro,string"` + Available uint64 `json:"available_napro,string"` + ProtectedLPoDPrincipal uint64 `json:"protected_lpod_principal_napro,string"` + LegacyHoldingsUnverified bool `json:"legacy_holdings_unverified"` +} + +func (e AllocationEpochBasis) Validate() error { + if e.FundingHash == zeroHash || !e.LegacyHoldingsUnverified || + e.Available == 0 || e.Available > CapNAPRO || e.Burned > CapNAPRO || + e.ProtectedLPoDPrincipal > CapNAPRO { + return fmt.Errorf("sale: explicit nominal LPoD epoch and unverified legacy holdings required") + } + return nil +} + +func (e AllocationEpochBasis) Digest() crypto.Hash32 { + return crypto.HashBytes( + []byte("aperod/sale/operator-allocation-epoch/legacy-holdings-unverified/v1"), + u64(e.FundingHeight), e.FundingHash[:], u64(e.Burned), u64(e.Available), + u64(e.ProtectedLPoDPrincipal), []byte{boolByte(e.LegacyHoldingsUnverified)}, + ) +} + +func boolByte(b bool) byte { + if b { + return 1 + } + return 0 +} diff --git a/sale/canonical.go b/sale/canonical.go new file mode 100644 index 0000000..54d1e28 --- /dev/null +++ b/sale/canonical.go @@ -0,0 +1,57 @@ +package sale + +import ( + "fmt" + "math" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// ApplyCanonicalTransactions validates actual confidential outputs as well as +// their debits. This remains a pure transition: it does NOT persist a block, +// create a durable UTXO, enable consensus activation or authorize invoices. +func (s *Ledger) ApplyCanonicalTransactions(height uint64, parent, blockHash crypto.Hash32, + transactions []*core.Transaction) (*Ledger, error) { + if s == nil || s.seen == nil || s.paid == nil || height == 0 || height != s.height+1 || + parent != s.hash || blockHash == zeroHash || blockHash == parent { + return nil, fmt.Errorf("sale: missing or noncanonical ledger parent") + } + next := &Ledger{checkpoint: s.checkpoint, height: height, hash: blockHash, + remaining: s.remaining, sold: s.sold, + seen: make(map[crypto.Hash32]struct{}, len(s.seen)+len(transactions)), + paid: make(map[crypto.Hash32]struct{}, len(s.paid)+len(transactions))} + for id := range s.seen { + next.seen[id] = struct{}{} + } + for id := range s.paid { + next.paid[id] = struct{}{} + } + for _, tx := range transactions { + if tx == nil { + return nil, fmt.Errorf("sale: nil authorized transaction") + } + a, err := tx.SaleAuthorization() + if err != nil { + return nil, err + } + if a.Genesis != s.checkpoint.Genesis || a.Height != height || a.Parent != parent || + !s.checkpoint.SaleAuthority.Verify(tx.SaleSigningMessage(), a.Signature[:]) { + return nil, fmt.Errorf("sale: unauthorized output or activation context") + } + if _, found := next.seen[a.Order]; found { + return nil, fmt.Errorf("sale: purchase order already consumed") + } + if _, found := next.paid[a.Payment]; found { + return nil, fmt.Errorf("sale: payment already consumed") + } + if a.Amount > next.remaining || a.Amount > math.MaxUint64-next.sold { + return nil, fmt.Errorf("sale: insufficient allocation") + } + next.seen[a.Order] = struct{}{} + next.paid[a.Payment] = struct{}{} + next.remaining -= a.Amount + next.sold += a.Amount + } + return next, nil +} diff --git a/sale/canonical_test.go b/sale/canonical_test.go new file mode 100644 index 0000000..921fea1 --- /dev/null +++ b/sale/canonical_test.go @@ -0,0 +1,80 @@ +package sale + +import ( + "bytes" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "testing" +) + +func TestCanonicalSaleBindsOutputAuthorityBudgetAndReplayIdentifiers(t *testing.T) { + f := setup(t) + ledger, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height, f.trusted) + if err != nil { + t.Fatal(err) + } + a := core.SaleAuthorization{Genesis: f.c.Genesis, Height: f.c.Height, Parent: f.c.Parent, + Order: crypto.HashBytes([]byte("opaque-order")), Payment: crypto.HashBytes([]byte("opaque-payment")), Amount: 10000000000} + tx, err := core.BuildSaleTransaction(a, f.address, f.c.Network, f.merchant) + if err != nil { + t.Fatal(err) + } + if tx.IsCoinbase() { + t.Fatal("sale must not inherit the legacy mint bypass") + } + if err = tx.Validate(); err != nil { + t.Fatal(err) + } + if bytes.Contains(tx.Extra, []byte(f.address)) { + t.Fatal("recipient address leaked in public authorization") + } + blockHash := crypto.HashBytes([]byte("sale-block")) + next, err := ledger.ApplyCanonicalTransactions(f.c.Height, f.c.Parent, blockHash, []*core.Transaction{tx}) + if err != nil { + t.Fatal(err) + } + if next.Remaining() != f.c.SaleRemaining-a.Amount || next.Sold() != a.Amount || + ledger.Remaining() != f.c.SaleRemaining { + t.Fatal("non-atomic or wrong debit transition") + } + if _, err = ledger.ApplyCanonicalTransactions(f.c.Height, f.c.Parent, blockHash, []*core.Transaction{tx, tx}); err == nil { + t.Fatal("duplicate sale accepted in the same block") + } + a.Height++ + a.Parent = blockHash + replay, err := core.BuildSaleTransaction(a, f.address, f.c.Network, f.merchant) + if err != nil { + t.Fatal(err) + } + if _, err = next.ApplyCanonicalTransactions(a.Height, a.Parent, crypto.HashBytes([]byte("next")), []*core.Transaction{replay}); err == nil { + t.Fatal("old order/payment re-signed for another block was accepted") + } + // Mutate the actual recipient output, not only an unsigned input field. + mutated := *tx + mutated.Outputs = append([]core.Output(nil), tx.Outputs...) + otherWallet, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + other, err := core.BuildSaleTransaction(a, crypto.AddressFromKeys(f.c.Network, otherWallet), f.c.Network, f.merchant) + if err != nil { + t.Fatal(err) + } + mutated.Outputs[0].OneTimePub = other.Outputs[0].OneTimePub + if _, err = ledger.ApplyCanonicalTransactions(f.c.Height, f.c.Parent, blockHash, []*core.Transaction{&mutated}); err == nil { + t.Fatal("recipient substitution accepted") + } + wrongKey, _, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + a.Height = f.c.Height + a.Parent = f.c.Parent + forged, err := core.BuildSaleTransaction(a, f.address, f.c.Network, wrongKey) + if err != nil { + t.Fatal(err) + } + if _, err = ledger.ApplyCanonicalTransactions(f.c.Height, f.c.Parent, blockHash, []*core.Transaction{forged}); err == nil { + t.Fatal("unauthorized issuer accepted") + } +} diff --git a/sale/net_allocation.go b/sale/net_allocation.go new file mode 100644 index 0000000..3fcdf38 --- /dev/null +++ b/sale/net_allocation.go @@ -0,0 +1,28 @@ +package sale + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" +) + +// NetAllocationBasis is an explicitly authority-attested forward accounting +// basis, not a proof of pre-checkpoint history. WalletNet accounts for issued +// holdings OUTSIDE the separately reserved LPoD principal and reward balance, +// including validator self-stake. Burned and WalletNet must use one baseline. +type NetAllocationBasis struct { + Burned uint64 `json:"burned_napro,string"` + WalletNet uint64 `json:"outside_wallet_net_napro,string"` + WalletAccountingRoot crypto.Hash32 `json:"wallet_accounting_root"` +} + +func (n NetAllocationBasis) Consumed() (uint64, error) { + if n.WalletAccountingRoot == zeroHash || n.Burned > CapNAPRO || n.WalletNet > CapNAPRO-n.Burned { + return 0, fmt.Errorf("sale: attested post-burn wallet accounting required") + } + return n.Burned + n.WalletNet, nil +} + +func (n NetAllocationBasis) Digest() crypto.Hash32 { + return crypto.HashBytes([]byte("aperod/sale/post-burn-wallet-basis/v2"), u64(n.Burned), u64(n.WalletNet), n.WalletAccountingRoot[:]) +} diff --git a/sale/reconciliation.go b/sale/reconciliation.go new file mode 100644 index 0000000..520f193 --- /dev/null +++ b/sale/reconciliation.go @@ -0,0 +1,136 @@ +package sale + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" +) + +// The initial protocol sale allocation must preserve the separately earmarked +// Guardian allocation within the primary 10B cap. +const MinimumGuardianReserveNAPRO uint64 = 1_000_000_000 * 100_000_000 + +// Reconciliation is a forward, authority-attested accounting declaration, not +// historical proof. Its LPoD commitment must be obtained from the canonical +// finalized activation parent, never supplied by a purchase API or explorer. +// Categories are disjoint: locked issued principal belongs to HistoricIssued, +// not to an independently spendable reserve as well. +type Reconciliation struct { + // Non-nil selects an EXPLICIT new signed accounting basis. Legacy witnesses + // keep their original domain and interpretation; there is no implicit upgrade. + NetAllocation *NetAllocationBasis `json:"net_allocation,omitempty"` + // Explicitly signed nominal budget, not an implicit V1/V2 reinterpretation. + // HistoricIssued represents its consumed burn counter, not legacy holdings. + AllocationEpoch *AllocationEpochBasis `json:"allocation_epoch,omitempty"` + Genesis crypto.Hash32 + Parent crypto.Hash32 + LPoDCommitment crypto.Hash32 + OtherClaimsRoot crypto.Hash32 + HistoricIssued uint64 + ValidatorReserved uint64 + LPoDReserved uint64 + GuardianReserved uint64 + OtherReserved uint64 +} + +func (r Reconciliation) Digest() crypto.Hash32 { + legacy := crypto.HashBytes( + []byte("aperod/sale/disjoint-forward-reconciliation/v1"), + r.Genesis[:], r.Parent[:], r.LPoDCommitment[:], r.OtherClaimsRoot[:], + u64(r.HistoricIssued), u64(r.ValidatorReserved), u64(r.LPoDReserved), + u64(r.GuardianReserved), u64(r.OtherReserved), + ) + if r.AllocationEpoch != nil { + epoch := r.AllocationEpoch.Digest() + if r.NetAllocation != nil { + net := r.NetAllocation.Digest() + return crypto.HashBytes([]byte("aperod/sale/invalid-mixed-allocation-bases/v1"), legacy[:], epoch[:], net[:]) + } + return crypto.HashBytes([]byte("aperod/sale/operator-epoch-reconciliation/v3"), legacy[:], epoch[:]) + } + if r.NetAllocation == nil { + return legacy + } + net := r.NetAllocation.Digest() + return crypto.HashBytes([]byte("aperod/sale/disjoint-forward-reconciliation/v2"), legacy[:], net[:]) +} + +func (r Reconciliation) Unassigned() (uint64, uint64, error) { + if r.AllocationEpoch != nil { + if r.NetAllocation != nil { + return 0, 0, fmt.Errorf("sale: mixed allocation bases") + } + if err := r.AllocationEpoch.Validate(); err != nil { + return 0, 0, err + } + if r.HistoricIssued != r.AllocationEpoch.Burned { + return 0, 0, fmt.Errorf("sale: nominal epoch burn counter mismatch") + } + } + if r.NetAllocation != nil { + consumed, err := r.NetAllocation.Consumed() + if err != nil { + return 0, 0, err + } + if consumed != r.HistoricIssued { + return 0, 0, fmt.Errorf("sale: post-burn consumed allocation mismatch") + } + } + remaining := CapNAPRO + for _, claim := range []uint64{r.HistoricIssued, r.ValidatorReserved, r.LPoDReserved, r.GuardianReserved, r.OtherReserved} { + if claim > remaining { + return 0, 0, fmt.Errorf("sale: overlapping declarations or reserves exceed the 10B cap") + } + remaining -= claim + } + if r.AllocationEpoch != nil && remaining != r.AllocationEpoch.Available { + return 0, 0, fmt.Errorf("sale: declared available allocation does not reconcile nominal reserves") + } + return remaining, CapNAPRO - remaining - r.HistoricIssued, nil +} + +// VerifyReconciledCheckpoint requires a caller-established canonical LPoD +// commitment and the already attested minimum nominal issuance. It cannot +// reduce that issuance to make the public available-allocation headline fit. +// This is ONLY an activation preflight; it does not grant payment acceptance. +func VerifyReconciledCheckpoint(c Checkpoint, r Reconciliation, + genesis, parent, canonicalLPoD crypto.Hash32, height, lpodDeclaredIssued uint64, + trusted []crypto.ValidatorPubKey) error { + if r.AllocationEpoch != nil { + if r.NetAllocation != nil { + return fmt.Errorf("sale: mixed allocation bases") + } + if err := r.AllocationEpoch.Validate(); err != nil { + return err + } + if r.AllocationEpoch.FundingHeight >= height { + return fmt.Errorf("sale: LPoD epoch is not before sale activation") + } + if c.SaleRemaining != r.AllocationEpoch.Available { + return fmt.Errorf("sale: epoch checkpoint must open the current available allocation") + } + lpodDeclaredIssued = r.AllocationEpoch.Burned + } + if r.NetAllocation != nil { + // The V3 nominal unlocked allocation is not independently proved wallet + // issuance. Only an explicitly signed V2 baseline can change this reading. + consumed, err := r.NetAllocation.Consumed() + if err != nil { + return err + } + lpodDeclaredIssued = consumed + } + if canonicalLPoD == zeroHash || r.LPoDCommitment != canonicalLPoD || + r.OtherClaimsRoot == zeroHash || r.Genesis != genesis || r.Parent != parent || + r.HistoricIssued < lpodDeclaredIssued || c.SnapshotRoot != r.Digest() { + return fmt.Errorf("sale: reconciliation is not bound to the canonical LPoD parent and declared issuance") + } + available, reserved, err := r.Unassigned() + if err != nil { + return err + } + if c.HistoricIssued != r.HistoricIssued || c.OtherReserved != reserved || c.SaleRemaining > available { + return fmt.Errorf("sale: checkpoint does not reconcile its disjoint allocation claims") + } + return c.Verify(genesis, parent, height, trusted) +} diff --git a/sale/reconciliation_test.go b/sale/reconciliation_test.go new file mode 100644 index 0000000..22e9980 --- /dev/null +++ b/sale/reconciliation_test.go @@ -0,0 +1,56 @@ +package sale + +import ( + "testing" + + "github.com/aperod/aperod/crypto" +) + +func TestReconciliationBindsLPoDAndDisjointClaims(t *testing.T) { + f := setup(t) + lpod := crypto.HashBytes([]byte("canonical-lpod-checkpoint")) + r := Reconciliation{Genesis: f.c.Genesis, Parent: f.c.Parent, + LPoDCommitment: lpod, OtherClaimsRoot: crypto.HashBytes([]byte("claims")), + HistoricIssued: CapNAPRO / 2, ValidatorReserved: CapNAPRO / 10, + LPoDReserved: CapNAPRO / 10, GuardianReserved: CapNAPRO / 20} + available, reserved, err := r.Unassigned() + if err != nil || available != CapNAPRO/4 { + t.Fatalf("wrong budget: %d %v", available, err) + } + f.c.SnapshotRoot, f.c.OtherReserved, f.c.SaleRemaining = r.Digest(), reserved, available + f.signCheckpoint(t) + check := func(r Reconciliation, lpod crypto.Hash32, floor uint64) error { + return VerifyReconciledCheckpoint(f.c, r, f.c.Genesis, f.c.Parent, lpod, f.c.Height, floor, f.trusted) + } + if err := check(r, lpod, r.HistoricIssued); err != nil { + t.Fatal(err) + } + changed := r + changed.GuardianReserved++ + if check(changed, lpod, r.HistoricIssued) == nil { + t.Fatal("unsigned reserve alteration accepted") + } + if check(r, crypto.HashBytes([]byte("other-lpod")), r.HistoricIssued) == nil { + t.Fatal("unrelated LPoD commitment accepted") + } + if check(r, lpod, r.HistoricIssued+1) == nil { + t.Fatal("earlier attested issuance silently reduced") + } + f.c.SaleRemaining++ + f.signCheckpoint(t) + if check(r, lpod, r.HistoricIssued) == nil { + t.Fatal("signed over-allocation accepted") + } +} + +func TestReconciliationRejectsOverflowAndNominalHeadline(t *testing.T) { + for _, r := range []Reconciliation{ + {HistoricIssued: 6_230_000_000 * 100_000_000, OtherReserved: 6_864_595_139 * 100_000_000}, + {HistoricIssued: 1, GuardianReserved: ^uint64(0)}, + {HistoricIssued: CapNAPRO, ValidatorReserved: 1}, + } { + if _, _, err := r.Unassigned(); err == nil { + t.Fatal("overlapping/overflowing claims accepted") + } + } +} diff --git a/sale/state.go b/sale/state.go new file mode 100644 index 0000000..c409268 --- /dev/null +++ b/sale/state.go @@ -0,0 +1,58 @@ +package sale + +import ( + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// State is constant-sized. Replay identities belong in the transactional store, +// not a ledger map copied in full on every block. +type State struct { + Checkpoint crypto.Hash32 `json:"checkpoint"` + Height uint64 `json:"height"` + Hash crypto.Hash32 `json:"hash"` + Remaining uint64 `json:"remaining_napro,string"` + Sold uint64 `json:"sold_napro,string"` +} + +func InitialState(c Checkpoint) State { + return State{Checkpoint: c.Message(), Height: c.Height - 1, Hash: c.Parent, Remaining: c.SaleRemaining} +} + +// Advance requires an already authenticated checkpoint. The caller must commit +// the returned state and both identity fences in the same batch as the outputs. +func (s State) Advance(c Checkpoint, height uint64, parent, hash crypto.Hash32, + txs []*core.Transaction, consumed func(byte, crypto.Hash32) (bool, error)) (State, error) { + if s.Checkpoint != c.Message() || s.Height+1 != height || height < c.Height || + s.Hash != parent || hash == zeroHash || hash == parent || + s.Remaining > c.SaleRemaining || s.Sold != c.SaleRemaining-s.Remaining || consumed == nil { + return State{}, fmt.Errorf("sale: inconsistent canonical state") + } + ledger := &Ledger{checkpoint: c, height: s.Height, hash: s.Hash, remaining: s.Remaining, + sold: s.Sold, seen: map[crypto.Hash32]struct{}{}, paid: map[crypto.Hash32]struct{}{}} + for _, tx := range txs { + a, err := tx.SaleAuthorization() + if err != nil { + return State{}, err + } + for _, ref := range []struct { + kind byte + id crypto.Hash32 + }{{'o', a.Order}, {'p', a.Payment}} { + used, err := consumed(ref.kind, ref.id) + if err != nil { + return State{}, err + } + if used { + return State{}, fmt.Errorf("sale: durable replay fence consumed") + } + } + } + next, err := ledger.ApplyCanonicalTransactions(height, parent, hash, txs) + if err != nil { + return State{}, err + } + return State{Checkpoint: s.Checkpoint, Height: height, Hash: hash, Remaining: next.remaining, Sold: next.sold}, nil +} diff --git a/store/genesis_vesting.go b/store/genesis_vesting.go new file mode 100644 index 0000000..0d1bce9 --- /dev/null +++ b/store/genesis_vesting.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package store + +import ( + "fmt" + "time" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" +) + +// This is a consensus schedule, not administrator-editable display metadata. +// The reserve is reclassified out of the non-circulating genesis remainder +// bound by the ALREADY attested nominal snapshot. The Guardian's eligible +// circulating budget is distinct and MUST NOT be debited or reused. +const GenesisVestingRecipient crypto.Address = "aproegAWbHEE8wunGatJwGLM68f3hskmbQmT8HJik9s827DymS2jScRb2hxqAgD34qfXwfQKKDCoj2okTWeVwsi2CXtA9Ybr76" +const GenesisVestingReserve uint64 = 849_010_000 * lpod.Unit +const GenesisVestingBasis = "attested-genesis-team-allocation-reclassification/v1" +const GenesisNominalTotal uint64 = 10_000_000_000 * lpod.Unit + +// GenesisVestingState is part of the signed accounting checkpoint. Canonical +// tip selection therefore rolls back both payments and their reserve debit. +// Heights, rather than local job flags, provide restart/downtime idempotency. +type GenesisVestingState struct { + Basis string `json:"basis"` + Genesis crypto.Hash32 `json:"genesis"` + FundingHeight uint64 `json:"funding_height"` + FundingParent crypto.Hash32 `json:"funding_parent"` + Recipient crypto.Address `json:"recipient"` + FundingDebit uint64 `json:"funding_debit_napro,string"` + SourceSnapshot crypto.Hash32 `json:"source_snapshot"` + FundingProof *GenesisVestingFunding `json:"funding_proof"` + OriginalNonCirculating uint64 `json:"original_nominal_noncirculating_napro,string"` + OtherNonCirculating uint64 `json:"other_nominal_noncirculating_napro,string"` + Remaining uint64 `json:"remaining_napro,string"` + Paid uint64 `json:"paid_napro,string"` + PaidHeights [10]uint64 `json:"paid_heights"` + LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` + LiquidityPaid uint64 `json:"liquidity_paid_napro,string,omitempty"` +} + +func GenesisVestingTranche(index int) (int64, uint64, error) { + if index < 0 || index >= 10 { + return 0, 0, fmt.Errorf("genesis vesting: invalid tranche") + } + amount := uint64(84_900_000 * lpod.Unit) + if index == 9 { + amount = 84_910_000 * lpod.Unit + } + return time.Date(2027+index, time.February, 10, 0, 0, 0, 0, time.UTC).UnixNano(), amount, nil +} + +func (v *GenesisVestingState) Validate(genesis crypto.Hash32, height uint64) error { + if v == nil { + return nil + } + if v.Basis != GenesisVestingBasis || v.Genesis != genesis || genesis == (crypto.Hash32{}) || + v.FundingParent == (crypto.Hash32{}) || v.FundingHeight == 0 || v.FundingHeight > height || + v.Recipient != GenesisVestingRecipient || v.FundingDebit != GenesisVestingReserve || + v.SourceSnapshot == (crypto.Hash32{}) || v.OriginalNonCirculating < GenesisVestingReserve || + v.OriginalNonCirculating > GenesisNominalTotal || v.OtherNonCirculating != v.OriginalNonCirculating-GenesisVestingReserve { + return fmt.Errorf("genesis vesting: invalid funded allocation") + } + var paid uint64 + var previous uint64 + gap := false + for i, h := range v.PaidHeights { + if h == 0 { + gap = true + continue + } + if gap || h < v.FundingHeight || h > height || h < previous { + return fmt.Errorf("genesis vesting: invalid payment sequence") + } + _, amount, _ := GenesisVestingTranche(i) + paid += amount // ten fixed tranches cannot overflow uint64 + previous = h + } + if paid != v.Paid || paid > GenesisVestingReserve || v.Remaining != GenesisVestingReserve-paid { + return fmt.Errorf("genesis vesting: non-conserved reserve") + } + if v.FundingProof != nil && v.FundingProof.Version == 2 { + if v.LiquidityRecipient != v.FundingProof.LiquidityRecipient || v.LiquidityPaid != GenesisLiquidityAmount { + return fmt.Errorf("genesis vesting: liquidity release differs from exclusive funding intent") + } + } else if v.LiquidityPaid != 0 || v.LiquidityRecipient != "" { + return fmt.Errorf("genesis vesting: undeclared liquidity release") + } + return nil +} + +func nominalNonCirculatingBound(a *LPoDAllocation) (uint64, error) { + if a == nil || a.Version != 3 || a.NominalSnapshot == nil || + !a.NominalSnapshot.valid(a.NominalEligible, a.InitialValidatorRemaining, a.SnapshotRoot) { + return 0, fmt.Errorf("genesis vesting: authenticated v3 nominal snapshot required") + } + circulating := a.NominalSnapshot.NominalCirculatingBefore + if circulating > GenesisNominalTotal || a.InitialValidatorRemaining > GenesisNominalTotal-circulating { + return 0, fmt.Errorf("genesis vesting: original 10B allocation exceeded") + } + // Guardian's 1B was a reclassification INSIDE circulating, not an + // additional allocation. Validator remaining is outside that circulation. + return GenesisNominalTotal-circulating-a.InitialValidatorRemaining, nil +} + +func (v *GenesisVestingState) validateSource(a *LPoDAllocation, height uint64) error { + if v == nil { return nil } + if a == nil { return fmt.Errorf("genesis vesting: missing allocation") } + if err := v.Validate(a.Genesis, height); err != nil { return err } + if err := v.FundingProof.validate(a, v.FundingHeight); err != nil { return err } + bound, err := nominalNonCirculatingBound(a) + if err != nil { return err } + if v.OriginalNonCirculating != bound || v.SourceSnapshot != a.SnapshotRoot || + v.FundingHeight <= a.FundingHeight { + return fmt.Errorf("genesis vesting: source differs from signed genesis accounting") + } + return nil +} + +// previewGenesisVesting never changes a loaded checkpoint. Funding and +// payments only become effective through the existing atomic canonical batch. +func previewGenesisVesting(before, next *LPoDCheckpoint, r *LPoDSettlement, height uint64) error { + if before.GenesisVesting == nil && (r.GenesisVestingActivationHeight == 0 || height < r.GenesisVestingActivationHeight) { + return nil + } + if before.Allocation == nil || before.Allocation.Version != 3 || !r.PositionProtocol { + return fmt.Errorf("genesis vesting: funded v3 canonical nominal allocation required") + } + if before.GenesisVesting == nil { + if err := r.GenesisVestingFunding.verifyQuorum(before.Allocation, height, r.Stake); err != nil { return err } + bound, err := nominalNonCirculatingBound(before.Allocation) + if err != nil { return err } + if height != r.GenesisVestingActivationHeight || height <= before.Allocation.FundingHeight || + bound < GenesisVestingReserve || r.Parent == (crypto.Hash32{}) { + return fmt.Errorf("genesis vesting: invalid activation or insufficient unallocated genesis budget") + } + if _, _, _, err := crypto.DecodeAddress(GenesisVestingRecipient); err != nil { + return fmt.Errorf("genesis vesting: invalid recipient: %w", err) + } + next.GenesisVesting = &GenesisVestingState{ + Basis: GenesisVestingBasis, Genesis: before.Allocation.Genesis, + FundingHeight: height, FundingParent: r.Parent, Recipient: GenesisVestingRecipient, + FundingDebit: GenesisVestingReserve, Remaining: GenesisVestingReserve, + SourceSnapshot: before.Allocation.SnapshotRoot, OriginalNonCirculating: bound, + FundingProof: r.GenesisVestingFunding, + OtherNonCirculating: bound-GenesisVestingReserve, + } + if r.GenesisVestingFunding.Version == 2 { + next.GenesisVesting.LiquidityRecipient = r.GenesisVestingFunding.LiquidityRecipient + next.GenesisVesting.LiquidityPaid = GenesisLiquidityAmount + } + } else { + if r.GenesisVestingActivationHeight != before.GenesisVesting.FundingHeight { + return fmt.Errorf("genesis vesting: activation cannot be disabled or changed after funding") + } + if err := before.GenesisVesting.validateSource(before.Allocation, before.State.LastHeight); err != nil { + return err + } + copyState := *before.GenesisVesting + next.GenesisVesting = ©State + } + v := next.GenesisVesting + for i := range v.PaidHeights { + deadline, amount, _ := GenesisVestingTranche(i) + if v.PaidHeights[i] != 0 || r.Timestamp < deadline { + continue + } + if v.Remaining < amount { + return fmt.Errorf("genesis vesting: reserve exhausted") + } + v.PaidHeights[i] = height + v.Paid += amount + v.Remaining -= amount + } + return v.validateSource(next.Allocation, height) +} + +func (v *GenesisVestingState) paymentAt(height uint64) uint64 { + if v == nil || height == 0 { + return 0 + } + var amount uint64 + for i, h := range v.PaidHeights { + if h == height { + _, n, _ := GenesisVestingTranche(i) + amount += n + } + } + return amount +} \ No newline at end of file diff --git a/store/genesis_vesting_funding.go b/store/genesis_vesting_funding.go new file mode 100644 index 0000000..3312b58 --- /dev/null +++ b/store/genesis_vesting_funding.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package store + +import ( + "encoding/json" + "fmt" + "math/big" + "os" + + "github.com/aperod/aperod/crypto" +) + +const GenesisVestingTrust = "operator-attested-exclusive-unissued-development-genesis-reserve/v1" +const GenesisLiquidityTrust = "operator-attested-exclusive-unissued-initial-liquidity-genesis-allocation/v1" +const GenesisLiquidityAmount uint64 = 300_000_000 * 100_000_000 + +// Circulation arithmetic only supplies an upper bound, NOT source eligibility. +// This distinct, purpose-bound governance attestation explicitly declares that +// the Development Fund reserve is unissued and excludes Guardian, validator, +// user balances and every other allocation. Historical proof remains false. +type GenesisVestingFunding struct { + Version uint32 `json:"version"` + Genesis crypto.Hash32 `json:"genesis"` + Snapshot crypto.Hash32 `json:"nominal_snapshot"` + Height uint64 `json:"activation_height"` + Recipient crypto.Address `json:"recipient"` + Reserve uint64 `json:"exclusive_unissued_development_napro,string"` + OtherAllocations uint64 `json:"other_genesis_allocations_napro,string"` + Trust string `json:"trust_assumption"` + LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` + LiquidityAmount uint64 `json:"liquidity_napro,string,omitempty"` + LiquidityTrust string `json:"liquidity_trust_assumption,omitempty"` + Attestations []LPoDAttestation `json:"attestations"` +} + +func (f *GenesisVestingFunding) Message() crypto.Hash32 { + if f == nil { return crypto.Hash32{} } + c := *f + c.Attestations = nil + raw, _ := json.Marshal(c) + return crypto.HashBytes(append([]byte("aperod/genesis-development-funding/v1\x00"), raw...)) +} + +func (f *GenesisVestingFunding) validate(a *LPoDAllocation, height uint64) error { + if f == nil || a == nil || (f.Version != 1 && f.Version != 2) || f.Genesis != a.Genesis || f.Snapshot != a.SnapshotRoot || + f.Height != height || f.Recipient != GenesisVestingRecipient || f.Reserve != GenesisVestingReserve || + f.Trust != GenesisVestingTrust || + len(f.Attestations) == 0 || len(f.Attestations) > 512 { + return fmt.Errorf("genesis vesting: separate purpose-bound funding attestation required") + } + if f.Version == 1 { + if f.OtherAllocations != GenesisNominalTotal-GenesisVestingReserve || + f.LiquidityAmount != 0 || f.LiquidityRecipient != "" || f.LiquidityTrust != "" { + return fmt.Errorf("genesis vesting: legacy declaration cannot authorize liquidity") + } + } else { + if f.OtherAllocations != GenesisNominalTotal-GenesisVestingReserve-GenesisLiquidityAmount || + f.LiquidityAmount != GenesisLiquidityAmount || f.LiquidityRecipient == "" || + f.LiquidityRecipient == GenesisVestingRecipient || f.LiquidityTrust != GenesisLiquidityTrust { + return fmt.Errorf("genesis vesting: distinct exclusive liquidity declaration required") + } + if network, _, _, err := crypto.DecodeAddress(f.LiquidityRecipient); err != nil || network != crypto.MainnetByte { + return fmt.Errorf("genesis vesting: invalid liquidity address: %w", err) + } + // Liquidity was already nominally available at the original snapshot; + // it is not taken from Guardian's fully earmarked eligible allocation. + if a.NominalSnapshot == nil || a.NominalSnapshot.NominalCirculatingBefore < a.NominalEligible || + a.NominalSnapshot.NominalCirculatingBefore-a.NominalEligible < GenesisLiquidityAmount { + return fmt.Errorf("genesis vesting: liquidity exceeds distinct nominal available bound") + } + } + seen := map[string]bool{} + for _, attestation := range f.Attestations { + id := attestation.Validator.Hex() + if seen[id] || !attestation.Validator.Verify(f.Message(), attestation.Signature) { + return fmt.Errorf("genesis vesting: invalid or duplicate funding signature") + } + seen[id] = true + } + return nil +} + +func (f *GenesisVestingFunding) verifyQuorum(a *LPoDAllocation, height uint64, stake map[string]LPoDValidatorStake) error { + if err := f.validate(a, height); err != nil { return err } + total, signed := new(big.Int), new(big.Int) + for _, s := range stake { + if s.Active { total.Add(total, new(big.Int).SetUint64(s.Amount)) } + } + for _, attestation := range f.Attestations { + s, ok := stake[attestation.Validator.Hex()] + if !ok || !s.Active || s.Amount == 0 { return fmt.Errorf("genesis vesting: funding signer is not an active validator") } + signed.Add(signed, new(big.Int).SetUint64(s.Amount)) + } + if total.Sign() == 0 || new(big.Int).Mul(signed, big.NewInt(3)).Cmp(new(big.Int).Mul(total, big.NewInt(2))) <= 0 { + return fmt.Errorf("genesis vesting: funding lacks canonical two-thirds quorum") + } + return nil +} + +func LoadGenesisVestingFunding(path string, height uint64) (*GenesisVestingFunding, error) { + raw, err := os.ReadFile(path) + if err != nil { return nil, err } + if len(raw) > 256*1024 { return nil, fmt.Errorf("genesis vesting: funding witness too large") } + var f GenesisVestingFunding + if err := json.Unmarshal(raw, &f); err != nil { return nil, err } + if f.Height != height || height == 0 { return nil, fmt.Errorf("genesis vesting: funding height mismatch") } + return &f, nil +} + +// Reject missed activations and disabling an already committed fund before +// starting consensus; do not silently run until the first production failure. +func (d *DB) CheckGenesisVestingConfig(height uint64, proof *GenesisVestingFunding) error { + c, err := d.LoadLPoDCheckpoint() + if err != nil { return err } + if c != nil && c.GenesisVesting != nil { + if height != c.GenesisVesting.FundingHeight || proof == nil || + proof.Message() != c.GenesisVesting.FundingProof.Message() { + return fmt.Errorf("genesis vesting: committed funding configuration cannot change") + } + return proof.validate(c.Allocation, height) + } + if height == 0 { + if proof != nil { return fmt.Errorf("genesis vesting: funding file without activation") } + return nil + } +saleState, err := d.LoadSaleState() +if err != nil { return err } +if saleState != nil { +return fmt.Errorf("genesis vesting: active sales require a versioned shared reserve transition") +} + _, tipHeight, err := d.GetTip() + if err != nil { return err } + if tipHeight >= height { return fmt.Errorf("genesis vesting: activation already missed") } + if c == nil { return fmt.Errorf("genesis vesting: canonical v3 funding prerequisite missing") } + if err := proof.validate(c.Allocation, height); err != nil { return err } + bound, err := nominalNonCirculatingBound(c.Allocation) + if err != nil { return err } + if bound < GenesisVestingReserve { return fmt.Errorf("genesis vesting: original nominal cap exhausted") } + return nil +} \ No newline at end of file diff --git a/store/genesis_vesting_test.go b/store/genesis_vesting_test.go new file mode 100644 index 0000000..5da27e0 --- /dev/null +++ b/store/genesis_vesting_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package store + +import ( + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" +) + +func vestingFixture(t *testing.T) (*LPoDCheckpoint, *LPoDSettlement) { + t.Helper() + key, pub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + g := crypto.HashBytes([]byte("vesting-test-genesis")) + snapshot := &LPoDNominalSnapshot{Basis: LPoDV3NominalBasis, NominalCirculatingBefore: 6_000_000_000*lpod.Unit, + EligibleNominal: lpod.InitialNAPRO, ValidatorRemaining: 2_000_000_000*lpod.Unit, EligibilityRule: LPoDV3EligibilityRule} + proof := &GenesisVestingFunding{Version: 1, Genesis: g, Snapshot: snapshot.Root(), Height: 11, + Recipient: GenesisVestingRecipient, Reserve: GenesisVestingReserve, + OtherAllocations: GenesisNominalTotal-GenesisVestingReserve, Trust: GenesisVestingTrust} + signature, err := key.Sign(proof.Message()) + if err != nil { t.Fatal(err) } + proof.Attestations = []LPoDAttestation{{Validator: pub, Signature: signature}} + return &LPoDCheckpoint{State: lpod.State{LastHeight: 10}, + Allocation: &LPoDAllocation{Version: 3, Genesis: g, FundingHeight: 2, Remaining: 0, + NominalEligible: lpod.InitialNAPRO, InitialValidatorRemaining: snapshot.ValidatorRemaining, + NominalSnapshot: snapshot, SnapshotRoot: snapshot.Root()}}, + &LPoDSettlement{Parent: crypto.HashBytes([]byte("canonical-parent")), PositionProtocol: true, + GenesisVestingActivationHeight: 11, GenesisVestingFunding: proof, + Stake: map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000*lpod.Unit, Active: true}}} +} + +func vestingStep(t *testing.T, before *LPoDCheckpoint, r *LPoDSettlement, height uint64) *LPoDCheckpoint { + t.Helper() + next := *before + a := *before.Allocation + next.Allocation = &a + next.State.LastHeight = height + if err := previewGenesisVesting(before, &next, r, height); err != nil { t.Fatal(err) } + return &next +} + +func TestGenesisVestingExactBoundaryAndConservation(t *testing.T) { + before, r := vestingFixture(t) + deadline, amount, _ := GenesisVestingTranche(0) + r.Timestamp = deadline-1 + funded := vestingStep(t, before, r, 11) + if funded.GenesisVesting.Paid != 0 || funded.Allocation.Remaining != 0 || + funded.GenesisVesting.OtherNonCirculating != 2_000_000_000*lpod.Unit-GenesisVestingReserve || + before.GenesisVesting != nil || before.Allocation.Remaining != 0 { + t.Fatal("funding, no-early-payment or immutable preview failed") + } + r.Timestamp = deadline + paid := vestingStep(t, funded, r, 12) + if paid.GenesisVesting.Paid != amount || paid.GenesisVesting.paymentAt(12) != amount || + paid.GenesisVesting.Remaining+paid.GenesisVesting.Paid != GenesisVestingReserve { + t.Fatal("exact deadline/amount or conservation failed") + } + repeated := vestingStep(t, paid, r, 13) + if repeated.GenesisVesting.Paid != amount || repeated.GenesisVesting.paymentAt(13) != 0 || + repeated.Allocation.Remaining != paid.Allocation.Remaining { + t.Fatal("duplicate payment or duplicate reserve debit") + } + if funded.GenesisVesting.Paid != 0 { t.Fatal("preview mutated the parent") } +} + +func TestGenesisVestingDowntimeCatchupAndFinalTranche(t *testing.T) { + before, r := vestingFixture(t) + r.Timestamp, _, _ = GenesisVestingTranche(2) + next := vestingStep(t, before, r, 11) + if next.GenesisVesting.Paid != 3*84_900_000*lpod.Unit { t.Fatal("missed annual tranches not caught up") } + r.Timestamp, _, _ = GenesisVestingTranche(9) + last := vestingStep(t, next, r, 12) + if last.GenesisVesting.Paid != GenesisVestingReserve || last.GenesisVesting.Remaining != 0 || + last.GenesisVesting.paymentAt(12) != 6*84_900_000*lpod.Unit+84_910_000*lpod.Unit { + t.Fatal("final 84.91M tranche or finite reserve is wrong") + } + again := vestingStep(t, last, r, 13) + if again.GenesisVesting.paymentAt(13) != 0 { t.Fatal("exhausted schedule paid again") } +} + +func TestGenesisVestingRejectsUnsafeFundingAndMutation(t *testing.T) { + for _, name := range []string{"insufficient", "legacy", "late", "parent", "non-position"} { + t.Run(name, func(t *testing.T) { + before, r := vestingFixture(t) + height := uint64(11) + switch name { + case "insufficient": + before.Allocation.NominalSnapshot.NominalCirculatingBefore = 7_500_000_000*lpod.Unit + before.Allocation.SnapshotRoot = before.Allocation.NominalSnapshot.Root() + case "legacy": before.Allocation.Version = 2 + case "late": height = 12 + case "parent": r.Parent = crypto.Hash32{} + case "non-position": r.PositionProtocol = false + } + next := *before + if previewGenesisVesting(before, &next, r, height) == nil { t.Fatal("unsafe funding accepted") } + }) + } + before, r := vestingFixture(t) + funded := vestingStep(t, before, r, 11) + for _, h := range []uint64{0, 12} { + r.GenesisVestingActivationHeight = h + next := *funded + if previewGenesisVesting(funded, &next, r, 12) == nil { t.Fatal("funding fork changed after commit") } + } + corrupt := *funded.GenesisVesting + corrupt.PaidHeights[1] = 11 + if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { t.Fatal("skipped tranche accepted") } + corrupt = *funded.GenesisVesting + corrupt.Remaining-- + if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { t.Fatal("lost reserve unit accepted") } +} + +func TestGenesisVestingFundingRequiresPurposeAndCanonicalQuorum(t *testing.T) { + for _, name := range []string{"missing", "wrong-purpose", "foreign-genesis", "bad-signature", "inactive", "insufficient-quorum"} { + t.Run(name, func(t *testing.T) { + before, r := vestingFixture(t) + key, pub, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + p := *r.GenesisVestingFunding + r.GenesisVestingFunding = &p + r.Stake = map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000*lpod.Unit, Active: true}} + switch name { + case "wrong-purpose": p.Trust = "unrestricted-mint" + case "foreign-genesis": p.Genesis = crypto.HashBytes([]byte("foreign-chain")) + case "inactive": r.Stake[pub.Hex()] = LPoDValidatorStake{Amount: 100_000*lpod.Unit} + case "insufficient-quorum": + _, other, err := crypto.GenerateValidatorKey() + if err != nil { t.Fatal(err) } + r.Stake[other.Hex()] = LPoDValidatorStake{Amount: 200_000*lpod.Unit, Active: true} + } + signature, err := key.Sign(p.Message()) + if err != nil { t.Fatal(err) } + p.Attestations = []LPoDAttestation{{Validator: pub, Signature: signature}} + if name == "bad-signature" { p.Attestations[0].Signature[0] ^= 1 } + if name == "missing" { r.GenesisVestingFunding = nil } + next := *before + if previewGenesisVesting(before, &next, r, 11) == nil { t.Fatal("unauthorized source declaration accepted") } + }) + } +} \ No newline at end of file diff --git a/store/leveldb.go b/store/leveldb.go index 1a4725e..e3949cb 100644 --- a/store/leveldb.go +++ b/store/leveldb.go @@ -3,13 +3,13 @@ package store import ( - cryptorand "crypto/rand" "encoding/binary" "encoding/json" "fmt" "math" "os" "path/filepath" + "sync" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" @@ -34,8 +34,10 @@ var ( // DB wraps a LevelDB database with typed methods for blockchain data. type DB struct { - db *leveldb.DB - path string + db *leveldb.DB + path string + canonicalMu sync.Mutex +ringMu sync.Mutex // serializes canonical rm/ and dense sampling-index writes } // Open opens or creates a LevelDB database at path. @@ -272,12 +274,20 @@ type StoredUTXO struct { // PutUTXO persists a UTXO. func (d *DB) PutUTXO(txHash crypto.Hash32, outIdx uint32, u *StoredUTXO) error { +if u == nil { +return fmt.Errorf("nil UTXO") +} +d.ringMu.Lock() +defer d.ringMu.Unlock() data, err := json.Marshal(u) if err != nil { return err } batch := new(leveldb.Batch) batch.Put(utxoKey(txHash, outIdx), data) +if err := d.stageRingSamplingChange(batch, u.OneTimePub, !u.ProtocolLocked); err != nil { +return err +} batch.Put(ringMemberKey(u.OneTimePub), data) return d.db.Write(batch, nil) } @@ -295,8 +305,11 @@ func (d *DB) GetUTXO(txHash crypto.Hash32, outIdx uint32) (*StoredUTXO, error) { return &u, nil } -// DeleteUTXO removes a UTXO (when spent). +// DeleteUTXO removes a rolled-back canonical output. Spending uses su/ markers, +// not this method: spent outputs must remain in the CLSAG anonymity set. func (d *DB) DeleteUTXO(txHash crypto.Hash32, outIdx uint32) error { +d.ringMu.Lock() +defer d.ringMu.Unlock() u, err := d.GetUTXO(txHash, outIdx) if err != nil { return err @@ -305,6 +318,9 @@ func (d *DB) DeleteUTXO(txHash crypto.Hash32, outIdx uint32) error { batch.Delete(utxoKey(txHash, outIdx)) batch.Delete(spentUTXOKey(txHash, outIdx)) if u != nil { +if err := d.stageRingSamplingChange(batch, u.OneTimePub, false); err != nil { +return err +} batch.Delete(ringMemberKey(u.OneTimePub)) } return d.db.Write(batch, &opt.WriteOptions{Sync: true}) @@ -335,13 +351,18 @@ func (d *DB) LookupRingMember(pub crypto.Point32) (*core.UTXO, error) { // EnsureRingMemberIndex backfills rm/ exactly once from the authoritative u/ // output store. New PutUTXO writes update both namespaces atomically. -func (d *DB) EnsureRingMemberIndex() error { +func (d *DB) ensureLegacyRingMemberIndex() error { marker := []byte("ring-member-index-v1") if ready, err := d.get(append(prefixMeta, marker...)); err != nil { return err } else if ready != nil { return nil } +// An unfinished legacy backfill must not leave an apparently ready ordinal +// index. Sampling stays unavailable until the complete rm/ store is indexed. +if err := d.db.Delete(ringSamplingCountKey, &opt.WriteOptions{Sync: true}); err != nil { +return err +} iter := d.db.NewIterator(util.BytesPrefix(prefixUTXO), nil) defer iter.Release() batch := new(leveldb.Batch) @@ -369,56 +390,6 @@ func (d *DB) EnsureRingMemberIndex() error { return d.db.Write(batch, &opt.WriteOptions{Sync: true}) } -// SampleRingMembers performs bounded-cost random seeks in the disk index. -// It does not materialize the complete historical output set in memory. -func (d *DB) SampleRingMembers(count int, exclude map[crypto.Point32]bool) ([]core.DecoyUTXO, error) { - if count <= 0 { - return nil, nil - } -const maxRingSampleCount = 4096 -if count > maxRingSampleCount { -return nil, fmt.Errorf("ring sample count exceeds maximum of %d", maxRingSampleCount) -} - out := make([]core.DecoyUTXO, 0, count) - seen := make(map[crypto.Point32]struct{}, count) - iter := d.db.NewIterator(util.BytesPrefix(prefixRingMember), nil) - defer iter.Release() - attempts := count * 8 - for len(out) < count && attempts > 0 { - attempts-- - var random [32]byte - if _, err := cryptorand.Read(random[:]); err != nil { - return nil, err - } - seek := append(append([]byte(nil), prefixRingMember...), random[:]...) - if !iter.Seek(seek) { - if !iter.First() { - break - } - } - var u StoredUTXO - if err := json.Unmarshal(iter.Value(), &u); err != nil { - return nil, err - } - if exclude[u.OneTimePub] { - continue - } - if _, duplicate := seen[u.OneTimePub]; duplicate { - continue - } - seen[u.OneTimePub] = struct{}{} - // Permanently locked consensus allocations are retained for audit but must - // never enter the CLSAG anonymity set. - if !u.ProtocolLocked { - out = append(out, core.DecoyUTXO{OneTimePub: u.OneTimePub, AmountCommit: u.AmountCommit}) - } - } - if err := iter.Error(); err != nil { - return nil, err - } - return out, nil -} - func storedToCoreUTXO(u *StoredUTXO) *core.UTXO { return &core.UTXO{ TxHash: u.TxHash, OutputIndex: u.OutputIndex, OneTimePub: u.OneTimePub, @@ -793,11 +764,16 @@ func (d *DB) LoadTxTotal() (int64, error) { // that a SIGKILL cannot leave tip/hash and tip/height out of sync with // each other or with the block data written by PutBlock/PutRawBlock. func (d *DB) PutTip(hash crypto.Hash32, height uint64) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() var hb [8]byte binary.LittleEndian.PutUint64(hb[:], height) hashKey := append(append([]byte{}, prefixMeta...), []byte("tip/hash")...) heightKey2 := append(append([]byte{}, prefixMeta...), []byte("tip/height")...) batch := new(leveldb.Batch) + if err := d.restoreSale(batch, hash, height); err != nil { + return err + } if err := d.restoreLPoDPool(batch, hash, height); err != nil { return err } @@ -876,13 +852,34 @@ func (d *DB) CommitRawBlockWithAVM( writeSetCommitment crypto.Hash32, lpodSettlement ...*LPoDSettlement, ) error { + return d.commitRawBlock(hash, height, data, writes, writeSetCommitment, nil, lpodSettlement) +} + +func (d *DB) CommitRawBlockWithSale(hash crypto.Hash32, height uint64, data []byte, writes []AVMWrite, + writeSetCommitment crypto.Hash32, saleSettlement *SaleSettlement, lpodSettlement ...*LPoDSettlement) error { + return d.commitRawBlock(hash, height, data, writes, writeSetCommitment, saleSettlement, lpodSettlement) +} + +func (d *DB) commitRawBlock(hash crypto.Hash32, height uint64, data []byte, writes []AVMWrite, + writeSetCommitment crypto.Hash32, saleSettlement *SaleSettlement, lpodSettlement []*LPoDSettlement) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() if err := d.validateLPoDBlock(data, hash, height, lpodSettlement); err != nil { return err } +if len(lpodSettlement) == 1 { +if err := d.checkSaleGenesisFundingTransition(height, lpodSettlement[0], +saleSettlement != nil && saleSettlement.Before == nil); err != nil { +return err +} +} batch, err := avmBatch(writes) if err != nil { return err } + if err := d.appendSaleSettlement(batch, data, hash, height, saleSettlement); err != nil { + return err + } // Reserve and carries share the same fsynced batch as the canonical tip. // With no approved source allocation there is no initial LPoD checkpoint. if err := d.appendLPoDSettlement(batch, hash, height, lpodSettlement); err != nil { diff --git a/store/lpod.go b/store/lpod.go index 6ded865..37a19e3 100644 --- a/store/lpod.go +++ b/store/lpod.go @@ -20,6 +20,7 @@ import ( // carry, rather than an independently persisted mutable "remaining" counter. // Initial funding requires a fully reconciled, quorum-attested allocation debit. type LPoDCheckpoint struct { +GenesisVesting *GenesisVestingState `json:"genesis_vesting,omitempty"` State lpod.State `json:"state"` Carries map[string]lpod.Carry `json:"carries"` Allocation *LPoDAllocation `json:"allocation,omitempty"` @@ -32,6 +33,8 @@ type LPoDCheckpoint struct { // LPoDSettlement is prepared from canonical registry/position state by consensus. // Store commits verify its clock, operations, checkpoint and actual payout plan. type LPoDSettlement struct { +GenesisVestingActivationHeight uint64 +GenesisVestingFunding *GenesisVestingFunding Parent crypto.Hash32 Vaults []lpod.Vault Migration *LPoDMigration @@ -142,6 +145,9 @@ func loadLPoDCheckpointWithReadBudget( } if c.Allocation != nil { a := c.Allocation +if c.GenesisVesting != nil { +if err := c.GenesisVesting.validateSource(a, c.State.LastHeight); err != nil { return nil, err } +} const publicAllocation = 9_000_000_000 * lpod.Unit const validatorAllocation = 2_000_000_000 * lpod.Unit if a.InitialValidatorRemaining > validatorAllocation || @@ -171,7 +177,7 @@ func loadLPoDCheckpointWithReadBudget( a.NominalEligible >= lpod.InitialNAPRO && a.NominalEligible <= LPoDV3NominalAllocationMaxNAPRO && a.NominalEligible <= publicAllocation-a.InitialValidatorRemaining && - a.Remaining == a.NominalEligible-lpod.InitialNAPRO && +a.Remaining == a.NominalEligible-lpod.InitialNAPRO && a.SnapshotRoot != (crypto.Hash32{}) && a.FundingParent != (crypto.Hash32{}) && a.Genesis != (crypto.Hash32{}) && a.TrustAssumption == LPoDV3TrustAssumption && @@ -304,6 +310,9 @@ type legacyLPoDAllocation struct { } func (d *DB) PreviewLPoD(height uint64, request *LPoDSettlement) (*LPoDCheckpoint, []lpod.Payment, error) { +if err := d.checkSaleGenesisFundingTransition(height, request, false); err != nil { +return nil, nil, err +} tip, _, err := d.GetTip() if err != nil || request == nil || request.Parent != tip { return nil, nil, fmt.Errorf("lpod: invalid preview parent") @@ -321,8 +330,11 @@ func (d *DB) PreviewLPoD(height uint64, request *LPoDSettlement) (*LPoDCheckpoin return nil, nil, fmt.Errorf("lpod: duplicate migration") } if request.PositionProtocol { - return d.previewPositions(before, height, request) +return d.previewPositions(before, height, request) } +if request.GenesisVestingActivationHeight != 0 || before.GenesisVesting != nil { +return nil, nil, fmt.Errorf("genesis vesting: position protocol required") +} next, carries, payments, err := lpod.Settle(before.State, before.Carries, height, request.Vaults) if err != nil { return nil, nil, err diff --git a/store/lpod_earnings_backfill.go b/store/lpod_earnings_backfill.go index 3b96795..3911948 100644 --- a/store/lpod_earnings_backfill.go +++ b/store/lpod_earnings_backfill.go @@ -386,9 +386,20 @@ func (d *DB) classifyHistoricalLPoDBlock(block *core.Block, m *LPoDMigration, fu for address := range principal { addressSet[address] = true } +var vestingAddress crypto.Address +var vestingAmount uint64 +if after.GenesisVesting != nil { +vestingAddress = after.GenesisVesting.Recipient +vestingAmount = after.GenesisVesting.paymentAt(block.Header.Height) +if vestingAmount > 0 { addressSet[vestingAddress] = true } +if after.GenesisVesting.FundingHeight == block.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { +addressSet[after.GenesisVesting.LiquidityRecipient] = true +} +} addresses := make([]string, 0, len(addressSet)) for address := range addressSet { - if rewards[address] > 0 || principal[address] > 0 { + if rewards[address] > 0 || principal[address] > 0 || (address == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { addresses = append(addresses, string(address)) } } @@ -401,6 +412,10 @@ func (d *DB) classifyHistoricalLPoDBlock(block *core.Block, m *LPoDMigration, fu for _, text := range addresses { address := crypto.Address(text) total, err := lpodAdd(rewards[address], principal[address]) +if err == nil && address == vestingAddress { total, err = lpodAdd(total, vestingAmount) } +if err == nil && after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { +total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) +} if err != nil { return nil, err } @@ -416,7 +431,8 @@ func (d *DB) classifyHistoricalLPoDBlock(block *core.Block, m *LPoDMigration, fu refs := make([]lpodEarningsRef, 0, len(addresses)) for i, text := range addresses { address := crypto.Address(text) - if !isLPoDPureReward(rewards[address], principal[address]) { + if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { continue } output := block.Txs[0].Outputs[i] diff --git a/store/lpod_positions.go b/store/lpod_positions.go index dfc2358..6533c82 100644 --- a/store/lpod_positions.go +++ b/store/lpod_positions.go @@ -461,6 +461,9 @@ func (d *DB) previewPositions(before *LPoDCheckpoint, height uint64, r *LPoDSett if err := next.validatePositions(); err != nil { return nil, nil, err } +if err := previewGenesisVesting(before, next, r, height); err != nil { +return nil, nil, err +} return next, payments, nil } @@ -524,7 +527,20 @@ func (d *DB) PayoutLPoD(height uint64, r *LPoDSettlement, after *LPoDCheckpoint, return core.Transaction{}, err } } - addresses := make([]string, 0, len(amounts)) +if after.GenesisVesting != nil { +if err := after.GenesisVesting.Validate(after.Allocation.Genesis, height); err != nil { +return core.Transaction{}, err +} +if err := add(after.GenesisVesting.Recipient, after.GenesisVesting.paymentAt(height)); err != nil { +return core.Transaction{}, err +} +if height == after.GenesisVesting.FundingHeight && after.GenesisVesting.LiquidityPaid > 0 { +if err := add(after.GenesisVesting.LiquidityRecipient, after.GenesisVesting.LiquidityPaid); err != nil { +return core.Transaction{}, err +} +} +} +addresses := make([]string, 0, len(amounts)) for a, n := range amounts { if n > 0 { addresses = append(addresses, string(a)) diff --git a/store/lpod_wallet_index.go b/store/lpod_wallet_index.go index 3682a68..de4ad69 100644 --- a/store/lpod_wallet_index.go +++ b/store/lpod_wallet_index.go @@ -77,6 +77,12 @@ func (d *DB) appendLPoDWalletIndex(batch *leveldb.Batch, b *core.Block, r *LPoDS return fmt.Errorf("lpod: funded checkpoint required for wallet discovery") } candidates := map[crypto.Address]bool{r.Leader: true} +if after.GenesisVesting != nil && after.GenesisVesting.paymentAt(b.Header.Height) > 0 { +candidates[after.GenesisVesting.Recipient] = true +} +if after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == b.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { +candidates[after.GenesisVesting.LiquidityRecipient] = true +} for _, p := range after.Positions { candidates[p.Deposit.Beneficiary] = true } @@ -254,8 +260,19 @@ func (d *DB) classifyLPoDEarnings(b *core.Block, r *LPoDSettlement, after *LPoDC for a := range principal { seen[a] = true } +var vestingAddress crypto.Address +var vestingAmount uint64 +if after.GenesisVesting != nil { +vestingAddress = after.GenesisVesting.Recipient +vestingAmount = after.GenesisVesting.paymentAt(b.Header.Height) +if vestingAmount > 0 { seen[vestingAddress] = true } +if after.GenesisVesting.FundingHeight == b.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { +seen[after.GenesisVesting.LiquidityRecipient] = true +} +} for a := range seen { - if rewards[a] > 0 || principal[a] > 0 { +if rewards[a] > 0 || principal[a] > 0 || (a == vestingAddress && vestingAmount > 0) || +(after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == b.Header.Height && a == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { addresses = append(addresses, string(a)) } } @@ -267,13 +284,18 @@ func (d *DB) classifyLPoDEarnings(b *core.Block, r *LPoDSettlement, after *LPoDC for i, text := range addresses { address := crypto.Address(text) total, err := lpodAdd(rewards[address], principal[address]) +if err == nil && address == vestingAddress { total, err = lpodAdd(total, vestingAmount) } +if err == nil && after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { +total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) +} if err != nil { return nil, err } if total == 0 { return nil, fmt.Errorf("lpod: zero canonical payout classification") } - if !isLPoDPureReward(rewards[address], principal[address]) { +if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || +(after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { // Mixed outputs must never be presented as earnings-only, and // principal-only outputs are not earnings. continue diff --git a/store/prune.go b/store/prune.go index a0e76c7..4a7a43a 100644 --- a/store/prune.go +++ b/store/prune.go @@ -77,7 +77,16 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { } } - if cursor >= pruneBelow { +protectedVestingHeights := map[uint64]bool{} +if hasFunding { +checkpoint, err := d.LoadLPoDCheckpoint() +if err != nil { return 0, fmt.Errorf("load reserve checkpoint before pruning: %w", err) } +if checkpoint != nil && checkpoint.GenesisVesting != nil { +protectedVestingHeights[checkpoint.GenesisVesting.FundingHeight] = true +for _, h := range checkpoint.GenesisVesting.PaidHeights { if h > 0 { protectedVestingHeights[h] = true } } +} +} +if cursor >= pruneBelow { return 0, nil // nothing new to prune } @@ -110,6 +119,9 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { // Gap in chain — skip, advance cursor past it. continue } +// Retain the at-most eleven reserve/payment bodies for permanent canonical +// payout evidence, even when ordinary old block bodies are pruned. +if protectedVestingHeights[h] { continue } // Strip TxData: replace the block bytes with a header-only version. // We encode the pruned marker as the single JSON string `null` appended diff --git a/store/ring_member_index_test.go b/store/ring_member_index_test.go index 8050c3f..1dcf45a 100644 --- a/store/ring_member_index_test.go +++ b/store/ring_member_index_test.go @@ -71,12 +71,4 @@ func TestRingMemberIndexPersistsSamplesAndDeletesCanonicalOutputs(t *testing.T) if got != nil { t.Fatal("rolled-back output remains in ring-member index") } -} - -func TestSampleRingMembersRejectsExcessiveCount(t *testing.T) { -db := openTestDB(t) -_, err := db.SampleRingMembers(int(^uint(0)>>1), nil) -if err == nil { -t.Fatal("expected excessive sample count to be rejected") -} -} +} \ No newline at end of file diff --git a/store/ring_sampling.go b/store/ring_sampling.go new file mode 100644 index 0000000..286acb5 --- /dev/null +++ b/store/ring_sampling.go @@ -0,0 +1,153 @@ +package store + +import ( + cryptorand "crypto/rand" + "errors" + "fmt" + "io" + "math/bits" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" +) + +// Bound untrusted request size and pathological entropy rejection. The store +// population can contain millions of outputs; work is O(count+len(exclude)), +// not O(population) or O(key-space gaps). No random-seek fallback is permitted. +const maxRingSamplingRequest = 4096 +const maxRingEntropyRejections = 128 +const maxStakedRingExclusions = 1_000_000 + +func (d *DB) SampleRingMembers(count int, exclude map[crypto.Point32]bool) ([]core.DecoyUTXO, error) { + return d.sampleRingMembers(count, exclude, cryptorand.Reader) +} + +// SampleRingMembersExcluding samples canonical ring records while excluding +// both caller-selected public keys and staked outputs identified by outpoint. +func (d *DB) SampleRingMembersExcluding(count int, exclude map[crypto.Point32]bool, excludeUTXOs map[core.UTXOKey]bool) ([]core.DecoyUTXO, error) { + return d.sampleRingMembersExcluding(count, exclude, excludeUTXOs, cryptorand.Reader) +} + +// Entropy is injected per call for deterministic tests, never package-global. +func (d *DB) sampleRingMembers(count int, exclude map[crypto.Point32]bool, entropy io.Reader) ([]core.DecoyUTXO, error) { + return d.sampleRingMembersExcluding(count, exclude, nil, entropy) +} + +func (d *DB) sampleRingMembersExcluding(count int, exclude map[crypto.Point32]bool, excludeUTXOs map[core.UTXOKey]bool, entropy io.Reader) ([]core.DecoyUTXO, error) { + if count <= 0 { + return nil, nil + } + if count > maxRingSamplingRequest || len(exclude) > maxRingSamplingRequest || len(excludeUTXOs) > maxStakedRingExclusions { + return nil, fmt.Errorf("ring sampling: request exceeds bounded limits") + } + if entropy == nil { + return nil, fmt.Errorf("ring sampling: cryptographic entropy required") + } + snapshot, err := d.db.GetSnapshot() + if err != nil { + return nil, err + } + defer snapshot.Release() + return sampleRingSnapshotExcluding(snapshot, count, exclude, excludeUTXOs, entropy) +} + +func sampleRingSnapshot(reader ringSamplingReader, count int, exclude map[crypto.Point32]bool, entropy io.Reader) ([]core.DecoyUTXO, error) { + return sampleRingSnapshotExcluding(reader, count, exclude, nil, entropy) +} + +func sampleRingSnapshotExcluding(reader ringSamplingReader, count int, exclude map[crypto.Point32]bool, excludeUTXOs map[core.UTXOKey]bool, entropy io.Reader) ([]core.DecoyUTXO, error) { + if count <= 0 { + return nil, nil + } + if count > maxRingSamplingRequest || len(exclude) > maxRingSamplingRequest || len(excludeUTXOs) > maxStakedRingExclusions || entropy == nil { + return nil, fmt.Errorf("ring sampling: invalid bounded request/entropy") + } + size, ready, err := ringSamplingCount(reader) + if err != nil { + return nil, err + } + if !ready { + return nil, fmt.Errorf("ring sampling: ordinal index not ready; prepare ring-member index") + } + // Cheap boundary sanity checks do not claim to audit every disk record. + // Each selected slot is independently cross-checked below. + if size > 0 { + if _, err := readRingSamplingMember(reader, size-1); err != nil { + return nil, err + } + } + if _, err := reader.Get(ringSamplingSlotKey(size), nil); !errors.Is(err, leveldb.ErrNotFound) { + if err != nil { + return nil, err + } + return nil, fmt.Errorf("ring sampling: slot past population boundary") + } + // Sparse virtual Fisher-Yates: swap the drawn ordinal with the last + // remaining ordinal. It visits each slot once and stores only changed + // mappings, not the complete population. Filtering a uniform permutation + // yields a uniform ordered sample of its non-excluded members. + swaps := make(map[uint64]uint64) + seen := make(map[crypto.Point32]bool) + out := make([]core.DecoyUTXO, 0, count) + at := func(index uint64) uint64 { + if mapped, ok := swaps[index]; ok { + return mapped + } + return index + } + draws := 0 + for remaining := size; remaining > 0 && len(out) < count; remaining-- { + draws++ + if draws > count+len(exclude)+len(excludeUTXOs) { + return nil, fmt.Errorf("ring sampling: candidate consistency budget exceeded") + } + index, err := uniformRingOrdinal(entropy, remaining) + if err != nil { + return nil, err // discard ALL previously selected candidates + } + ordinal := at(index) + swaps[index] = at(remaining - 1) + delete(swaps, remaining-1) + u, err := readRingSamplingMember(reader, ordinal) + if err != nil { + return nil, err + } + if seen[u.OneTimePub] { + return nil, fmt.Errorf("ring sampling: duplicate canonical key") + } + seen[u.OneTimePub] = true + if exclude[u.OneTimePub] || excludeUTXOs[core.UTXOKey{TxHash: u.TxHash, OutputIndex: u.OutputIndex}] { + continue + } + out = append(out, core.DecoyUTXO{OneTimePub: u.OneTimePub, AmountCommit: u.AmountCommit}) + } + return out, nil // genuine exhausted eligible population is an ordinary shortfall +} + +func uniformRingOrdinal(entropy io.Reader, size uint64) (uint64, error) { + if size == 0 || entropy == nil { + return 0, fmt.Errorf("ring sampling: invalid entropy/population") + } + if size == 1 { + return 0, nil + } + width := bits.Len64(size - 1) + byteCount := (width + 7) / 8 + mask := byte(0xff >> (byteCount*8 - width)) + var buf [8]byte + for attempt := 0; attempt < maxRingEntropyRejections; attempt++ { + if _, err := io.ReadFull(entropy, buf[:byteCount]); err != nil { + return 0, fmt.Errorf("ring sampling: entropy: %w", err) + } + buf[0] &= mask + var value uint64 + for _, b := range buf[:byteCount] { + value = value<<8 | uint64(b) + } + if value < size { + return value, nil + } + } + return 0, fmt.Errorf("ring sampling: entropy rejection limit exceeded") +} diff --git a/store/ring_sampling_index.go b/store/ring_sampling_index.go new file mode 100644 index 0000000..45d484f --- /dev/null +++ b/store/ring_sampling_index.go @@ -0,0 +1,289 @@ +package store + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// These are derived local indexes, not consensus/wire-format changes. rm/ +// remains authoritative. Slots cover unique, unlocked rm/ keys, including spent +// outputs. A count is also the readiness marker, published only after backfill. +var ( + ringSamplingCountKey = []byte("m/ring-sampling-v1/count") + ringSamplingSlotPrefix = []byte("rs1/") + ringSamplingReversePrefix = []byte("ri1/") +) + +func ringSamplingSlotKey(index uint64) []byte { + key := make([]byte, len(ringSamplingSlotPrefix)+8) + copy(key, ringSamplingSlotPrefix) + binary.BigEndian.PutUint64(key[len(ringSamplingSlotPrefix):], index) + return key +} + +func ringSamplingReverseKey(pub crypto.Point32) []byte { + return append(append([]byte(nil), ringSamplingReversePrefix...), pub[:]...) +} + +func ringSamplingUint(value uint64) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], value) + return b[:] +} + +type ringSamplingReader interface { + Get([]byte, *opt.ReadOptions) ([]byte, error) +} + +func ringSamplingCount(reader ringSamplingReader) (uint64, bool, error) { + data, err := reader.Get(ringSamplingCountKey, nil) + if errors.Is(err, leveldb.ErrNotFound) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + if len(data) != 8 { + return 0, false, fmt.Errorf("ring sampling: malformed count") + } + return binary.BigEndian.Uint64(data), true, nil +} + +func decodeRingRecord(key, data []byte) (*StoredUTXO, error) { + var u StoredUTXO + if len(key) != len(prefixRingMember)+len(u.OneTimePub) || + !bytes.HasPrefix(key, prefixRingMember) { + return nil, fmt.Errorf("ring sampling: malformed canonical key") + } + if err := json.Unmarshal(data, &u); err != nil { + return nil, fmt.Errorf("ring sampling: decode canonical member: %w", err) + } + if !bytes.Equal(key[len(prefixRingMember):], u.OneTimePub[:]) { + return nil, fmt.Errorf("ring sampling: canonical key/payload mismatch") + } + return &u, nil +} + +// EnsureRingMemberIndex is a startup/maintenance operation, not part of a +// sampling request. A legacy database takes one streaming O(N) preparation. +// Memory is bounded by 1000 index entries (legacy u/ backfill uses 10000). +// Writers are excluded; an interrupted build has no readiness marker and is +// safely restarted. Never roll this out against a live production DB implicitly. +func (d *DB) EnsureRingMemberIndex() error { + d.ringMu.Lock() + defer d.ringMu.Unlock() + if err := d.ensureLegacyRingMemberIndex(); err != nil { + return err + } + if _, ready, err := ringSamplingCount(d.db); err != nil || ready { + return err + } + // Remove incomplete derived state, including stale tail slots from a + // previous interrupted build. Do not remove any canonical/spent members. + for _, prefix := range [][]byte{ringSamplingSlotPrefix, ringSamplingReversePrefix} { + iter := d.db.NewIterator(util.BytesPrefix(prefix), nil) + batch := new(leveldb.Batch) + for iter.Next() { + batch.Delete(append([]byte(nil), iter.Key()...)) + if batch.Len() >= 1000 { + if err := d.db.Write(batch, nil); err != nil { + iter.Release() + return err + } + batch.Reset() + } + } + err := iter.Error() + iter.Release() + if err != nil { + return err + } + if err := d.db.Write(batch, nil); err != nil { + return err + } + } + iter := d.db.NewIterator(util.BytesPrefix(prefixRingMember), nil) + defer iter.Release() + batch := new(leveldb.Batch) + var count uint64 + for iter.Next() { + u, err := decodeRingRecord(iter.Key(), iter.Value()) + if err != nil { + return err + } + if u.ProtocolLocked { + continue + } + if count == math.MaxUint64 { + return fmt.Errorf("ring sampling: population overflow") + } + batch.Put(ringSamplingSlotKey(count), u.OneTimePub[:]) + batch.Put(ringSamplingReverseKey(u.OneTimePub), ringSamplingUint(count)) + count++ + if batch.Len() >= 2000 { + if err := d.db.Write(batch, nil); err != nil { + return err + } + batch.Reset() + } + } + if err := iter.Error(); err != nil { + return err + } + batch.Put(ringSamplingCountKey, ringSamplingUint(count)) + return d.db.Write(batch, &opt.WriteOptions{Sync: true}) +} + +// InvalidateRingSamplingIndex fences sampling BEFORE an offline downgrade to a +// legacy writer, an old backup import or a canonical rm/ repair. Legacy writers +// do not maintain ordinal slots. After they stop, EnsureRingMemberIndex rebuilds +// from rm/. Caller must quiesce clients/writers and retain a coherent backup; +// this is not a production migration performed implicitly by a request. +func (d *DB) InvalidateRingSamplingIndex() error { + d.ringMu.Lock() + defer d.ringMu.Unlock() + return d.db.Delete(ringSamplingCountKey, &opt.WriteOptions{Sync: true}) +} + +// stageRingSamplingChange runs under ringMu and stages every derived change +// into the SAME LevelDB batch as u/ and rm/. No memory population cache exists. +func (d *DB) stageRingSamplingChange(batch *leveldb.Batch, pub crypto.Point32, eligible bool) error { + count, ready, err := ringSamplingCount(d.db) + if err != nil { + return err + } + if !ready { + // A brand-new rm/ store starts at ordinal zero. Existing legacy rm/ + // stores keep accepting canonical writes but remain unavailable for + // sampling until the explicit streaming preparation finishes. + iter := d.db.NewIterator(util.BytesPrefix(prefixRingMember), nil) + nonempty := iter.First() + err := iter.Error() + iter.Release() + if err != nil { + return err + } + if nonempty { + return nil + } + } + oldData, err := d.get(ringMemberKey(pub)) + if err != nil { + return err + } + oldEligible := false + if oldData != nil { + old, err := decodeRingRecord(ringMemberKey(pub), oldData) + if err != nil { + return err + } + oldEligible = !old.ProtocolLocked + } + reverse, err := d.get(ringSamplingReverseKey(pub)) + if err != nil { + return err + } + if oldEligible != (reverse != nil) { + return fmt.Errorf("ring sampling: canonical/reverse membership mismatch") + } + var index uint64 + if oldEligible { + if len(reverse) != 8 || count == 0 { + return fmt.Errorf("ring sampling: malformed reverse index") + } + index = binary.BigEndian.Uint64(reverse) + slot, err := d.get(ringSamplingSlotKey(index)) + if err != nil { + return err + } + if index >= count || !bytes.Equal(slot, pub[:]) { + return fmt.Errorf("ring sampling: reverse/slot mismatch") + } + } + switch { + case oldEligible && !eligible: + last := count - 1 + if index != last { + lastPub, err := d.get(ringSamplingSlotKey(last)) + if err != nil { + return err + } + if len(lastPub) != len(pub) { + return fmt.Errorf("ring sampling: missing tail slot") + } + var moved crypto.Point32 + copy(moved[:], lastPub) + if moved == pub { + return fmt.Errorf("ring sampling: duplicate tail member") + } + if _, err := readRingSamplingMember(d.db, last); err != nil { + return err + } + batch.Put(ringSamplingSlotKey(index), lastPub) + batch.Put(ringSamplingReverseKey(moved), ringSamplingUint(index)) + } + batch.Delete(ringSamplingSlotKey(last)) + batch.Delete(ringSamplingReverseKey(pub)) + count-- + case !oldEligible && eligible: + if count == math.MaxUint64 { + return fmt.Errorf("ring sampling: population overflow") + } + // An unexpected existing tail is corruption, not permission to + // overwrite it or return a biased partial sample. + tail, err := d.get(ringSamplingSlotKey(count)) + if err != nil { + return err + } + if tail != nil { + return fmt.Errorf("ring sampling: occupied append slot") + } + batch.Put(ringSamplingSlotKey(count), pub[:]) + batch.Put(ringSamplingReverseKey(pub), ringSamplingUint(count)) + count++ + } + batch.Put(ringSamplingCountKey, ringSamplingUint(count)) + return nil +} + +// Resolve a slot against both the inverse mapping and authoritative rm/ +// payload from one reader/snapshot. Missing/corrupt records are fatal. +func readRingSamplingMember(reader ringSamplingReader, index uint64) (*StoredUTXO, error) { + data, err := reader.Get(ringSamplingSlotKey(index), nil) + if err != nil { + return nil, fmt.Errorf("ring sampling: read slot: %w", err) + } + var pub crypto.Point32 + if len(data) != len(pub) { + return nil, fmt.Errorf("ring sampling: malformed slot") + } + copy(pub[:], data) + reverse, err := reader.Get(ringSamplingReverseKey(pub), nil) + if err != nil { + return nil, fmt.Errorf("ring sampling: read reverse: %w", err) + } + if len(reverse) != 8 || binary.BigEndian.Uint64(reverse) != index { + return nil, fmt.Errorf("ring sampling: inconsistent reverse") + } + data, err = reader.Get(ringMemberKey(pub), nil) + if err != nil { + return nil, fmt.Errorf("ring sampling: read canonical member: %w", err) + } + u, err := decodeRingRecord(ringMemberKey(pub), data) + if err != nil { + return nil, err + } + if u.ProtocolLocked { + return nil, fmt.Errorf("ring sampling: locked member in eligible index") + } + return u, nil +} diff --git a/store/ring_sampling_test.go b/store/ring_sampling_test.go new file mode 100644 index 0000000..d28fee1 --- /dev/null +++ b/store/ring_sampling_test.go @@ -0,0 +1,570 @@ +package store + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "sync" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +func samplingDB(t *testing.T) *DB { + t.Helper() + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func sparseRingOutput(i byte) *StoredUTXO { + var u StoredUTXO + // Deliberately unequal key-space gaps, not contiguous ordinal keys. + u.OneTimePub[0] = []byte{0, 1, 128, 255, 200, 100}[int(i)%6] + u.OneTimePub[31] = i + 1 + u.TxHash[0] = i + 1 + u.AmountCommit[0] = i + 10 + return &u +} + +func putSamplingOutput(t *testing.T, db *DB, u *StoredUTXO) { + t.Helper() + if err := db.PutUTXO(u.TxHash, u.OutputIndex, u); err != nil { + t.Fatal(err) + } +} + +func sampleIdentity(sample []core.DecoyUTXO) string { + var result string + for _, u := range sample { + result += fmt.Sprintf("%02x", u.OneTimePub[31]) + } + return result +} + +func TestRingSamplingSparseKeysEveryOrderedSampleIsEquiprobable(t *testing.T) { + db := samplingDB(t) + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + orders := map[string]int{} + for a := byte(0); a < 4; a++ { + for b := byte(0); b < 3; b++ { + for c := byte(0); c < 2; c++ { + got, err := db.sampleRingMembers(3, nil, bytes.NewReader([]byte{a, b, c})) + if err != nil || len(got) != 3 { + t.Fatalf("draw %d/%d/%d: %v %v", a, b, c, got, err) + } + seen := map[crypto.Point32]bool{} + for _, u := range got { + if seen[u.OneTimePub] { + t.Fatal("repeated member") + } + seen[u.OneTimePub] = true + canonical, err := db.LookupRingMember(u.OneTimePub) + if err != nil || canonical.AmountCommit != u.AmountCommit { + t.Fatal("canonical pair changed") + } + } + orders[sampleIdentity(got)]++ + } + } + } + if len(orders) != 24 { + t.Fatalf("got %d ordered samples, want 24", len(orders)) + } + for order, times := range orders { + if times != 1 { + t.Fatalf("order %s chosen %d times", order, times) + } + } + for a := byte(0); a < 4; a++ { + got, err := db.sampleRingMembers(1, nil, bytes.NewReader([]byte{a})) + if err != nil || got[0].OneTimePub != sparseRingOutput(a).OneTimePub { + t.Fatalf("single draw %d: %v %v", a, got, err) + } + } +} + +func TestRingSamplingFilteredPermutationRemainsUniform(t *testing.T) { + db := samplingDB(t) + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + locked := sparseRingOutput(4) + locked.ProtocolLocked = true + putSamplingOutput(t, db, locked) + exclude := map[crypto.Point32]bool{sparseRingOutput(1).OneTimePub: true} + orders := map[string]int{} + for a := byte(0); a < 4; a++ { + for b := byte(0); b < 3; b++ { + for c := byte(0); c < 2; c++ { + got, err := db.sampleRingMembers(2, exclude, bytes.NewReader([]byte{a, b, c})) + if err != nil || len(got) != 2 { + t.Fatalf("filtered draw: %v %v", got, err) + } + for _, member := range got { + if exclude[member.OneTimePub] || member.OneTimePub == locked.OneTimePub { + t.Fatal("excluded/locked member selected") + } + } + orders[sampleIdentity(got)]++ + } + } + } + if len(orders) != 6 { + t.Fatalf("filtered orders: %v", orders) + } + for _, times := range orders { + if times != 4 { + t.Fatalf("filtered selection not uniform: %v", orders) + } + } +} + +func TestRingSamplingShortfallsAndBoundedRequests(t *testing.T) { + db := samplingDB(t) + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + empty, err := db.sampleRingMembers(3, nil, bytes.NewReader(nil)) + if err != nil || len(empty) != 0 { + t.Fatalf("empty pool: %v %v", empty, err) + } + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + exclude := map[crypto.Point32]bool{sparseRingOutput(1).OneTimePub: true} + got, err := db.sampleRingMembers(15, exclude, bytes.NewReader([]byte{0, 0, 0})) + if err != nil || len(got) != 3 { + t.Fatalf("shortfall must return every eligible member: %v %v", got, err) + } + for _, n := range []int{0, -1} { + got, err := db.sampleRingMembers(n, nil, nil) + if got != nil || err != nil { + t.Fatal("nonpositive count changed") + } + } + excludedOutput := sparseRingOutput(2) + got, err = db.sampleRingMembersExcluding(15, nil, map[core.UTXOKey]bool{ + {TxHash: excludedOutput.TxHash, OutputIndex: excludedOutput.OutputIndex}: true, + }, bytes.NewReader([]byte{0, 0, 0})) + if err != nil || len(got) != 3 { + t.Fatalf("outpoint exclusion should return every eligible member: %v %v", got, err) + } + for _, candidate := range got { + if candidate.OneTimePub == excludedOutput.OneTimePub { + t.Fatal("staked outpoint was selected as a ring member") + } + } + for _, entropy := range []io.Reader{nil, bytes.NewReader(nil), bytes.NewReader([]byte{0})} { + got, err := db.sampleRingMembers(3, nil, entropy) + if got != nil || err == nil { + t.Fatalf("entropy fault returned partial success: %v %v", got, err) + } + } + got, err = db.sampleRingMembers(maxRingSamplingRequest+1, nil, bytes.NewReader(nil)) + if got != nil || err == nil { + t.Fatal("oversized count accepted") + } + oversized := make(map[crypto.Point32]bool) + for i := 0; i <= maxRingSamplingRequest; i++ { + var pub crypto.Point32 + binary.BigEndian.PutUint64(pub[:8], uint64(i)) + oversized[pub] = true + } + if got, err := db.sampleRingMembers(1, oversized, bytes.NewReader(nil)); got != nil || err == nil { + t.Fatal("oversized exclusions accepted") + } +} + +func TestRingOrdinalRejectionAndEntropyBounds(t *testing.T) { + value, err := uniformRingOrdinal(bytes.NewReader([]byte{3, 2}), 3) + if err != nil || value != 2 { + t.Fatalf("out-of-range draw not rejected: %d %v", value, err) + } + entropy := bytes.NewReader(bytes.Repeat([]byte{255}, maxRingEntropyRejections+1)) + if _, err := uniformRingOrdinal(entropy, 3); err == nil || entropy.Len() != 1 { + t.Fatal("unbounded or biased rejection fallback") + } +} + +type samplingReaderFunc func([]byte, *opt.ReadOptions) ([]byte, error) + +func (f samplingReaderFunc) Get(key []byte, options *opt.ReadOptions) ([]byte, error) { + return f(key, options) +} + +func TestRingSamplingStorageFaultDiscardsPartialSelection(t *testing.T) { + db := samplingDB(t) + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + snapshot, err := db.db.GetSnapshot() + if err != nil { + t.Fatal(err) + } + defer snapshot.Release() + for failAt := 1; failAt <= 14; failAt++ { + calls := 0 + reader := samplingReaderFunc(func(k []byte, o *opt.ReadOptions) ([]byte, error) { + calls++ + if calls == failAt { + return nil, errors.New("injected storage read fault") + } + return snapshot.Get(k, o) + }) + got, err := sampleRingSnapshot(reader, 3, nil, bytes.NewReader([]byte{0, 0, 0})) + if got != nil || err == nil { + t.Fatalf("storage fault %d leaked partial selection: %v %v", failAt, got, err) + } + } + db.Close() + if got, err := db.SampleRingMembers(3, nil); got != nil || err == nil { + t.Fatal("closed backend fell back instead of failing") + } +} + +func TestRingSamplingCorruptionFailsClosed(t *testing.T) { + for _, fault := range []string{"count", "slot", "reverse", "payload", "key", "lock", "tail"} { + t.Run(fault, func(t *testing.T) { + db := samplingDB(t) + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + u := sparseRingOutput(3) // tail checked on every nonempty request + switch fault { + case "count": + db.db.Put(ringSamplingCountKey, []byte{1}, nil) + case "slot": + db.db.Delete(ringSamplingSlotKey(3), nil) + case "reverse": + db.db.Put(ringSamplingReverseKey(u.OneTimePub), ringSamplingUint(2), nil) + case "payload": + db.db.Put(ringMemberKey(u.OneTimePub), []byte("{"), nil) + case "key": + u.OneTimePub = sparseRingOutput(1).OneTimePub + data, _ := json.Marshal(u) + db.db.Put(ringMemberKey(sparseRingOutput(3).OneTimePub), data, nil) + case "lock": + u.ProtocolLocked = true + data, _ := json.Marshal(u) + db.db.Put(ringMemberKey(u.OneTimePub), data, nil) + case "tail": + db.db.Put(ringSamplingSlotKey(4), sparseRingOutput(0).OneTimePub[:], nil) + } + if got, err := db.sampleRingMembers(3, nil, bytes.NewReader([]byte{0, 0, 0})); got != nil || err == nil { + t.Fatalf("corrupt %s accepted: %v %v", fault, got, err) + } + }) + } +} + +type mutationEntropy struct { + once sync.Once + mutate func() + reader io.Reader +} + +func (m *mutationEntropy) Read(p []byte) (int, error) { + m.once.Do(m.mutate) + return m.reader.Read(p) +} + +func TestRingSamplingSnapshotAtomicWritesRollbackAndReopen(t *testing.T) { + path := t.TempDir() + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { db.Close() }() + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + entropy := &mutationEntropy{reader: bytes.NewReader([]byte{0, 0, 0}), mutate: func() { + if err := db.DeleteUTXO(sparseRingOutput(1).TxHash, 0); err != nil { + t.Fatal(err) + } + putSamplingOutput(t, db, sparseRingOutput(4)) + }} + old, err := db.sampleRingMembers(4, nil, entropy) + if err != nil || sampleIdentity(old) != "01040302" { + t.Fatalf("sample mixed snapshots: %v %v", sampleIdentity(old), err) + } + // Spending is NOT deletion. A spent member remains selectable. + if err := db.MarkUTXOSpent(sparseRingOutput(0).TxHash, 0); err != nil { + t.Fatal(err) + } + locked := sparseRingOutput(2) + locked.ProtocolLocked = true + putSamplingOutput(t, db, locked) + got, err := db.sampleRingMembers(10, nil, bytes.NewReader([]byte{0, 0})) + if err != nil || len(got) != 3 { + t.Fatalf("lock/rollback population: %v %v", got, err) + } + seen := map[crypto.Point32]bool{} + for _, u := range got { + seen[u.OneTimePub] = true + } + if !seen[sparseRingOutput(0).OneTimePub] || seen[locked.OneTimePub] || seen[sparseRingOutput(1).OneTimePub] { + t.Fatal("spent, locked or rolled-back semantics wrong") + } + locked.ProtocolLocked = false + putSamplingOutput(t, db, locked) + putSamplingOutput(t, db, locked) // idempotence, no duplicate ordinal + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + reopened, err := db.sampleRingMembers(10, nil, bytes.NewReader([]byte{0, 0, 0})) + if err != nil || len(reopened) != 4 { + t.Fatalf("durable index after reopen: %v %v", reopened, err) + } +} + +func TestRingSamplingLegacyBackfillInterruptedIndexAndIdempotence(t *testing.T) { + db := samplingDB(t) + batch := new(leveldb.Batch) + for i := byte(0); i < 4; i++ { + u := sparseRingOutput(i) + u.ProtocolLocked = i == 3 + data, _ := json.Marshal(u) + batch.Put(utxoKey(u.TxHash, 0), data) + } + // Simulate a stopped/interrupted derived build with orphan slots. No + // count/readiness marker may exist until all canonical entries are indexed. + batch.Put(ringSamplingSlotKey(999), []byte("stale")) + batch.Put(ringSamplingReverseKey(sparseRingOutput(5).OneTimePub), []byte("stale")) + if err := db.db.Write(batch, nil); err != nil { + t.Fatal(err) + } + if got, err := db.SampleRingMembers(1, nil); got != nil || err == nil { + t.Fatal("legacy/unready index silently sampled") + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + if _, err := db.db.Get(ringSamplingSlotKey(999), nil); !errors.Is(err, leveldb.ErrNotFound) { + t.Fatal("interrupted tail not removed") + } + got, err := db.sampleRingMembers(10, nil, bytes.NewReader([]byte{0, 0})) + if err != nil || len(got) != 3 { + t.Fatalf("backfill failed: %v %v", got, err) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + again, err := db.sampleRingMembers(10, nil, bytes.NewReader([]byte{0, 0})) + if err != nil || !reflect.DeepEqual(got, again) { + t.Fatal("idempotent prepare changed sampling") + } +} + +func TestRingSamplingInterruptedPreparedRMBuildRestartsAfterReopen(t *testing.T) { + path := t.TempDir() + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + // Existing v1 rm/ store: deliberately enough entries to flush a partial + // 1000-member ordinal batch before reaching the malformed last record. + batch := new(leveldb.Batch) + batch.Put([]byte("m/ring-member-index-v1"), []byte{1}) + for i := uint64(1); i <= 1001; i++ { + var u StoredUTXO + binary.BigEndian.PutUint64(u.OneTimePub[:8], i) + u.AmountCommit[0] = 1 + data, _ := json.Marshal(u) + batch.Put(ringMemberKey(u.OneTimePub), data) + } + var broken crypto.Point32 + broken[0] = 255 + batch.Put(ringMemberKey(broken), []byte("{")) + if err := db.db.Write(batch, nil); err != nil { + t.Fatal(err) + } + if err := db.EnsureRingMemberIndex(); err == nil { + t.Fatal("malformed member was silently skipped") + } + if _, ready, err := ringSamplingCount(db.db); err != nil || ready { + t.Fatal("partial build published readiness") + } + if data, err := db.db.Get(ringSamplingSlotKey(999), nil); err != nil || len(data) != 32 { + t.Fatal("fixture did not exercise a flushed partial batch") + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + // The canonical source is explicitly repaired in the temporary fixture. + if err := db.db.Delete(ringMemberKey(broken), nil); err != nil { + t.Fatal(err) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + count, ready, err := ringSamplingCount(db.db) + if err != nil || !ready || count != 1001 { + t.Fatalf("restart: count=%d ready=%t err=%v", count, ready, err) + } +} + +func TestRingSamplingFailedMutationCannotPartiallyWriteCanonicalState(t *testing.T) { + db := samplingDB(t) + u := sparseRingOutput(0) + putSamplingOutput(t, db, u) + // Metadata corruption must refuse both insertion and rollback before + // writing u/, rm/, su/ or any derived index entry. + if err := db.db.Put(ringSamplingCountKey, []byte("bad"), nil); err != nil { + t.Fatal(err) + } + next := sparseRingOutput(1) + if err := db.PutUTXO(next.TxHash, 0, next); err == nil { + t.Fatal("insert continued after index fault") + } + if got, err := db.GetUTXO(next.TxHash, 0); err != nil || got != nil { + t.Fatal("failed insert wrote canonical output") + } + if err := db.DeleteUTXO(u.TxHash, 0); err == nil { + t.Fatal("rollback continued after index fault") + } + if got, err := db.GetUTXO(u.TxHash, 0); err != nil || got == nil { + t.Fatal("failed rollback deleted canonical output") + } + // A closed real backend fails snapshot acquisition and mutations. + db.Close() + if err := db.PutUTXO(next.TxHash, 0, next); err == nil { + t.Fatal("closed backend accepted write") + } + if err := db.DeleteUTXO(u.TxHash, 0); err == nil { + t.Fatal("closed backend accepted rollback") + } +} + +func TestRingSamplingLegacyWriterCompatibilityRequiresExplicitFence(t *testing.T) { + db := samplingDB(t) + for i := byte(0); i < 4; i++ { + putSamplingOutput(t, db, sparseRingOutput(i)) + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + if err := db.InvalidateRingSamplingIndex(); err != nil { + t.Fatal(err) + } + // Simulate old software writing only canonical namespaces. It cannot + // update or invalidate an index format it does not know about. + u := sparseRingOutput(4) + data, _ := json.Marshal(u) + batch := new(leveldb.Batch) + batch.Put(utxoKey(u.TxHash, 0), data) + batch.Put(ringMemberKey(u.OneTimePub), data) + batch.Delete(ringMemberKey(sparseRingOutput(1).OneTimePub)) + batch.Delete(utxoKey(sparseRingOutput(1).TxHash, 0)) + if err := db.db.Write(batch, nil); err != nil { + t.Fatal(err) + } + if got, err := db.SampleRingMembers(4, nil); got != nil || err == nil { + t.Fatal("stale derived slots sampled after legacy writes") + } + if err := db.EnsureRingMemberIndex(); err != nil { + t.Fatal(err) + } + got, err := db.sampleRingMembers(10, nil, bytes.NewReader([]byte{0, 0, 0})) + if err != nil || len(got) != 4 { + t.Fatalf("legacy rebuild: %v %v", got, err) + } + seen := map[crypto.Point32]bool{} + for _, u := range got { + seen[u.OneTimePub] = true + } + if !seen[u.OneTimePub] || seen[sparseRingOutput(1).OneTimePub] { + t.Fatal("legacy addition/rollback not reflected by rebuild") + } +} + +func TestRingSamplingConcurrentWritersKeepDenseBijection(t *testing.T) { + db := samplingDB(t) + const writers = 8 + const each = 32 + var wg sync.WaitGroup + errs := make(chan error, writers) + for worker := 0; worker < writers; worker++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for j := 0; j < each; j++ { + i := uint64(worker*each + j + 1) + var u StoredUTXO + binary.BigEndian.PutUint64(u.OneTimePub[:8], i) + binary.BigEndian.PutUint64(u.TxHash[:8], i) + if err := db.PutUTXO(u.TxHash, 0, &u); err != nil { + errs <- err + return + } + } + }(worker) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + // Zero draws repeatedly pick the swapped tail: a complete permutation, + // not repeated ordinal zero. 256 requires at most one byte per draw. + got, err := db.sampleRingMembers(writers*each, nil, bytes.NewReader(make([]byte, writers*each))) + if err != nil || len(got) != writers*each { + t.Fatalf("concurrent dense population: %d %v", len(got), err) + } +} + +// Complexity proof using an on-demand reader, NOT a claim of real disk +// throughput. Five million canonical pairs need no five-million-entry cache. +func TestRingSamplingMillionEntryPopulationHasBoundedReads(t *testing.T) { + const population = uint64(5_000_000) + calls := 0 + reader := samplingReaderFunc(func(key []byte, _ *opt.ReadOptions) ([]byte, error) { + calls++ + if bytes.Equal(key, ringSamplingCountKey) { + return ringSamplingUint(population), nil + } + if bytes.HasPrefix(key, ringSamplingSlotPrefix) { + i := binary.BigEndian.Uint64(key[len(ringSamplingSlotPrefix):]) + if i >= population { + return nil, leveldb.ErrNotFound + } + var pub crypto.Point32 + binary.BigEndian.PutUint64(pub[:8], i+1) + return pub[:], nil + } + i := binary.BigEndian.Uint64(key[len(key)-32:len(key)-24]) - 1 + if bytes.HasPrefix(key, ringSamplingReversePrefix) { + return ringSamplingUint(i), nil + } + u := StoredUTXO{} + binary.BigEndian.PutUint64(u.OneTimePub[:8], i+1) + u.AmountCommit[0] = 1 + return json.Marshal(u) + }) + got, err := sampleRingSnapshot(reader, 15, nil, bytes.NewReader(make([]byte, 45))) + if err != nil || len(got) != 15 || calls != 50 { + t.Fatalf("population-sized work or failed sample: len=%d calls=%d err=%v", len(got), calls, err) + } +} diff --git a/store/sale.go b/store/sale.go new file mode 100644 index 0000000..6e05710 --- /dev/null +++ b/store/sale.go @@ -0,0 +1,315 @@ +package store + +import ( + "encoding/binary" + "encoding/json" + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/syndtr/goleveldb/leveldb" +) + +// SaleMigration is operator configuration, never accepted from a payment API. +// Activation is independently gated by this signed, reconciled witness. +type SaleMigration struct { + Checkpoint sale.Checkpoint + Reconciliation sale.Reconciliation + Trusted []crypto.ValidatorPubKey +} +type SaleSettlement struct { + Migration *SaleMigration + Before *sale.State + After sale.State +} +type saleJournal struct { + Before *sale.State `json:"before"` + QueuedOrders []crypto.Hash32 `json:"queued_orders,omitempty"` +} + +var saleStateKey = []byte("sale/state/v1") + +func saleFence(kind byte, id crypto.Hash32) []byte { + return append([]byte{'s', 'a', 'l', 'e', '/', kind, '/'}, id[:]...) +} +func saleJournalKey(hash crypto.Hash32) []byte { return append([]byte("sale/journal/v1/"), hash[:]...) } + +func (d *DB) LoadSaleState() (*sale.State, error) { + raw, err := d.get(saleStateKey) + if err != nil || raw == nil { + return nil, err + } + var s sale.State + if err = json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("sale: corrupt durable state: %w", err) + } + return &s, nil +} +func (d *DB) SaleConsumed(kind byte, id crypto.Hash32) (bool, error) { + if kind != 'o' && kind != 'p' { + return false, fmt.Errorf("sale: invalid fence namespace") + } + return d.db.Has(saleFence(kind, id), nil) +} + +func (d *DB) PrepareSaleBlock(b *core.Block, m *SaleMigration) (*SaleSettlement, error) { + if b == nil { + return nil, fmt.Errorf("sale: nil block") + } + before, err := d.LoadSaleState() + if err != nil { + return nil, err + } + txs := []*core.Transaction{} + for i := range b.Txs { + tx := &b.Txs[i] + if tx.IsSale() { + txs = append(txs, tx) + } + } + if before == nil && len(txs) == 0 && (m == nil || b.Header.Height < m.Checkpoint.Height) { + return nil, nil + } + if m == nil || len(txs) > 128 { + return nil, fmt.Errorf("sale: approved migration missing or block limit exceeded") + } + if b.Header.MerkleRoot != core.MerkleRoot(b.Txs) { + return nil, fmt.Errorf("sale: transaction body does not match header") + } + seenOutputs := map[crypto.Point32]bool{} + for _, tx := range txs { + for _, out := range tx.Outputs { + if seenOutputs[out.OneTimePub] { + return nil, fmt.Errorf("sale: duplicate recipient output key") + } + exists, err := d.db.Has(ringMemberKey(out.OneTimePub), nil) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("sale: recipient output key already exists") + } + seenOutputs[out.OneTimePub] = true + } + } + c := m.Checkpoint + // Recheck the externally configured authority set, including after restart. + if err = c.Verify(c.Genesis, c.Parent, c.Height, m.Trusted); err != nil { + return nil, err + } + tip, height, err := d.GetTip() + if err != nil { + return nil, err + } + if tip != b.Header.PrevHash || height+1 != b.Header.Height { + return nil, fmt.Errorf("sale: block is not the durable tip successor") + } + start := sale.InitialState(c) + if before == nil { + if b.Header.Height != c.Height || tip != c.Parent { + return nil, fmt.Errorf("sale: missed activation parent") + } + raw, err := d.GetRawBlockByHeight(0) + if err != nil { + return nil, err + } + var genesis core.Block + if json.Unmarshal(raw, &genesis) != nil || genesis.Hash() != c.Genesis { + return nil, fmt.Errorf("sale: canonical genesis mismatch") + } + l, err := d.LoadLPoDCheckpoint() + if err != nil { + return nil, err + } +if err = VerifySaleReserveFloors(l, m.Reconciliation); err != nil { +return nil, err + } + knownIssued, err := SaleKnownIssuedFloor(l) + if err != nil { + return nil, err + } + if err = sale.VerifyReconciledCheckpoint(c, m.Reconciliation, c.Genesis, tip, l.Digest(), c.Height, + knownIssued, m.Trusted); err != nil { + return nil, err + } + } else { + start = *before + } + after, err := start.Advance(c, b.Header.Height, b.Header.PrevHash, b.Hash(), txs, d.SaleConsumed) + if err != nil { + return nil, err + } + return &SaleSettlement{Migration: m, Before: before, After: after}, nil +} + +func (d *DB) appendSaleSettlement(batch *leveldb.Batch, data []byte, hash crypto.Hash32, height uint64, p *SaleSettlement) error { + var b core.Block + if json.Unmarshal(data, &b) != nil { + s, err := d.LoadSaleState() + if err != nil { + return err + } + if s != nil || p != nil { + return fmt.Errorf("sale: canonical body required") + } + return nil + } + hasSale := false + for _, tx := range b.Txs { + hasSale = hasSale || tx.IsSale() + } + s, err := d.LoadSaleState() + if err != nil { + return err + } + if p == nil { + if s != nil || hasSale { + return fmt.Errorf("sale: missing prepared canonical settlement") + } + return nil + } + if b.Hash() != hash || b.Header.Height != height { + return fmt.Errorf("sale: body context mismatch") + } + actual, err := d.PrepareSaleBlock(&b, p.Migration) + if err != nil { + return err + } + if actual == nil || actual.After != p.After { + return fmt.Errorf("sale: stale or forged settlement") + } + raw, err := json.Marshal(actual.After) + if err != nil { + return err + } + batch.Put(saleStateKey, raw) + journal := saleJournal{Before: actual.Before} + for txIndex, tx := range b.Txs { + if !tx.IsSale() { + continue + } + a, err := tx.SaleAuthorization() + if err != nil { + return err + } + order, err := d.SaleOrder(a.Order) + if err != nil { + return err + } + if order != nil { + if order.Checkpoint != actual.After.Checkpoint || order.Amount != a.Amount || order.Payment != a.Payment { + return fmt.Errorf("sale: canonical transaction conflicts with reserved payment") + } + journal.QueuedOrders = append(journal.QueuedOrders, a.Order) + batch.Delete(salePendingKey(a.Order)) + } + batch.Put(saleFence('o', a.Order), hash[:]) + batch.Put(saleFence('p', a.Payment), hash[:]) + th := tx.Hash() + var location [12]byte + binary.BigEndian.PutUint64(location[:8], height) + binary.BigEndian.PutUint32(location[8:], uint32(txIndex)) + batch.Put(append(append([]byte{}, prefixTxIdx...), th[:]...), location[:]) + for i, out := range tx.Outputs { + u := StoredUTXO{TxHash: th, OutputIndex: uint32(i), OneTimePub: out.OneTimePub, TxPubKey: out.TxPubKey, + AmountCommit: out.AmountCommit, EncAmount: out.EncAmount, BlockHeight: height} + raw, err := json.Marshal(u) + if err != nil { + return err + } + batch.Put(utxoKey(th, uint32(i)), raw) + batch.Put(ringMemberKey(out.OneTimePub), raw) + } + } + raw, err = json.Marshal(journal) + if err != nil { + return err + } + batch.Put(saleJournalKey(hash), raw) + return nil +} + +func (d *DB) restoreSale(batch *leveldb.Batch, target crypto.Hash32, height uint64) error { + s, err := d.LoadSaleState() + if err != nil || s == nil { + return err + } + cursor, current, err := d.GetTip() + if err != nil { + return err + } + if s.Hash != cursor || s.Height != current || height > current { + return fmt.Errorf("sale: inconsistent rollback clock") + } + for current > height { + raw, err := d.get(saleJournalKey(cursor)) + if err != nil { + return err + } + var journal saleJournal + if raw == nil || json.Unmarshal(raw, &journal) != nil { + return fmt.Errorf("sale: rollback witness missing") + } + body, err := d.GetRawBlock(cursor) + if err != nil { + return err + } + var b core.Block + if json.Unmarshal(body, &b) != nil || b.Hash() != cursor || b.Header.Height != current { + return fmt.Errorf("sale: rollback body missing") + } + for _, id := range journal.QueuedOrders { + order, err := d.SaleOrder(id) + if err != nil { + return err + } + if order == nil || order.Payment == (crypto.Hash32{}) { + return fmt.Errorf("sale: rollback payment obligation missing") + } + batch.Put(salePendingKey(id), id[:]) + } + for _, tx := range b.Txs { + if !tx.IsSale() { + continue + } + a, err := tx.SaleAuthorization() + if err != nil { + return err + } + batch.Delete(saleFence('o', a.Order)) + batch.Delete(saleFence('p', a.Payment)) + th := tx.Hash() + batch.Delete(append(append([]byte{}, prefixTxIdx...), th[:]...)) + for i, out := range tx.Outputs { + batch.Delete(utxoKey(th, uint32(i))) + batch.Delete(ringMemberKey(out.OneTimePub)) + batch.Delete(spentUTXOKey(th, uint32(i))) + } + } + batch.Delete(heightKey(current)) + batch.Delete(saleJournalKey(cursor)) + cursor = b.Header.PrevHash + current-- + s = journal.Before + if s == nil && current > height { + return fmt.Errorf("sale: rollback crosses activation without witness") + } + if s != nil && (s.Hash != cursor || s.Height != current) { + return fmt.Errorf("sale: corrupt rollback witness") + } + } + if cursor != target { + return fmt.Errorf("sale: rollback target is not an ancestor") + } + if s == nil { + batch.Delete(saleStateKey) + } else { + raw, err := json.Marshal(s) + if err != nil { + return err + } + batch.Put(saleStateKey, raw) + } + return nil +} diff --git a/store/sale_allocation_review.go b/store/sale_allocation_review.go new file mode 100644 index 0000000..690016c --- /dev/null +++ b/store/sale_allocation_review.go @@ -0,0 +1,76 @@ +package store + +import ( + "fmt" + + "github.com/aperod/aperod/sale" +) + +// SaleAllocationReview separates the presentation residual from an unissued +// sale source. It is unsigned accounting, not a migration or spending authority. +type SaleAllocationReview struct { + RecordedBurn uint64 `json:"recorded_burn_napro,string"` + ValidatorReserve uint64 `json:"validator_reserve_napro,string"` + LPoDRewardBalance uint64 `json:"lpod_reward_balance_napro,string"` + LPoDPrincipal uint64 `json:"lpod_principal_locked_napro,string"` + LPoDUnassigned uint64 `json:"lpod_unassigned_napro,string"` + PresentationResidual uint64 `json:"presentation_residual_napro,string"` + BeforeOutsideWallets uint64 `json:"upper_bound_before_outside_wallets_napro,string"` + OutsideWalletNet *uint64 `json:"declared_outside_wallet_net_napro,string"` + AfterDeclaredWallets *uint64 `json:"upper_bound_after_declared_wallets_napro,string"` + ActivationAuthorized bool `json:"activation_authorized"` +} + +// OutsideWalletNet includes all already issued value outside the LPoD reward +// balance and its separately recorded guardian principal: free wallets, own +// validator stake, and any other issued holdings. It must be a net figure using +// the SAME burn baseline, not gross historical minting less burns a second time. +// A missing value remains unknown and never becomes an assumed zero. +func ReviewPostBurnSaleSource(c *LPoDCheckpoint, burned uint64, outsideWalletNet *uint64) (SaleAllocationReview, error) { + var out SaleAllocationReview + if c == nil || c.Allocation == nil || c.Allocation.Version != 3 || + c.Allocation.NominalSnapshot == nil { + return out, fmt.Errorf("sale review: canonical v3 allocation required") + } + if c.GenesisVesting != nil || + c.Allocation.NominalSnapshot.PreexistingGuardianReservation != 0 { + return out, fmt.Errorf("sale review: additional reserves require disjoint classification") + } + if err := c.State.Validate(); err != nil { + return out, err + } + if c.PrincipalReturned > c.PrincipalDeposited || + c.PrincipalLocked != c.PrincipalDeposited-c.PrincipalReturned { + return out, fmt.Errorf("sale review: principal conservation invalid") + } + left := sale.CapNAPRO + for _, debit := range []uint64{burned, c.Allocation.ValidatorRemaining, c.State.Balance} { + if debit > left { + return out, fmt.Errorf("sale review: post-burn reserve accounting exceeds cap") + } + left -= debit + } + if c.PrincipalLocked > left { + return out, fmt.Errorf("sale review: principal exceeds remaining allocation") + } + protected := left - c.PrincipalLocked + if c.Allocation.Remaining > protected { + return out, fmt.Errorf("sale review: earmarked LPoD capital exceeds remaining allocation") + } + protected -= c.Allocation.Remaining + out = SaleAllocationReview{ + RecordedBurn: burned, ValidatorReserve: c.Allocation.ValidatorRemaining, + LPoDRewardBalance: c.State.Balance, LPoDPrincipal: c.PrincipalLocked, + PresentationResidual: left, BeforeOutsideWallets: protected, LPoDUnassigned: c.Allocation.Remaining, + } + if outsideWalletNet != nil { + wallet := *outsideWalletNet + if wallet > out.BeforeOutsideWallets { + return SaleAllocationReview{}, fmt.Errorf("sale review: issued wallet liabilities exceed available source") + } + after := out.BeforeOutsideWallets - wallet + out.OutsideWalletNet = &wallet + out.AfterDeclaredWallets = &after + } + return out, nil +} diff --git a/store/sale_allocation_review_test.go b/store/sale_allocation_review_test.go new file mode 100644 index 0000000..9526edf --- /dev/null +++ b/store/sale_allocation_review_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "math" + "testing" + + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/sale" +) + +func netSourceFixture() *LPoDCheckpoint { + return &LPoDCheckpoint{ + Allocation: &LPoDAllocation{Version: 3, ValidatorRemaining: 199731332900000000, + InitialValidatorRemaining: 199731332900000000 + 62503922060849, + NominalSnapshot: &LPoDNominalSnapshot{NominalCirculatingBefore: 623000000000000000}}, + State: lpod.State{FundingDebit: 100000000000000000, RewardInflow: 62503922060849, SurplusInflow: 62503922060849, Balance: 100062503922060849}, + PrincipalDeposited: 5000000 * 100000000, PrincipalLocked: 5000000 * 100000000, + } +} + +func TestSalePostBurnReviewDoesNotReapplyNominalCirculation(t *testing.T) { + c := netSourceFixture() + review, err := ReviewPostBurnSaleSource(c, 13745052310704501, nil) + if err != nil { + t.Fatal(err) + } + if review.PresentationResidual != 686461110867234650 { + t.Fatalf("wrong observed residual: %d", review.PresentationResidual) + } + if review.BeforeOutsideWallets != 685961110867234650 { + t.Fatal("principal exposed as free sale source") + } + if review.OutsideWalletNet != nil || review.AfterDeclaredWallets != nil || review.ActivationAuthorized { + t.Fatal("unknown wallet liabilities became an activated budget") + } + c.Allocation.NominalSnapshot.NominalCirculatingBefore = 100000000 + other, err := ReviewPostBurnSaleSource(c, 13745052310704501, nil) + if err != nil || other.PresentationResidual != review.PresentationResidual { + t.Fatal("nominal vesting declaration treated as proved wallet issuance") + } + // Self-stake is issued outside the guardian principal, not a second reward reserve. + issued := uint64(1000000*100000000 + 100*100000000) + bound, err := ReviewPostBurnSaleSource(c, 13745052310704501, &issued) + if err != nil || *bound.AfterDeclaredWallets != review.BeforeOutsideWallets-issued { + t.Fatal("outside wallet/self-stake liabilities not protected") + } + issued++ + if *bound.OutsideWalletNet == issued { + t.Fatal("review aliased mutable caller declaration") + } +} + +func TestSalePostBurnReviewPaidRewardsNeverBecomeNewCapacity(t *testing.T) { + c := netSourceFixture() + net := uint64(100000000) + before, err := ReviewPostBurnSaleSource(c, 13745052310704501, &net) + if err != nil { + t.Fatal(err) + } + c.State.Balance -= 100000000 + c.State.AngelPaid += 100000000 + c.State.AccruedLiability += 100000000 + c.State.DeficitOutflow += 100000000 + net += 100000000 + after, err := ReviewPostBurnSaleSource(c, 13745052310704501, &net) + if err != nil || *before.AfterDeclaredWallets != *after.AfterDeclaredWallets { + t.Fatal("reward payment regenerated sale capacity") + } + c.PrincipalLocked -= 200000000 + c.PrincipalReturned += 200000000 + net += 200000000 + returned, err := ReviewPostBurnSaleSource(c, 13745052310704501, &net) + if err != nil || *returned.AfterDeclaredWallets != *before.AfterDeclaredWallets { + t.Fatal("principal refund regenerated sale capacity") + } +} + +func TestSalePostBurnReviewFailsClosed(t *testing.T) { + for _, kind := range []string{"burn", "principal", "state", "wallet", "extra"} { + t.Run(kind, func(t *testing.T) { + c := netSourceFixture() + burn := uint64(13745052310704501) + var wallet *uint64 + switch kind { + case "burn": + burn = math.MaxUint64 + case "principal": + c.PrincipalReturned = 1 + case "state": + c.State.Balance++ + case "wallet": + x := sale.CapNAPRO + wallet = &x + case "extra": + c.GenesisVesting = &GenesisVestingState{} + } + if _, err := ReviewPostBurnSaleSource(c, burn, wallet); err == nil { + t.Fatal("inconsistent accounting accepted") + } + }) + } +} diff --git a/store/sale_config.go b/store/sale_config.go new file mode 100644 index 0000000..47992bc --- /dev/null +++ b/store/sale_config.go @@ -0,0 +1,102 @@ +package store + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/sale" +) + +// LoadSaleMigrationFile reads a LOCAL operator trust configuration, never a +// witness supplied by HTTP or a peer. The configured signers are explicit trust +// roots, not roots discovered from the payment server's response. +// This performs no writes and cannot initialize or repair the allocation ledger. +func (d *DB) LoadSaleMigrationFile(path string) (*SaleMigration, error) { + state, err := d.LoadSaleState() + if err != nil { + return nil, err + } + if path == "" { + if state != nil { + return nil, fmt.Errorf("sale: activated ledger requires its signed configuration") + } + return nil, nil + } + m, err := readSignedSaleMigrationFile(path) + if err != nil { + return nil, err + } + cp := m.Checkpoint + genesisRaw, err := d.GetRawBlockByHeight(0) + if err != nil { + return nil, err + } + var genesis core.Block + if len(genesisRaw) == 0 || json.Unmarshal(genesisRaw, &genesis) != nil || genesis.Hash() != cp.Genesis { + return nil, fmt.Errorf("sale: configuration does not bind the canonical genesis") + } + hash, height, err := d.GetTip() + if err != nil { + return nil, err + } + if state == nil { + if height == ^uint64(0) || cp.Height != height+1 || cp.Parent != hash { + return nil, fmt.Errorf("sale: initial activation requires the exact canonical parent barrier") + } + lpod, err := d.LoadLPoDCheckpoint() + if err != nil { + return nil, err + } + if err := VerifySaleReserveFloors(lpod, m.Reconciliation); err != nil { + return nil, err + } + knownIssued, err := SaleKnownIssuedFloor(lpod) + if err != nil { + return nil, err + } + if err := sale.VerifyReconciledCheckpoint(cp, m.Reconciliation, genesis.Hash(), hash, + lpod.Digest(), height+1, knownIssued, m.Trusted); err != nil { + return nil, err + } + } else if state.Checkpoint != cp.Message() || state.Hash != hash || state.Height != height || height < cp.Height { + return nil, fmt.Errorf("sale: durable ledger and signed configuration disagree") + } + if state != nil && (state.Sold > cp.SaleRemaining || state.Remaining != cp.SaleRemaining-state.Sold) { + return nil, fmt.Errorf("sale: durable budget does not match its signed baseline") + } + return m, nil +} + +func readSignedSaleMigrationFile(path string) (*SaleMigration, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, 1_048_577)) + if err != nil || len(raw) > 1_048_576 { + return nil, fmt.Errorf("sale: configuration size invalid") + } + var m SaleMigration + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&m); err != nil { + return nil, err + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return nil, fmt.Errorf("sale: trailing configuration data") + } + cp := m.Checkpoint + if cp.SnapshotRoot != m.Reconciliation.Digest() { + return nil, fmt.Errorf("sale: unsigned reconciliation") + } + if err = cp.Verify(cp.Genesis, cp.Parent, cp.Height, m.Trusted); err != nil { + return nil, err + } + return &m, nil +} diff --git a/store/sale_epoch_reconciliation_test.go b/store/sale_epoch_reconciliation_test.go new file mode 100644 index 0000000..c96720f --- /dev/null +++ b/store/sale_epoch_reconciliation_test.go @@ -0,0 +1,108 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" +) + +func epochMigrationFixture(t *testing.T) (*DB, *SaleMigration, crypto.ValidatorPrivKey, crypto.Address) { + t.Helper() + g, m, key, address, records := saleStoreFixture(t) + d := seedSaleParent(t, filepath.Join(t.TempDir(), "chain"), records) + t.Cleanup(func() { _ = d.Close() }) + l, err := d.LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + e := &sale.AllocationEpochBasis{FundingHeight: l.Allocation.FundingHeight, + FundingHash: l.Allocation.FundingBlock, Burned: 13745052310704501, + ProtectedLPoDPrincipal: l.PrincipalLocked, LegacyHoldingsUnverified: true} + r := sale.Reconciliation{AllocationEpoch: e, Genesis: m.Checkpoint.Genesis, + Parent: g.Hash(), LPoDCommitment: l.Digest(), + OtherClaimsRoot: crypto.HashBytes([]byte("fixture-no-other-claims")), + HistoricIssued: e.Burned, ValidatorReserved: l.Allocation.ValidatorRemaining, + GuardianReserved: l.State.Balance, LPoDReserved: l.Allocation.Remaining} + e.Available = sale.CapNAPRO - e.Burned - r.ValidatorReserved - r.GuardianReserved - r.LPoDReserved + _, reserved, err := r.Unassigned() + if err != nil { + t.Fatal(err) + } + m.Reconciliation = r + m.Checkpoint.HistoricIssued, m.Checkpoint.OtherReserved = e.Burned, reserved + m.Checkpoint.SaleRemaining = e.Available + m.Trusted = []crypto.ValidatorPubKey{key.Public()} + signEpochMigration(t, m, key) + return d, m, key, address +} + +func signEpochMigration(t *testing.T, m *SaleMigration, key crypto.ValidatorPrivKey) { + t.Helper() + m.Checkpoint.SnapshotRoot = m.Reconciliation.Digest() + sig, err := key.Sign(m.Checkpoint.Message()) + if err != nil { + t.Fatal(err) + } + m.Checkpoint.Attestations = []sale.Attestation{{Validator: key.Public(), Signature: sig}} +} + +func TestOperatorEpochDebitsOnlyItsFullCurrentAvailableSource(t *testing.T) { + d, m, key, address := epochMigrationFixture(t) + before, err := d.LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + b, settlement := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, m.Checkpoint.Parent, "paid") + commitSaleFixture(t, d, b, m, settlement) + state, err := d.LoadSaleState() + const amount uint64 = 100 * 100000000 + if err != nil || state == nil || state.Remaining != m.Checkpoint.SaleRemaining-amount || state.Sold != amount { + t.Fatal("wrong canonical allocation debit", err) + } + if output, err := d.GetUTXO(b.Txs[len(b.Txs)-1].Hash(), 0); err != nil || output == nil { + t.Fatal("allocation debit without buyer UTXO", err) + } + after, err := d.LoadLPoDCheckpoint() + if err != nil || after.PrincipalLocked != before.PrincipalLocked { + t.Fatal("existing principal changed", err) + } +} + +func TestOperatorEpochRejectsResignedPolicyAndReserveMutations(t *testing.T) { + for _, kind := range []string{"mixed", "funding", "principal", "validator", "guardian", "unassigned", "verified-claim", "available", "smaller-budget"} { + t.Run(kind, func(t *testing.T) { + d, m, key, address := epochMigrationFixture(t) + e := m.Reconciliation.AllocationEpoch + switch kind { + case "mixed": + m.Reconciliation.NetAllocation = &sale.NetAllocationBasis{} + case "funding": + e.FundingHash[0] ^= 1 + case "principal": + e.ProtectedLPoDPrincipal++ + case "validator": + m.Reconciliation.ValidatorReserved-- + case "guardian": + m.Reconciliation.GuardianReserved-- + case "unassigned": + m.Reconciliation.LPoDReserved++ + case "verified-claim": + e.LegacyHoldingsUnverified = false + case "available": + e.Available++ + case "smaller-budget": + m.Checkpoint.SaleRemaining-- + } + signEpochMigration(t, m, key) + if err := m.Checkpoint.Verify(m.Checkpoint.Genesis, m.Checkpoint.Parent, m.Checkpoint.Height, m.Trusted); err != nil { + t.Fatal("mutation not genuinely signed", err) + } + b, _ := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, m.Checkpoint.Parent, kind) + if _, err := d.PrepareSaleBlock(b, m); err == nil { + t.Fatal("signed invalid opening accepted") + } + }) + } +} diff --git a/store/sale_epoch_reserve_floors.go b/store/sale_epoch_reserve_floors.go new file mode 100644 index 0000000..58745e0 --- /dev/null +++ b/store/sale_epoch_reserve_floors.go @@ -0,0 +1,38 @@ +package store + +import ( + "fmt" + + "github.com/aperod/aperod/sale" +) + +// A signed nominal basis preserves canonical funds and existing principal. +// It neither authenticates old global holdings nor reissues reserved funds. +func verifyEpochSaleReserveFloors(c *LPoDCheckpoint, r sale.Reconciliation) error { + e := r.AllocationEpoch + if e == nil || r.NetAllocation != nil { + return fmt.Errorf("sale: explicit, unmixed operator allocation epoch required") + } + if err := e.Validate(); err != nil { + return err + } + if c.GenesisVesting != nil || c.Allocation.NominalSnapshot.PreexistingGuardianReservation != 0 { + return fmt.Errorf("sale: additional canonical obligations require explicit classification") + } + if err := c.State.Validate(); err != nil { + return err + } + if c.PrincipalReturned > c.PrincipalDeposited || c.PrincipalLocked != c.PrincipalDeposited-c.PrincipalReturned { + return fmt.Errorf("sale: existing principal conservation invalid") + } + if e.FundingHeight != c.Allocation.FundingHeight || e.FundingHash != c.Allocation.FundingBlock || + e.ProtectedLPoDPrincipal != c.PrincipalLocked { + return fmt.Errorf("sale: operator epoch does not preserve the canonical LPoD funding and principal") + } + if r.ValidatorReserved != c.Allocation.ValidatorRemaining || r.GuardianReserved != c.State.Balance || + r.LPoDReserved != c.Allocation.Remaining { + return fmt.Errorf("sale: nominal epoch must preserve exact canonical reserves") + } + _, _, err := r.Unassigned() + return err +} diff --git a/store/sale_genesis_funding_guard.go b/store/sale_genesis_funding_guard.go new file mode 100644 index 0000000..c113170 --- /dev/null +++ b/store/sale_genesis_funding_guard.go @@ -0,0 +1,28 @@ +package store + +import "fmt" + +// The first sale protocol does not implement a shared-ledger reclassification +// into a newly activated genesis reserve. Fail closed instead of independently +// reusing the old nominal non-circulating bound after sales have reserved it. +func (d *DB) checkSaleGenesisFundingTransition(height uint64, request *LPoDSettlement, activatingSales bool) error { + if request == nil || request.GenesisVestingActivationHeight == 0 || + height < request.GenesisVestingActivationHeight { + return nil + } + c, err := d.LoadLPoDCheckpoint() + if err != nil { + return err + } + if c != nil && c.GenesisVesting != nil { + return nil + } + state, err := d.LoadSaleState() + if err != nil { + return err + } + if activatingSales || state != nil { + return fmt.Errorf("sale: new genesis funding requires a versioned shared reserve transition") + } + return nil +} diff --git a/store/sale_invoice.go b/store/sale_invoice.go new file mode 100644 index 0000000..4647ccb --- /dev/null +++ b/store/sale_invoice.go @@ -0,0 +1,102 @@ +package store + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "math/big" + "regexp" + "strings" + + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +// SaleInvoice is private merchant metadata, never consensus/public metadata. +// Decimal strings are immutable; the API display float is not a payment quote. +type SaleInvoice struct { + ProviderID string `json:"provider_id"` + Currency string `json:"currency"` + Address string `json:"address"` + Amount string `json:"amount"` + PriceUSD string `json:"price_usd"` +} + +var saleProviderID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) +var saleCurrency = regexp.MustCompile(`^[a-z0-9]{2,32}$`) +var saleDecimal = regexp.MustCompile(`^(0|[1-9][0-9]{0,29})(\.[0-9]{1,30})?$`) + +func (i SaleInvoice) Valid() bool { + value, ok := new(big.Rat).SetString(i.Amount) + return saleProviderID.MatchString(i.ProviderID) && saleCurrency.MatchString(i.Currency) && + saleDecimal.MatchString(i.Amount) && ok && value.Sign() > 0 && validSalePrice(i.PriceUSD) && + len(i.Address) > 0 && len(i.Address) <= 256 && + !strings.ContainsAny(i.Address, " \t\r\n\x00") +} + +func validSalePrice(price string) bool { + v, ok := new(big.Rat).SetString(price) + return saleDecimal.MatchString(price) && ok && v.Sign() > 0 +} + +func SaleProviderPayment(id string) crypto.Hash32 { + return crypto.Hash32(sha256.Sum256([]byte("aperod/protocol-sale/payment/v1\x00NOWPayments\x00" + id))) +} + +// BindSaleInvoice synchronously fences provider identity across all orders. +// Identical retries survive a lost HTTP response or process restart. +func (d *DB) BindSaleInvoice(id crypto.Hash32, invoice SaleInvoice) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + if !invoice.Valid() { + return fmt.Errorf("sale: invalid exact invoice") + } + order, err := d.SaleOrder(id) + if err != nil { + return err + } + if order == nil { + return fmt.Errorf("sale: invoice has no reservation") + } + if order.QuoteUSD == "" || invoice.PriceUSD != order.QuoteUSD { + return fmt.Errorf("sale: invoice price does not match frozen quote") + } + if order.Invoice != nil { + if *order.Invoice == invoice { + return nil + } + return fmt.Errorf("sale: immutable invoice changed") + } + if order.Payment != (crypto.Hash32{}) { + return fmt.Errorf("sale: invoice binding after payment refused") + } + payment := SaleProviderPayment(invoice.ProviderID) + key := append([]byte("sale/invoice/"), payment[:]...) + existing, err := d.get(key) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("sale: invoice already belongs to another order") + } + if used, err := d.SaleConsumed('p', payment); err != nil { + return err + } else if used { + return fmt.Errorf("sale: invoice payment already consumed") + } + if existing, err := d.get(saleOrderPaymentKey(payment)); err != nil { + return err + } else if existing != nil { + return fmt.Errorf("sale: payment identity already assigned") + } + order.Invoice = &invoice + raw, err := json.Marshal(order) + if err != nil { + return err + } + batch := new(leveldb.Batch) + batch.Put(saleOrderKey(id), raw) + batch.Put(key, id[:]) + return d.db.Write(batch, &opt.WriteOptions{Sync: true}) +} diff --git a/store/sale_ledger_reconcile.go b/store/sale_ledger_reconcile.go new file mode 100644 index 0000000..d778065 --- /dev/null +++ b/store/sale_ledger_reconcile.go @@ -0,0 +1,123 @@ +package store + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +// SaleLedgerReconcileReport proves a bounded, hash-bound, sale-free interval. +// It is not a new checkpoint, issuance, or proof of buyer credit. +type SaleLedgerReconcileReport struct { + Status string `json:"status"` + FromHeight uint64 `json:"from_height"` + TipHeight uint64 `json:"tip_height"` + Blocks uint64 `json:"blocks"` + Remaining string `json:"remaining_napro"` + Sold string `json:"sold_napro"` +} + +// ReconcileSaleLedger only catches up a previously activated ledger across +// canonical bodies containing NO sales. Any sale requires a different full +// reconciliation, not ignoring monetary evidence. Caller must use an exclusive +// stopped database or an immutable copy; expected tip binds operator consent. +// All journals and the final ledger pointer are committed in one synchronous +// batch. No UTXOs, orders, fences, canonical tips or allocation amounts change. +func (d *DB) ReconcileSaleLedger(path string, expectedHash crypto.Hash32, expectedHeight uint64, apply bool) (*SaleLedgerReconcileReport, error) { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + m, err := readSignedSaleMigrationFile(path) + if err != nil { + return nil, err + } + c := m.Checkpoint + raw, err := d.GetRawBlockByHeight(0) + var genesis core.Block + if err != nil || len(raw) == 0 || json.Unmarshal(raw, &genesis) != nil || genesis.Hash() != c.Genesis { + return nil, fmt.Errorf("sale: canonical genesis binding unavailable") + } + before, err := d.LoadSaleState() + if err != nil || before == nil { + return nil, fmt.Errorf("sale: existing activated ledger required") + } + tip, height, err := d.GetTip() + if err != nil || tip != expectedHash || height != expectedHeight || height == ^uint64(0) || tip == (crypto.Hash32{}) { + return nil, fmt.Errorf("sale: expected canonical tip mismatch") + } + if before.Checkpoint != c.Message() || before.Height < c.Height || before.Height > height || + before.Sold > c.SaleRemaining || before.Remaining != c.SaleRemaining-before.Sold { + return nil, fmt.Errorf("sale: signed baseline or budget mismatch") + } + if height-before.Height > 100000 { + return nil, fmt.Errorf("sale: reconciliation interval exceeds bounded limit") + } + anchor, found, err := d.GetCanonicalHash(before.Height) + if err != nil || !found || anchor != before.Hash { + return nil, fmt.Errorf("sale: ledger is not a canonical anchor") + } + current := *before + batch := new(leveldb.Batch) + for h := before.Height + 1; h <= height; h++ { + raw, err := d.GetRawBlockByHeight(h) + if err != nil || len(raw) == 0 { + return nil, fmt.Errorf("sale: canonical body unavailable at %d", h) + } + var b core.Block + if json.Unmarshal(raw, &b) != nil || b.Header.Height != h || b.Header.PrevHash != current.Hash || + b.Header.MerkleRoot != core.MerkleRoot(b.Txs) { + return nil, fmt.Errorf("sale: canonical body binding invalid at %d", h) + } + hash := b.Hash() + canonical, found, err := d.GetCanonicalHash(h) + if err != nil || !found || canonical != hash { + return nil, fmt.Errorf("sale: canonical hash mismatch at %d", h) + } + for _, tx := range b.Txs { + if tx.IsSale() { + return nil, fmt.Errorf("sale: interval contains a sale at %d; monetary replay required", h) + } + } + next, err := current.Advance(c, h, current.Hash, hash, nil, d.SaleConsumed) + if err != nil { + return nil, err + } + if next.Remaining != before.Remaining || next.Sold != before.Sold { + return nil, fmt.Errorf("sale: budget changed") + } + journal, err := json.Marshal(saleJournal{Before: ¤t}) + if err != nil { + return nil, err + } + previous, err := d.get(saleJournalKey(hash)) + if err != nil { + return nil, err + } + if previous != nil && !bytes.Equal(previous, journal) { + return nil, fmt.Errorf("sale: conflicting rollback journal at %d", h) + } + batch.Put(saleJournalKey(hash), journal) + current = next + } + if current.Hash != tip || current.Height != height { + return nil, fmt.Errorf("sale: interval does not terminate at canonical tip") + } + status := "verified_no_sales" + if apply && height > before.Height { + state, err := json.Marshal(current) + if err != nil { + return nil, err + } + batch.Put(saleStateKey, state) + if err = d.db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + return nil, err + } + status = "reconciled" + } + return &SaleLedgerReconcileReport{Status: status, FromHeight: before.Height, TipHeight: height, + Blocks: height - before.Height, Remaining: fmt.Sprint(before.Remaining), Sold: fmt.Sprint(before.Sold)}, nil +} diff --git a/store/sale_ledger_reconcile_test.go b/store/sale_ledger_reconcile_test.go new file mode 100644 index 0000000..e91092c --- /dev/null +++ b/store/sale_ledger_reconcile_test.go @@ -0,0 +1,126 @@ +package store + +import ( + "encoding/binary" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { + for _, scenario := range []string{"valid", "missing-body", "wrong-merkle", "wrong-parent", "wrong-baseline", "wrong-tip", "sale-in-gap", "journal-conflict"} { + t.Run(scenario, func(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + dir := t.TempDir() + path := filepath.Join(dir, "chain.db") + d := seedSaleParent(t, path, records) + activation, p := saleTestBlock(t, d, m, key, address, g.Header.Height+1, g.Hash(), "reconcile") + commitSaleFixture(t, d, activation, m, p) // genuinely funded canonical sale + before, _ := d.LoadSaleState() + old := saleUpgradeRecords(t, d) + next := core.Block{Header: activation.Header} + next.Header.Height++ + next.Header.PrevHash = activation.Hash() + next.Header.Timestamp++ + next.Txs = nil + next.Header.MerkleRoot = core.MerkleRoot(nil) + switch scenario { + case "wrong-parent": + next.Header.PrevHash = crypto.HashBytes([]byte("wrong")) + case "wrong-merkle": + next.Header.MerkleRoot = crypto.HashBytes([]byte("wrong")) + case "sale-in-gap": + next.Txs = activation.Txs + next.Header.MerkleRoot = core.MerkleRoot(next.Txs) + } + if err := next.Header.Sign(key); err != nil { + t.Fatal(err) + } + hash, height := next.Hash(), next.Header.Height + raw, _ := json.Marshal(next) + if err := d.PutRawBlock(hash, height, raw); err != nil { + t.Fatal(err) + } + hb := make([]byte, 8) + binary.LittleEndian.PutUint64(hb, height) + if err := d.db.Put([]byte("m/tip/hash"), hash[:], nil); err != nil { + t.Fatal(err) + } + if err := d.db.Put([]byte("m/tip/height"), hb, nil); err != nil { + t.Fatal(err) + } + if scenario == "missing-body" { + d.db.Delete(append([]byte("b/"), hash[:]...), nil) + } + if scenario == "wrong-baseline" { + m.Checkpoint.Attestations[0].Signature[0] ^= 1 + } + if scenario == "journal-conflict" { + d.db.Put(saleJournalKey(hash), []byte("conflict"), nil) + } + config := filepath.Join(dir, "sale.json") + encoded, _ := json.Marshal(m) + if err := os.WriteFile(config, encoded, 0600); err != nil { + t.Fatal(err) + } + snapshot := saleUpgradeRecords(t, d) + old = snapshot + expected := hash + if scenario == "wrong-tip" { + expected = crypto.HashBytes([]byte("wrong")) + } + _, err := d.ReconcileSaleLedger(config, expected, height, false) + if (err == nil) != (scenario == "valid") { + t.Fatalf("unexpected dry-run result: %v", err) + } + if !reflect.DeepEqual(snapshot, saleUpgradeRecords(t, d)) { + t.Fatal("dry-run changed data") + } + if scenario == "valid" { + r, err := d.ReconcileSaleLedger(config, hash, height, true) + if err != nil || r.Status != "reconciled" { + t.Fatalf("apply: %v %+v", err, r) + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + d, err = Open(path) + if err != nil { + t.Fatal(err) + } + current, err := d.LoadSaleState() + if err != nil { + t.Fatal(err) + } + if current.Remaining != before.Remaining || current.Sold != before.Sold || current.Checkpoint != before.Checkpoint || current.Hash != hash || current.Height != height { + t.Fatal("budget or canonical state changed incorrectly") + } + after := saleUpgradeRecords(t, d) + for k, v := range old { + if k != string(saleStateKey) && !reflect.DeepEqual(v, after[k]) { + t.Fatalf("original financial record changed: %q", k) + } + } + if _, err := d.LoadSaleMigrationFile(config); err != nil { + t.Fatal(err) + } + if _, err := d.ReconcileSaleLedger(config, hash, height, true); err != nil { + t.Fatal("idempotent apply:", err) + } + } else { + if _, err := d.ReconcileSaleLedger(config, expected, height, true); err == nil { + t.Fatal("unsafe apply accepted") + } + if !reflect.DeepEqual(snapshot, saleUpgradeRecords(t, d)) { + t.Fatal("refusal changed data") + } + } + d.Close() + }) + } +} diff --git a/store/sale_net_reconciliation_test.go b/store/sale_net_reconciliation_test.go new file mode 100644 index 0000000..129f801 --- /dev/null +++ b/store/sale_net_reconciliation_test.go @@ -0,0 +1,190 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" +) + +func TestSaleNetReconciliationRequiresExplicitSignedRootAndCanonicalReserves(t *testing.T) { + c := netSourceFixture() + genesis := crypto.HashBytes([]byte("net-source-genesis")) + parent := crypto.HashBytes([]byte("net-source-parent")) + key, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + // Synthetic declared wallet/self-stake amount is NOT production evidence. + net := &sale.NetAllocationBasis{Burned: 13745052310704501, WalletNet: 100010000000000, + WalletAccountingRoot: crypto.HashBytes([]byte("synthetic outside-wallet declaration"))} + consumed, err := net.Consumed() + if err != nil { + t.Fatal(err) + } + r := sale.Reconciliation{NetAllocation: net, Genesis: genesis, Parent: parent, LPoDCommitment: c.Digest(), + OtherClaimsRoot: crypto.HashBytes([]byte("no-other-claims")), HistoricIssued: consumed, + ValidatorReserved: c.Allocation.ValidatorRemaining, GuardianReserved: c.State.Balance, LPoDReserved: c.PrincipalLocked} + budget, reserved, err := r.Unassigned() + if err != nil { + t.Fatal(err) + } + cp := sale.Checkpoint{Genesis: genesis, Parent: parent, Height: 10, Network: crypto.MainnetByte, + SaleAuthority: pub, SnapshotRoot: r.Digest(), HistoricIssued: consumed, OtherReserved: reserved, SaleRemaining: budget} + sign := func() { + cp.SnapshotRoot = r.Digest() + sig, err := key.Sign(cp.Message()) + if err != nil { + t.Fatal(err) + } + cp.Attestations = []sale.Attestation{{Validator: pub, Signature: sig}} + } + sign() + if err := VerifySaleReserveFloors(c, r); err != nil { + t.Fatal(err) + } + if err := sale.VerifyReconciledCheckpoint(cp, r, genesis, parent, c.Digest(), 10, 623000000000000000, []crypto.ValidatorPubKey{pub}); err != nil { + t.Fatal(err) + } + originalRoot := cp.SnapshotRoot + r.NetAllocation = nil + if r.Digest() == originalRoot { + t.Fatal("legacy and corrected bases share signature domain") + } + if err := VerifySaleReserveFloors(c, r); err == nil { + t.Fatal("unsigned implicit interpretation change accepted") + } + r.NetAllocation = net + for _, kind := range []string{"wallet-root", "principal", "rewards", "validator", "wallet-consumed"} { + t.Run(kind, func(t *testing.T) { + m := r + n := *net + m.NetAllocation = &n + switch kind { + case "wallet-root": + n.WalletAccountingRoot = crypto.Hash32{} + case "principal": + m.LPoDReserved-- + case "rewards": + m.GuardianReserved-- + case "validator": + m.ValidatorReserved-- + case "wallet-consumed": + n.WalletNet++ + } + cp.SnapshotRoot = m.Digest() + sig, err := key.Sign(cp.Message()) + if err != nil { + t.Fatal(err) + } + cp.Attestations = []sale.Attestation{{Validator: pub, Signature: sig}} + if err := cp.Verify(genesis, parent, 10, []crypto.ValidatorPubKey{pub}); err != nil { + t.Fatal("mutation was not genuinely signed", err) + } + if err := VerifySaleReserveFloors(c, m); err == nil { + t.Fatal("signed invalid net allocation accepted") + } + }) + } +} + +func TestSaleNetCheckpointLoadsOnlyAtExactParentAndRejectsResignedPrincipalOmission(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + chainPath := filepath.Join(t.TempDir(), "chain") + d := seedSaleParent(t, chainPath, records) + defer func() { d.Close() }() + l, err := d.LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + net := &sale.NetAllocationBasis{Burned: 0, WalletNet: 100000000, + WalletAccountingRoot: crypto.HashBytes([]byte("synthetic wallet inventory root"))} + consumed, err := net.Consumed() + if err != nil { + t.Fatal(err) + } + m.Reconciliation.NetAllocation = net + m.Reconciliation.HistoricIssued = consumed + m.Reconciliation.ValidatorReserved = l.Allocation.ValidatorRemaining + m.Reconciliation.GuardianReserved = l.State.Balance + m.Reconciliation.LPoDReserved = l.PrincipalLocked + l.Allocation.Remaining + m.Reconciliation.OtherReserved = 0 + m.Trusted = []crypto.ValidatorPubKey{key.Public()} + path := filepath.Join(t.TempDir(), "migration.json") + write := func() { + budget, reserved, err := m.Reconciliation.Unassigned() + if err != nil { + t.Fatal(err) + } + m.Checkpoint.HistoricIssued = m.Reconciliation.HistoricIssued + m.Checkpoint.OtherReserved = reserved + m.Checkpoint.SaleRemaining = budget + m.Checkpoint.SnapshotRoot = m.Reconciliation.Digest() + sig, err := key.Sign(m.Checkpoint.Message()) + if err != nil { + t.Fatal(err) + } + m.Checkpoint.Attestations = []sale.Attestation{{Validator: key.Public(), Signature: sig}} + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + write() + if _, err := d.LoadSaleMigrationFile(path); err != nil { + t.Fatal("genuine signed exact-parent control rejected", err) + } + m.Reconciliation.LPoDReserved-- + write() + if err := m.Checkpoint.Verify(m.Checkpoint.Genesis, g.Hash(), g.Header.Height+1, m.Trusted); err != nil { + t.Fatal("mutation not genuinely signed", err) + } + if _, err := d.LoadSaleMigrationFile(path); err == nil || !strings.Contains(err.Error(), "canonical reserves") { + t.Fatal("signed omission accepted", err) + } + tip, height, err := d.GetTip() + if err != nil || tip != g.Hash() || height != g.Header.Height { + t.Fatal("read-only configuration check changed tip") + } + state, err := d.LoadSaleState() + if err != nil || state != nil { + t.Fatal("read-only configuration check initialized ledger") + } + m.Reconciliation.LPoDReserved++ + write() + if _, err := d.LoadSaleMigrationFile(path); err != nil { + t.Fatal(err) + } + block, settlement := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "net-first") + commitSaleFixture(t, d, block, m, settlement) + credited := block.Txs[len(block.Txs)-1] + output, err := d.GetUTXO(credited.Hash(), 0) + state, loadErr := d.LoadSaleState() + if err != nil || output == nil || output.AmountCommit != credited.Outputs[0].AmountCommit || + loadErr != nil || state == nil || state.Sold != 100*100000000 || + state.Remaining != m.Checkpoint.SaleRemaining-state.Sold { + t.Fatal("net source debit and canonical recipient output were not conserved") + } + beforeRestart := *state + if err := d.Close(); err != nil { + t.Fatal(err) + } + d, err = Open(chainPath) + if err != nil { + t.Fatal(err) + } + if _, err := d.LoadSaleMigrationFile(path); err != nil { + t.Fatal("net checkpoint lost on real reopen", err) + } + state, err = d.LoadSaleState() + if err != nil || state == nil || *state != beforeRestart { + t.Fatal("reopen lost net source debit") + } +} diff --git a/store/sale_net_reserve_floors.go b/store/sale_net_reserve_floors.go new file mode 100644 index 0000000..560e076 --- /dev/null +++ b/store/sale_net_reserve_floors.go @@ -0,0 +1,70 @@ +package store + +import ( + "fmt" + + "github.com/aperod/aperod/sale" +) + +func verifyNetSaleReserveFloors(c *LPoDCheckpoint, r sale.Reconciliation) error { + // This initial V2 form supports the current funded V3 pool only. + // Independent extra funding cannot be hidden or guessed into this basis. + review, err := ReviewPostBurnSaleSource(c, r.NetAllocation.Burned, &r.NetAllocation.WalletNet) + if err != nil { + return err + } + consumed, err := r.NetAllocation.Consumed() + if err != nil { + return err + } + if r.HistoricIssued != consumed || r.ValidatorReserved != review.ValidatorReserve || + r.GuardianReserved != review.LPoDRewardBalance || r.LPoDReserved != review.LPoDPrincipal+review.LPoDUnassigned { + return fmt.Errorf("sale: post-burn canonical reserves or consumed allocation mismatch") + } + if c.State.FundingDebit != sale.MinimumGuardianReserveNAPRO { + return fmt.Errorf("sale: canonical Guardian initial allocation missing") + } + floor, err := saleRewardWalletFloor(c, r.NetAllocation.Burned) + if err != nil { + return err + } + if r.NetAllocation.WalletNet < floor { + return fmt.Errorf("sale: known post-LPoD wallet reward issuance omitted") + } + // The old declared 1B has already funded this pool; reserve its CURRENT + // balance, and principal separately, rather than earmarking another 1B. + _, _, err = r.Unassigned() + return err +} + +// This is a conservative lower bound, not a replacement for the attested +// wallet opening. Give ALL declared burns and locked guardian principal the +// benefit of reducing the known reward outflow: their provenance may include +// pre-anchor funds. Never subtract validator-to-LPoD redistribution twice. +func saleRewardWalletFloor(c *LPoDCheckpoint, burned uint64) (uint64, error) { + if c == nil || c.Allocation == nil { + return 0, fmt.Errorf("sale: reward accounting unavailable") + } + a := c.Allocation + if a.InitialValidatorRemaining > sale.CapNAPRO || a.ValidatorRemaining > a.InitialValidatorRemaining { + return 0, fmt.Errorf("sale: invalid post-LPoD validator draw") + } + draw := a.InitialValidatorRemaining - a.ValidatorRemaining + if c.State.RewardInflow > draw { + return 0, fmt.Errorf("sale: LPoD reward inflow exceeds validator draw") + } + paid := draw - c.State.RewardInflow + for _, outflow := range []uint64{c.State.LeaderPaid, c.State.AngelPaid} { + if outflow > sale.CapNAPRO-paid { + return 0, fmt.Errorf("sale: known reward issuance exceeds cap") + } + paid += outflow + } + for _, possiblyRemoved := range []uint64{burned, c.PrincipalLocked} { + if possiblyRemoved >= paid { + return 0, nil + } + paid -= possiblyRemoved + } + return paid, nil +} diff --git a/store/sale_orders.go b/store/sale_orders.go new file mode 100644 index 0000000..fdc59cc --- /dev/null +++ b/store/sale_orders.go @@ -0,0 +1,311 @@ +package store + +import ( + "encoding/json" + "fmt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// SaleOrder is private operator storage, never public chain metadata. +// Reserve before creating an external invoice. Mark paid only after verifying +// the provider's payment identity, currency and exact received amount. +type SaleOrder struct { + Order crypto.Hash32 `json:"order"` + Checkpoint crypto.Hash32 `json:"checkpoint"` + Recipient crypto.Address `json:"recipient"` + Amount uint64 `json:"amount_napro,string"` + Payment crypto.Hash32 `json:"payment"` + Invoice *SaleInvoice `json:"invoice,omitempty"` + QuoteUSD string `json:"quote_usd,omitempty"` +} + +// SaleDelivery is canonical inclusion, not a buyer recovery acknowledgement. +type SaleDelivery struct { + Block crypto.Hash32 + Transaction crypto.Hash32 + Height uint64 +} + +func (d *DB) SaleOrderDelivery(id crypto.Hash32) (*SaleDelivery, error) { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + return d.saleOrderDeliveryLocked(id) +} + +func (d *DB) saleOrderDeliveryLocked(id crypto.Hash32) (*SaleDelivery, error) { + raw, err := d.get(saleFence('o', id)) + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + if len(raw) != 32 { + return nil, fmt.Errorf("sale: corrupt delivery fence") + } + var hash crypto.Hash32 + copy(hash[:], raw) + body, err := d.GetRawBlock(hash) + if err != nil { + return nil, err + } + var block core.Block + if body == nil || json.Unmarshal(body, &block) != nil || block.Hash() != hash { + return nil, fmt.Errorf("sale: delivery body unavailable") + } + canonical, err := d.get(heightKey(block.Header.Height)) + if err != nil { + return nil, err + } + if len(canonical) != 32 || string(canonical) != string(hash[:]) { + return nil, fmt.Errorf("sale: delivery is no longer canonical") + } + for _, tx := range block.Txs { + if !tx.IsSale() { + continue + } + auth, err := tx.SaleAuthorization() + if err != nil { + return nil, err + } + if auth.Order == id { + return &SaleDelivery{Block: hash, Transaction: tx.Hash(), Height: block.Header.Height}, nil + } + } + return nil, fmt.Errorf("sale: delivery fence has no canonical transaction") +} + +func saleOrderKey(id crypto.Hash32) []byte { return append([]byte("sale/order/"), id[:]...) } +func salePendingKey(id crypto.Hash32) []byte { return append([]byte("sale/pending/"), id[:]...) } +func saleOrderPaymentKey(id crypto.Hash32) []byte { + return append([]byte("sale/order-payment/"), id[:]...) +} + +func (d *DB) SaleOrder(id crypto.Hash32) (*SaleOrder, error) { + raw, err := d.get(saleOrderKey(id)) + if err != nil || raw == nil { + return nil, err + } + var order SaleOrder + if err := json.Unmarshal(raw, &order); err != nil { + return nil, fmt.Errorf("sale: corrupt order: %w", err) + } + if order.Order != id || order.Amount == 0 { + return nil, fmt.Errorf("sale: inconsistent order") + } + return &order, nil +} + +func (d *DB) pendingSaleOrders() ([]SaleOrder, error) { + it := d.db.NewIterator(util.BytesPrefix([]byte("sale/pending/")), nil) + defer it.Release() + var orders []SaleOrder + for it.Next() { + if len(orders) >= 4096 || len(it.Value()) != 32 { + return nil, fmt.Errorf("sale: pending order bound or identity invalid") + } + var id crypto.Hash32 + copy(id[:], it.Value()) + order, err := d.SaleOrder(id) + if err != nil { + return nil, err + } + if order == nil { + return nil, fmt.Errorf("sale: orphan pending reservation") + } + orders = append(orders, *order) + } + return orders, it.Error() +} + +// ReservedSaleOrders is an issuer-local obligation, not extra issuance. +func (d *DB) ReservedSaleOrders() (uint64, error) { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + orders, err := d.pendingSaleOrders() + if err != nil { + return 0, err + } + var total uint64 + for _, order := range orders { + if order.Amount > ^uint64(0)-total { + return 0, fmt.Errorf("sale: reservation overflow") + } + total += order.Amount + } + return total, nil +} + +func (d *DB) ReserveSaleOrder(order SaleOrder, network crypto.NetworkByte) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + actual, _, _, err := crypto.DecodeAddress(order.Recipient) + if order.QuoteUSD != "" && !validSalePrice(order.QuoteUSD) { + return fmt.Errorf("sale: invalid frozen quote") + } + if err != nil || actual != network || order.Order == (crypto.Hash32{}) || + order.Payment != (crypto.Hash32{}) || order.Invoice != nil || order.Amount == 0 { + return fmt.Errorf("sale: invalid reservation") + } + state, err := d.LoadSaleState() + if err != nil { + return err + } + if state == nil || state.Checkpoint != order.Checkpoint { + return fmt.Errorf("sale: activated checkpoint unavailable") + } + tip, height, err := d.GetTip() + if err != nil { + return err + } + if tip != state.Hash || height != state.Height { + return fmt.Errorf("sale: state does not match canonical tip") + } + existing, err := d.SaleOrder(order.Order) + if err != nil { + return err + } + if existing != nil { + if existing.Recipient != order.Recipient || existing.Amount != order.Amount || existing.Checkpoint != order.Checkpoint || existing.QuoteUSD != order.QuoteUSD { + return fmt.Errorf("sale: conflicting reservation retry") + } + return nil + } + used, err := d.SaleConsumed('o', order.Order) + if err != nil { + return err + } + if used { + return fmt.Errorf("sale: order already consumed") + } + pending, err := d.pendingSaleOrders() + if err != nil { + return err + } + if len(pending) >= 4096 { + return fmt.Errorf("sale: pending order capacity exceeded") + } + available := state.Remaining + for _, previous := range pending { + if previous.Checkpoint != state.Checkpoint || previous.Amount > available { + return fmt.Errorf("sale: inconsistent reserved allocation") + } + available -= previous.Amount + } + if order.Amount > available { + return fmt.Errorf("sale: allocation is already reserved") + } + raw, err := json.Marshal(order) + if err != nil { + return err + } + batch := new(leveldb.Batch) + batch.Put(saleOrderKey(order.Order), raw) + batch.Put(salePendingKey(order.Order), order.Order[:]) + return d.db.Write(batch, &opt.WriteOptions{Sync: true}) +} + +func (d *DB) MarkSaleOrderPaid(id, payment crypto.Hash32) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + if payment == (crypto.Hash32{}) { + return fmt.Errorf("sale: empty payment identity") + } + order, err := d.SaleOrder(id) + if err != nil { + return err + } + if order == nil { + return fmt.Errorf("sale: unreserved paid order") + } + if order.Invoice != nil && SaleProviderPayment(order.Invoice.ProviderID) != payment { + return fmt.Errorf("sale: payment does not match bound invoice") + } + if bound, err := d.get(append([]byte("sale/invoice/"), payment[:]...)); err != nil { + return err + } else if bound != nil && string(bound) != string(id[:]) { + return fmt.Errorf("sale: payment invoice belongs to another order") + } + if order.Payment != (crypto.Hash32{}) { + if order.Payment == payment { + return nil + } + return fmt.Errorf("sale: conflicting paid retry") + } + if other, err := d.get(saleOrderPaymentKey(payment)); err != nil { + return err + } else if other != nil { + return fmt.Errorf("sale: payment belongs to another order") + } + if used, err := d.SaleConsumed('p', payment); err != nil { + return err + } else if used { + return fmt.Errorf("sale: payment already consumed") + } + order.Payment = payment + raw, err := json.Marshal(order) + if err != nil { + return err + } + batch := new(leveldb.Batch) + batch.Put(saleOrderKey(id), raw) + batch.Put(saleOrderPaymentKey(payment), id[:]) + return d.db.Write(batch, &opt.WriteOptions{Sync: true}) +} + +// PendingPaidSales uses only durable paid orders and the current block context. +// A crash before canonical commit leaves the obligation pending for retry. +func (d *DB) PendingPaidSales(genesis crypto.Hash32, height uint64, parent crypto.Hash32, + network crypto.NetworkByte, signer interface { + Sign(crypto.Hash32) ([]byte, error) + }) ([]core.Transaction, error) { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + orders, err := d.pendingSaleOrders() + if err != nil { + return nil, err + } + state, err := d.LoadSaleState() + if err != nil { + return nil, err + } + if state == nil { + // The activation block establishes the signed budget before invoices + // may reserve it. Reorged paid obligations wait for reactivation. + return nil, nil + } + if height != state.Height+1 || parent != state.Hash { + return nil, fmt.Errorf("sale: stale payout context") + } + available := state.Remaining + for _, order := range orders { + if order.Checkpoint != state.Checkpoint || order.Amount > available { + return nil, fmt.Errorf("sale: outstanding allocation obligations exceed canonical budget") + } + available -= order.Amount + } + var txs []core.Transaction + for _, order := range orders { + if order.Checkpoint != state.Checkpoint { + return nil, fmt.Errorf("sale: pending order checkpoint changed") + } + if order.Payment == (crypto.Hash32{}) { + continue + } + tx, err := core.BuildSaleTransaction(core.SaleAuthorization{Genesis: genesis, Height: height, + Parent: parent, Order: order.Order, Payment: order.Payment, Amount: order.Amount}, order.Recipient, network, signer) + if err != nil { + return nil, err + } + txs = append(txs, *tx) + if len(txs) == 128 { + break + } + } + return txs, nil +} diff --git a/store/sale_orders_test.go b/store/sale_orders_test.go new file mode 100644 index 0000000..a9e9f3c --- /dev/null +++ b/store/sale_orders_test.go @@ -0,0 +1,126 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestSaleReservationPaymentAndRollbackObligation(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + path := t.TempDir() + d := seedSaleParent(t, path, records) + defer func() { d.Close() }() + initial, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "activation") + commitSaleFixture(t, d, initial, m, r) + order := SaleOrder{Order: crypto.HashBytes([]byte("reserved-order")), Checkpoint: m.Checkpoint.Message(), Recipient: address, Amount: 500 * 100000000, QuoteUSD: "0.08"} + if err := d.ReserveSaleOrder(order, m.Checkpoint.Network); err != nil { + t.Fatal(err) + } + if err := d.ReserveSaleOrder(order, m.Checkpoint.Network); err != nil { + t.Fatal("exact retry rejected", err) + } + changed := order + changed.Amount++ + if d.ReserveSaleOrder(changed, m.Checkpoint.Network) == nil { + t.Fatal("conflicting retry accepted") + } + tooMuch := order + tooMuch.Order = crypto.HashBytes([]byte("oversubscribed")) + tooMuch.Amount = m.Checkpoint.SaleRemaining + if d.ReserveSaleOrder(tooMuch, m.Checkpoint.Network) == nil { + t.Fatal("already reserved allocation sold twice") + } + if txs, err := d.PendingPaidSales(m.Checkpoint.Genesis, initial.Header.Height+1, initial.Hash(), m.Checkpoint.Network, key); err != nil || len(txs) != 0 { + t.Fatal("unpaid reservation became spendable", err) + } + invoice := SaleInvoice{ProviderID: "payment-123", Currency: "btc", Address: "bc1-test", Amount: "0.001000000000000001", PriceUSD: "0.08"} + if err := d.BindSaleInvoice(order.Order, invoice); err != nil { + t.Fatal(err) + } + if err := d.BindSaleInvoice(order.Order, invoice); err != nil { + t.Fatal("invoice retry rejected", err) + } + wrongInvoice := invoice + wrongInvoice.Amount = "0.002" + if d.BindSaleInvoice(order.Order, wrongInvoice) == nil { + t.Fatal("quote mutated") + } + payment := SaleProviderPayment(invoice.ProviderID) + if err := d.MarkSaleOrderPaid(order.Order, payment); err != nil { + t.Fatal(err) + } + if err := d.MarkSaleOrderPaid(order.Order, payment); err != nil { + t.Fatal("paid retry rejected", err) + } + second := order + second.Order = crypto.HashBytes([]byte("second-order")) + if err := d.ReserveSaleOrder(second, m.Checkpoint.Network); err != nil { + t.Fatal(err) + } + if d.BindSaleInvoice(second.Order, invoice) == nil { + t.Fatal("same invoice assigned twice") + } + if d.MarkSaleOrderPaid(second.Order, payment) == nil { + t.Fatal("payment assigned twice") + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + var err error + d, err = Open(path) + if err != nil { + t.Fatal(err) + } + restored, err := d.SaleOrder(order.Order) + if err != nil || restored == nil || restored.Invoice == nil || *restored.Invoice != invoice || restored.QuoteUSD != "0.08" { + t.Fatal("immutable exact invoice or frozen price lost on reopening", err) + } + txs, err := d.PendingPaidSales(m.Checkpoint.Genesis, initial.Header.Height+1, initial.Hash(), m.Checkpoint.Network, key) + if err != nil || len(txs) != 1 { + t.Fatal("paid obligation lost at restart", err) + } + block, settlement := saleTestBlock(t, d, m, key, address, initial.Header.Height+1, initial.Hash(), "replace") + block.Txs[len(block.Txs)-1] = txs[0] + settlement.Transactions = append([]core.Transaction(nil), block.Txs[2:]...) + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + block.Header.Sign(key) + commitSaleFixture(t, d, block, m, settlement) + delivery, err := d.SaleOrderDelivery(order.Order) + if err != nil || delivery == nil || delivery.Block != block.Hash() || delivery.Transaction != txs[0].Hash() { + t.Fatal("exact canonical payment receipt missing", err) + } + if pending, err := d.PendingPaidSales(m.Checkpoint.Genesis, block.Header.Height+1, block.Hash(), m.Checkpoint.Network, key); err != nil || len(pending) != 0 { + t.Fatal("canonical payment remained pending", err) + } + reserved, err := d.ReservedSaleOrders() + if err != nil || reserved != second.Amount { + t.Fatal("wrong remaining reservations", err) + } + if err = d.PutTip(initial.Hash(), initial.Header.Height); err != nil { + t.Fatal(err) + } + if delivery, err = d.SaleOrderDelivery(order.Order); err != nil || delivery != nil { + t.Fatal("rolled back delivery reported as included", err) + } + retry, err := d.PendingPaidSales(m.Checkpoint.Genesis, initial.Header.Height+1, initial.Hash(), m.Checkpoint.Network, key) + if err != nil || len(retry) != 1 { + t.Fatal("rollback discarded paid obligation", err) + } + auth, _ := retry[0].SaleAuthorization() + if auth.Payment != payment || auth.Order != order.Order || auth.Amount != order.Amount { + t.Fatal("retry changed paid intent") + } + // A private order is persisted as exact integer JSON, not a float. + raw, err := d.get(saleOrderKey(order.Order)) + if err != nil { + t.Fatal(err) + } + var persisted map[string]any + json.Unmarshal(raw, &persisted) + if _, ok := persisted["amount_napro"].(string); !ok { + t.Fatal("inexact persisted amount") + } +} diff --git a/store/sale_reconciliation_floor.go b/store/sale_reconciliation_floor.go new file mode 100644 index 0000000..7e9e4a9 --- /dev/null +++ b/store/sale_reconciliation_floor.go @@ -0,0 +1,34 @@ +package store + +import ( + "fmt" + "github.com/aperod/aperod/sale" +) + +// SaleKnownIssuedFloor includes rewards canonically recorded after the nominal +// activation declaration. Their debit reduces ValidatorRemaining; omitting the +// matching issuance would make already-paid rewards available for sale again. +// This is a conservative minimum, not complete historical issuance proof. +func SaleKnownIssuedFloor(c *LPoDCheckpoint) (uint64, error) { + if c == nil || c.Allocation == nil || c.Allocation.NominalSnapshot == nil { + return 0, fmt.Errorf("sale: canonical nominal issuance declaration missing") + } + before := c.Allocation.NominalSnapshot.NominalCirculatingBefore + rewards := c.State.RewardInflow + if before > sale.CapNAPRO || rewards > sale.CapNAPRO-before { + return 0, fmt.Errorf("sale: known issuance exceeds the 10B cap") + } + issued := before + rewards + // Genesis vesting spends a separately reclassified NON-circulating + // reserve. Its paid portion must move into the issuance floor as its + // Remaining falls, or those credited coins become sale capacity again. + // LiquidityPaid is deliberately not added: that funding declaration + // reclassifies already nominally circulating liquidity, not new issuance. + if c.GenesisVesting != nil { + if c.GenesisVesting.Paid > sale.CapNAPRO-issued { + return 0, fmt.Errorf("sale: genesis delivery exceeds the 10B cap") + } + issued += c.GenesisVesting.Paid + } + return issued, nil +} diff --git a/store/sale_reconciliation_floor_test.go b/store/sale_reconciliation_floor_test.go new file mode 100644 index 0000000..3169c9a --- /dev/null +++ b/store/sale_reconciliation_floor_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "github.com/aperod/aperod/sale" + "testing" +) + +func TestSaleKnownIssuedFloorIncludesPostDeclarationRewards(t *testing.T) { + c := &LPoDCheckpoint{Allocation: &LPoDAllocation{NominalSnapshot: &LPoDNominalSnapshot{ + NominalCirculatingBefore: 623000000000000000, + }}} + c.State.RewardInflow = 267930900000000 + floor, err := SaleKnownIssuedFloor(c) + if err != nil || floor != 623267930900000000 { + t.Fatalf("floor=%d err=%v", floor, err) + } + c.Allocation.NominalSnapshot.NominalCirculatingBefore = sale.CapNAPRO + if _, err := SaleKnownIssuedFloor(c); err == nil { + t.Fatal("known issuance overflow accepted") + } + if _, err := SaleKnownIssuedFloor(nil); err == nil { + t.Fatal("missing canonical declaration accepted") + } +} diff --git a/store/sale_recovery.go b/store/sale_recovery.go new file mode 100644 index 0000000..83c6834 --- /dev/null +++ b/store/sale_recovery.go @@ -0,0 +1,118 @@ +package store + +import ( + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + + "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +// SaleRecovery is an owner-signed acknowledgement of a particular canonical +// delivery. It is not consensus issuance and becomes stale after a reorg. +type SaleRecovery struct { + Block crypto.Hash32 `json:"block"` + Transaction crypto.Hash32 `json:"transaction"` + Signature string `json:"signature"` +} + +func SaleRecoveryChallenge(genesis crypto.Hash32, order *SaleOrder, delivery *SaleDelivery) string { + return "APRO_SALE_RECOVERY_V1\x00" + hex.EncodeToString(genesis[:]) + ":" + + hex.EncodeToString(order.Checkpoint[:]) + ":" + hex.EncodeToString(order.Order[:]) + ":" + + hex.EncodeToString(delivery.Block[:]) + ":" + hex.EncodeToString(delivery.Transaction[:]) + ":" + + strconv.FormatUint(order.Amount, 10) +} + +func validSaleRecoveryProof(addr crypto.Address, challenge, signature string) bool { + if len(signature) != 128 { + return false + } + raw, err := hex.DecodeString(signature) + if err != nil { + return false + } + _, spend, _, err := crypto.DecodeAddress(addr) + if err != nil { + return false + } + var nonce crypto.Point32 + copy(nonce[:], raw[:32]) + if err := crypto.ValidateAuthorizedOutputPoints(spend, nonce); err != nil { + return false + } + P, err := new(edwards25519.Point).SetBytes(spend[:]) + if err != nil { + return false + } + R, err := new(edwards25519.Point).SetBytes(nonce[:]) + if err != nil { + return false + } + s, err := new(edwards25519.Scalar).SetCanonicalBytes(raw[32:]) + if err != nil { + return false + } + h := sha512.New() + h.Write([]byte("APRO_WEB_AUTH_CHALLENGE_V1\x00")) + h.Write([]byte(challenge)) + h.Write(spend[:]) + h.Write(nonce[:]) + c, err := new(edwards25519.Scalar).SetUniformBytes(h.Sum(nil)) + if err != nil { + return false + } + lhs := new(edwards25519.Point).ScalarBaseMult(s) + rhs := new(edwards25519.Point).Add(R, new(edwards25519.Point).ScalarMult(c, P)) + return lhs.Equal(rhs) == 1 +} + +func saleRecoveryKey(id crypto.Hash32) []byte { + return append([]byte("sale/recovery/"), id[:]...) +} + +// RecordSaleRecovery checks the canonical fence under the same lock used by +// block commit and rollback. A signed old receipt cannot acknowledge a new +// delivery even if that order later returns to the pending queue. +func (d *DB) RecordSaleRecovery(genesis, id, block, tx crypto.Hash32, signature string) error { + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + raw, err := d.get(saleOrderKey(id)) + if err != nil || raw == nil { + return fmt.Errorf("sale: order unavailable: %v", err) + } + var order SaleOrder + if err := json.Unmarshal(raw, &order); err != nil { + return err + } + if order.Payment == (crypto.Hash32{}) { + return fmt.Errorf("sale: payment not confirmed") + } + delivery, err := d.saleOrderDeliveryLocked(id) + if err != nil || delivery == nil || delivery.Block != block || delivery.Transaction != tx { + return fmt.Errorf("sale: canonical delivery mismatch: %v", err) + } + if !validSaleRecoveryProof(order.Recipient, SaleRecoveryChallenge(genesis, &order, delivery), signature) { + return fmt.Errorf("sale: invalid owner recovery signature") + } + encoded, err := json.Marshal(SaleRecovery{Block: block, Transaction: tx, Signature: signature}) + if err != nil { + return err + } + return d.db.Put(saleRecoveryKey(id), encoded, &opt.WriteOptions{Sync: true}) +} + +func (d *DB) SaleOrderRecovery(id crypto.Hash32) (*SaleRecovery, error) { + raw, err := d.get(saleRecoveryKey(id)) + if err != nil || raw == nil { + return nil, err + } + var result SaleRecovery + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/store/sale_recovery_test.go b/store/sale_recovery_test.go new file mode 100644 index 0000000..c1e7351 --- /dev/null +++ b/store/sale_recovery_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "crypto/sha512" + "encoding/hex" + "testing" + + "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" +) + +func TestSaleOwnerRecoverySignatureBindsCanonicalDelivery(t *testing.T) { + seed := sha512.Sum512([]byte("owner-recovery-spend")) + x, err := new(edwards25519.Scalar).SetUniformBytes(seed[:]) + if err != nil { + t.Fatal(err) + } + private := x.Bytes() + pub := new(edwards25519.Point).ScalarBaseMult(x).Bytes() + var spend, view crypto.Point32 + copy(spend[:], pub) + copy(view[:], pub) + addr := crypto.EncodeAddress(crypto.MainnetByte, spend, view) + order := &SaleOrder{ + Order: crypto.HashBytes([]byte("order")), Checkpoint: crypto.HashBytes([]byte("checkpoint")), + Amount: 100000000, + } + delivery := &SaleDelivery{Block: crypto.HashBytes([]byte("canonical block")), Transaction: crypto.HashBytes([]byte("canonical tx"))} + challenge := SaleRecoveryChallenge(crypto.HashBytes([]byte("genesis")), order, delivery) + nonceHash := sha512.New() + nonceHash.Write([]byte("APRO_WEB_AUTH_NONCE_V1\x00")) + nonceHash.Write(private) + nonceHash.Write([]byte(challenge)) + r, err := new(edwards25519.Scalar).SetUniformBytes(nonceHash.Sum(nil)) + if err != nil { + t.Fatal(err) + } + R := new(edwards25519.Point).ScalarBaseMult(r).Bytes() + challengeHash := sha512.New() + challengeHash.Write([]byte("APRO_WEB_AUTH_CHALLENGE_V1\x00")) + challengeHash.Write([]byte(challenge)) + challengeHash.Write(pub) + challengeHash.Write(R) + c, err := new(edwards25519.Scalar).SetUniformBytes(challengeHash.Sum(nil)) + if err != nil { + t.Fatal(err) + } + signature := hex.EncodeToString(append(append([]byte{}, R...), new(edwards25519.Scalar).MultiplyAdd(c, x, r).Bytes()...)) + if !validSaleRecoveryProof(addr, challenge, signature) { + t.Fatal("owner-signed canonical recovery was rejected") + } + for _, other := range []string{ + challenge + ":changed", "APRO_SALE_RECOVERY_V1\x00unrelated-order", + SaleRecoveryChallenge(crypto.HashBytes([]byte("genesis")), order, &SaleDelivery{Block: crypto.HashBytes([]byte("reorg")), Transaction: delivery.Transaction}), + } { + if validSaleRecoveryProof(addr, other, signature) { + t.Fatal("a signature was reused for a different sale or chain") + } + } + if validSaleRecoveryProof(addr, challenge, signature[:len(signature)-2]+"ff") { + t.Fatal("a modified owner signature passed") + } +} diff --git a/store/sale_reserve_floors.go b/store/sale_reserve_floors.go new file mode 100644 index 0000000..a533469 --- /dev/null +++ b/store/sale_reserve_floors.go @@ -0,0 +1,53 @@ +package store + +import ( + "fmt" + + "github.com/aperod/aperod/sale" +) + +// VerifySaleReserveFloors checks disjoint reservations at the activation +// parent. Signatures cannot waive these protocol floors. Do not apply the +// historic-issued comparison to an already activated ledger's later tip: +// its checkpoint binds the original activation parent, not today's rewards. +func VerifySaleReserveFloors(c *LPoDCheckpoint, r sale.Reconciliation) error { + if c == nil || c.Allocation == nil || c.Allocation.Version != 3 || + c.Allocation.NominalSnapshot == nil { + return fmt.Errorf("sale: canonical v3 reserve accounting unavailable") + } + if r.NetAllocation != nil { + if r.AllocationEpoch != nil { + return fmt.Errorf("sale: mixed allocation bases") + } + return verifyNetSaleReserveFloors(c, r) + } + if r.AllocationEpoch != nil { + return verifyEpochSaleReserveFloors(c, r) + } + guardian := sale.MinimumGuardianReserveNAPRO + if existing := c.Allocation.NominalSnapshot.PreexistingGuardianReservation; existing > guardian { + guardian = existing + } + // V3's entire eligible LPoD capital, including its unused portion, is + // already included in NominalCirculatingBefore. Do not count Remaining + // as a second disjoint unissued reserve; protect it with the issuance + // floor and reject a declaration that cannot contain the eligible capital. + if c.Allocation.NominalSnapshot.NominalCirculatingBefore < c.Allocation.NominalEligible { + return fmt.Errorf("sale: nominal declaration omits earmarked LPoD capital") + } + if r.ValidatorReserved < c.Allocation.ValidatorRemaining || r.GuardianReserved < guardian { + return fmt.Errorf("sale: canonical validator, LPoD or Guardian reserve omitted") + } + if c.GenesisVesting != nil && r.OtherReserved < c.GenesisVesting.Remaining { + return fmt.Errorf("sale: canonical funded genesis reserve omitted") + } + issued, err := SaleKnownIssuedFloor(c) + if err != nil { + return err + } + if r.HistoricIssued < issued { + return fmt.Errorf("sale: previously delivered issuance omitted") + } + _, _, err = r.Unassigned() + return err +} diff --git a/store/sale_reserve_floors_test.go b/store/sale_reserve_floors_test.go new file mode 100644 index 0000000..1f85bd3 --- /dev/null +++ b/store/sale_reserve_floors_test.go @@ -0,0 +1,79 @@ +package store + +import ( + "testing" + + "github.com/aperod/aperod/sale" +) + +func reserveFloorFixture() (*LPoDCheckpoint, sale.Reconciliation) { + c := &LPoDCheckpoint{Allocation: &LPoDAllocation{ + Version: 3, ValidatorRemaining: 199732069100000000, + NominalSnapshot: &LPoDNominalSnapshot{NominalCirculatingBefore: 623000000000000000}, + }} + c.State.RewardInflow = 73704000000000 + r := sale.Reconciliation{HistoricIssued: 623073704000000000, + ValidatorReserved: c.Allocation.ValidatorRemaining, GuardianReserved: sale.MinimumGuardianReserveNAPRO} + return c, r +} + +func TestSaleCanonicalReserveFloors(t *testing.T) { + c, r := reserveFloorFixture() + if err := VerifySaleReserveFloors(c, r); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"validator", "lpod", "guardian", "previous-guardian", "genesis", "paid", "version"} { + t.Run(kind, func(t *testing.T) { + c, r := reserveFloorFixture() + switch kind { + case "validator": + r.ValidatorReserved-- + case "lpod": + c.Allocation.NominalEligible = c.Allocation.NominalSnapshot.NominalCirculatingBefore + 1 + case "guardian": + r.GuardianReserved-- + case "previous-guardian": + c.Allocation.NominalSnapshot.PreexistingGuardianReservation = r.GuardianReserved + 1 + case "genesis": + c.GenesisVesting = &GenesisVestingState{Remaining: 1} + case "paid": + c.GenesisVesting = &GenesisVestingState{Paid: 1} + case "version": + c.Allocation.Version = 2 + } + if err := VerifySaleReserveFloors(c, r); err == nil { + t.Fatal("canonical claim was omitted") + } + }) + } +} + +func TestGenesisReserveDeliveryNeverCreatesNewSaleCapacity(t *testing.T) { + c, r := reserveFloorFixture() + const reserved uint64 = 1000000000000 + c.GenesisVesting = &GenesisVestingState{Remaining: reserved} + r.OtherReserved = reserved + before, _, err := r.Unassigned() + if err != nil { + t.Fatal(err) + } + c.GenesisVesting.Paid = 99 + c.GenesisVesting.Remaining -= 99 + c.GenesisVesting.LiquidityPaid = 30000000000000000 + r.OtherReserved -= 99 + r.HistoricIssued, err = SaleKnownIssuedFloor(c) + if err != nil || r.HistoricIssued != 623073704000000099 { + t.Fatalf("floor=%d err=%v", r.HistoricIssued, err) + } + after, _, err := r.Unassigned() + if err != nil || after != before { + t.Fatalf("sale capacity changed from %d to %d: %v", before, after, err) + } + if err := VerifySaleReserveFloors(c, r); err != nil { + t.Fatal(err) + } + c.GenesisVesting.Paid = sale.CapNAPRO + if _, err := SaleKnownIssuedFloor(c); err == nil { + t.Fatal("issuance overflow accepted") + } +} diff --git a/store/sale_reward_wallet_floor_test.go b/store/sale_reward_wallet_floor_test.go new file mode 100644 index 0000000..6b22a44 --- /dev/null +++ b/store/sale_reward_wallet_floor_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "testing" + + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/sale" +) + +func TestSaleRewardWalletFloorProtectsOutsideValueWithoutDoubleCounting(t *testing.T) { + c := &LPoDCheckpoint{ + Allocation: &LPoDAllocation{InitialValidatorRemaining: 2000 * lpod.Unit, + ValidatorRemaining: 1980 * lpod.Unit}, + State: lpod.State{RewardInflow: 10 * lpod.Unit, + LeaderPaid: lpod.Unit, AngelPaid: 2 * lpod.Unit}, + } + for _, test := range []struct{ burn, principal, want uint64 }{ + {0, 0, 13 * lpod.Unit}, + {2 * lpod.Unit, 0, 11 * lpod.Unit}, + {0, 3 * lpod.Unit, 10 * lpod.Unit}, + {2 * lpod.Unit, 3 * lpod.Unit, 8 * lpod.Unit}, + {13 * lpod.Unit, 0, 0}, + {sale.CapNAPRO, sale.CapNAPRO, 0}, + } { + c.PrincipalLocked = test.principal + got, err := saleRewardWalletFloor(c, test.burn) + if err != nil || got != test.want { + t.Fatalf("burn=%d locked=%d got=%d want=%d err=%v", + test.burn, test.principal, got, test.want, err) + } + } +} + +func TestSaleRewardWalletFloorRejectsInconsistentEpochCounters(t *testing.T) { + for _, kind := range []string{"reserve", "inflow", "payout"} { + t.Run(kind, func(t *testing.T) { + c := &LPoDCheckpoint{Allocation: &LPoDAllocation{InitialValidatorRemaining: 100}, + State: lpod.State{RewardInflow: 10}} + switch kind { + case "reserve": + c.Allocation.ValidatorRemaining = 101 + case "inflow": + c.State.RewardInflow = 101 + case "payout": + c.State.LeaderPaid = sale.CapNAPRO + } + if _, err := saleRewardWalletFloor(c, 0); err == nil { + t.Fatal("invalid reward accounting accepted") + } + }) + } +} diff --git a/store/sale_signed_reserve_test.go b/store/sale_signed_reserve_test.go new file mode 100644 index 0000000..87f4e18 --- /dev/null +++ b/store/sale_signed_reserve_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" +) + +func TestSaleFullyResignedGuardianOmissionRejectedAtStartupAndBlock(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + d := seedSaleParent(t, filepath.Join(t.TempDir(), "chain"), records) + defer d.Close() + // Use a disposable, explicit one-authority trust root. Both controls + // really pass its signature threshold; no signature check is mocked. + m.Trusted = []crypto.ValidatorPubKey{key.Public()} + resign := func() { + m.Checkpoint.SnapshotRoot = m.Reconciliation.Digest() + _, reserved, err := m.Reconciliation.Unassigned() + if err != nil { + t.Fatal(err) + } + m.Checkpoint.OtherReserved = reserved + sig, err := key.Sign(m.Checkpoint.Message()) + if err != nil { + t.Fatal(err) + } + m.Checkpoint.Attestations = []sale.Attestation{{Validator: key.Public(), Signature: sig}} + } + file := filepath.Join(t.TempDir(), "migration.json") + write := func() { + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } + } + resign() + write() + if _, err := d.LoadSaleMigrationFile(file); err != nil { + t.Fatalf("valid signed control: %v", err) + } + m.Reconciliation.GuardianReserved-- + resign() + write() + l, err := d.LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + issued, err := SaleKnownIssuedFloor(l) + if err != nil { + t.Fatal(err) + } + if err := sale.VerifyReconciledCheckpoint(m.Checkpoint, m.Reconciliation, m.Checkpoint.Genesis, g.Hash(), l.Digest(), m.Checkpoint.Height, issued, m.Trusted); err != nil { + t.Fatalf("mutation signature/context invalid rather than reserve-invalid: %v", err) + } + if _, err := d.LoadSaleMigrationFile(file); err == nil || !strings.Contains(err.Error(), "Guardian") { + t.Fatalf("startup did not enforce Guardian floor: %v", err) + } + b, _ := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "guardian-omission") + if _, err := d.PrepareSaleBlock(b, m); err == nil || !strings.Contains(err.Error(), "Guardian") { + t.Fatalf("block did not enforce Guardian floor: %v", err) + } + tip, height, err := d.GetTip() + if err != nil || tip != g.Hash() || height != g.Header.Height { + t.Fatal("failed block changed tip") + } + state, err := d.LoadSaleState() + if err != nil || state != nil { + t.Fatal("failed block initialized a sale ledger") + } +} + +func TestSaleRejectsNewGenesisReserveWithoutChangingExistingFund(t *testing.T) { + g, _, _, _, records := saleStoreFixture(t) + d := seedSaleParent(t, filepath.Join(t.TempDir(), "chain"), records) + defer d.Close() + request := &LPoDSettlement{GenesisVestingActivationHeight: g.Header.Height + 1} + if err := d.checkSaleGenesisFundingTransition(g.Header.Height+1, request, true); err == nil { + t.Fatal("simultaneous reservation accepted") + } + if err := d.checkSaleGenesisFundingTransition(g.Header.Height, request, true); err != nil { + t.Fatal(err) + } + if err := d.checkSaleGenesisFundingTransition(g.Header.Height+1, nil, true); err != nil { + t.Fatal(err) + } + if err := d.db.Put(saleStateKey, []byte(`{}`), nil); err != nil { + t.Fatal(err) + } + if err := d.checkSaleGenesisFundingTransition(g.Header.Height+1, request, false); err == nil { + t.Fatal("new reserve accepted after sales") + } + if err := d.CheckGenesisVestingConfig(g.Header.Height+2, nil); err == nil || !strings.Contains(err.Error(), "shared reserve") { + t.Fatalf("startup accepted new funding: %v", err) + } +} diff --git a/store/sale_test.go b/store/sale_test.go new file mode 100644 index 0000000..8f225e2 --- /dev/null +++ b/store/sale_test.go @@ -0,0 +1,224 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" +) + +func saleStoreFixture(t *testing.T) (*core.Block, *SaleMigration, crypto.ValidatorPrivKey, crypto.Address, map[string][]byte) { + t.Helper() + base, _, lm, lk, la := trustedLPoDV3FundingFixture(t) + defer base.Close() + g, settlement, raw := lpodActivationBlock(t, base, lm, lk, la) + if err := base.CommitRawBlockWithAVM(g.Hash(), g.Header.Height, raw, nil, crypto.Hash32{}, settlement); err != nil { + t.Fatal(err) + } + authority, pub := lk, lk.Public() + l, err := base.LoadLPoDCheckpoint() + if err != nil { + t.Fatal(err) + } + knownIssued, err := SaleKnownIssuedFloor(l) + if err != nil { + t.Fatal(err) + } + r := sale.Reconciliation{Genesis: lm.Genesis, Parent: g.Hash(), LPoDCommitment: l.Digest(), + OtherClaimsRoot: crypto.HashBytes([]byte("fixture-disjoint-claims")), HistoricIssued: knownIssued, + ValidatorReserved: l.Allocation.ValidatorRemaining, GuardianReserved: 1000000000 * 100000000} + c := sale.Checkpoint{Genesis: lm.Genesis, Parent: g.Hash(), Network: crypto.MainnetByte, Height: g.Header.Height + 1, SnapshotRoot: r.Digest(), + HistoricIssued: r.HistoricIssued, OtherReserved: r.ValidatorReserved + r.LPoDReserved + r.GuardianReserved, SaleRemaining: 100000 * 100000000, SaleAuthority: pub} + m := &SaleMigration{Checkpoint: c, Reconciliation: r} + for i := 0; i < 4; i++ { + k, p, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + m.Trusted = append(m.Trusted, p) + if i < 3 { + sig, err := k.Sign(c.Message()) + if err != nil { + t.Fatal(err) + } + m.Checkpoint.Attestations = append(m.Checkpoint.Attestations, sale.Attestation{Validator: p, Signature: sig}) + } + } + records := map[string][]byte{} + it := base.db.NewIterator(nil, nil) + for it.Next() { + records[string(it.Key())] = append([]byte(nil), it.Value()...) + } + err = it.Error() + it.Release() + if err != nil { + t.Fatal(err) + } + return g, m, authority, la, records +} +func seedSaleParent(t *testing.T, path string, records map[string][]byte) *DB { + t.Helper() + d, err := Open(path) + if err != nil { + t.Fatal(err) + } + for key, value := range records { + if err = d.db.Put([]byte(key), value, nil); err != nil { + t.Fatal(err) + } + } + return d +} +func saleTestBlock(t *testing.T, d *DB, m *SaleMigration, key crypto.ValidatorPrivKey, address crypto.Address, height uint64, parent crypto.Hash32, id string) (*core.Block, *LPoDSettlement) { + t.Helper() + tx, err := core.BuildSaleTransaction(core.SaleAuthorization{Genesis: m.Checkpoint.Genesis, Height: height, Parent: parent, + Order: crypto.HashBytes([]byte("order-" + id)), Payment: crypto.HashBytes([]byte("payment-" + id)), Amount: 100 * 100000000}, address, m.Checkpoint.Network, key) + if err != nil { + t.Fatal(err) + } + b, r, _ := lpodActivationBlock(t, d, nil, key, address) + if b.Header.Height != height || b.Header.PrevHash != parent { + t.Fatal("fixture clock mismatch") + } + b.Txs = append(b.Txs, *tx) + r.Transactions = append([]core.Transaction(nil), b.Txs[2:]...) + b.Header.MerkleRoot = core.MerkleRoot(b.Txs) + if err = b.Header.Sign(key); err != nil { + t.Fatal(err) + } + return b, r +} +func commitSaleFixture(t *testing.T, d *DB, b *core.Block, m *SaleMigration, r *LPoDSettlement) { + t.Helper() + p, err := d.PrepareSaleBlock(b, m) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(b) + if err = d.CommitRawBlockWithSale(b.Hash(), b.Header.Height, raw, nil, crypto.Hash32{}, p, r); err != nil { + t.Fatal(err) + } +} +func TestSaleDurableBatchRestartRollbackAndIndependentStores(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + paths := []string{t.TempDir() + "/node-a", t.TempDir() + "/node-b"} + a := seedSaleParent(t, paths[0], records) + defer func() { a.Close() }() + b := seedSaleParent(t, paths[1], records) + defer b.Close() + block, r := saleTestBlock(t, a, m, key, address, m.Checkpoint.Height, g.Hash(), "first") + commitSaleFixture(t, a, block, m, r) + commitSaleFixture(t, b, block, m, r) + as, _ := a.LoadSaleState() + bs, _ := b.LoadSaleState() + if as == nil || bs == nil || *as != *bs || as.Sold != 100*100000000 { + t.Fatal("independent canonical stores disagree") + } + tx := block.Txs[len(block.Txs)-1] + auth, _ := tx.SaleAuthorization() + u, err := a.GetUTXO(tx.Hash(), 0) + if err != nil || u == nil || u.AmountCommit != tx.Outputs[0].AmountCommit { + t.Fatal("durable debit without output") + } + used, err := a.SaleConsumed('p', auth.Payment) + if err != nil || !used { + t.Fatal("payment fence missing") + } + if err = a.Close(); err != nil { + t.Fatal(err) + } + a, err = Open(paths[0]) + if err != nil { + t.Fatal(err) + } + restarted, err := a.LoadSaleState() + if err != nil || restarted == nil || *restarted != *as { + t.Fatal("restart lost allocation") + } + replay, _ := saleTestBlock(t, a, m, key, address, block.Header.Height+1, block.Hash(), "first") + if _, err = a.PrepareSaleBlock(replay, m); err == nil { + t.Fatal("restart lost durable replay fences") + } + if err = a.PutTip(g.Hash(), g.Header.Height); err != nil { + t.Fatal(err) + } + after, err := a.LoadSaleState() + if err != nil || after != nil { + t.Fatal("rollback did not restore pre-activation allocation") + } + u, err = a.GetUTXO(tx.Hash(), 0) + if err != nil || u != nil { + t.Fatal("rollback retained sale output") + } + used, err = a.SaleConsumed('p', auth.Payment) + if err != nil || used { + t.Fatal("rollback retained payment fence") + } + commitSaleFixture(t, a, block, m, r) + reapplied, _ := a.LoadSaleState() + if *reapplied != *as { + t.Fatal("reapply differs from independent store") + } +} +func TestSaleRejectedCommitLeavesTipBudgetOutputAndFencesUnchanged(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + d := seedSaleParent(t, t.TempDir()+"/node", records) + defer d.Close() + b, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "rejected") + p, err := d.PrepareSaleBlock(b, m) + if err != nil { + t.Fatal(err) + } + p.After.Remaining-- + raw, _ := json.Marshal(b) + if err = d.CommitRawBlockWithSale(b.Hash(), b.Header.Height, raw, nil, crypto.Hash32{}, p, r); err == nil { + t.Fatal("forged debit committed") + } + tip, height, err := d.GetTip() + if err != nil || tip != g.Hash() || height != g.Header.Height { + t.Fatal("failed commit moved tip") + } + s, err := d.LoadSaleState() + if err != nil || s != nil { + t.Fatal("failed commit persisted state") + } + u, err := d.GetUTXO(b.Txs[len(b.Txs)-1].Hash(), 0) + if err != nil || u != nil { + t.Fatal("failed commit created output") + } + if err = d.CommitRawBlockWithAVM(b.Hash(), b.Header.Height, raw, nil, crypto.Hash32{}); err == nil { + t.Fatal("legacy commit bypassed sale authorization") + } +} +func TestSaleBodyMutationAndMissingRollbackJournalFailClosed(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + d := seedSaleParent(t, t.TempDir()+"/node", records) + defer d.Close() + b, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "journal") + b.Txs[len(b.Txs)-1].Outputs[0].EncAmount[0] ^= 1 + if _, err := d.PrepareSaleBlock(b, m); err == nil { + t.Fatal("modified body matched signed header") + } + b.Txs[len(b.Txs)-1].Outputs[0].EncAmount[0] ^= 1 + commitSaleFixture(t, d, b, m, r) + before, err := d.LoadSaleState() + if err != nil { + t.Fatal(err) + } + if err = d.db.Delete(saleJournalKey(b.Hash()), nil); err != nil { + t.Fatal(err) + } + if err = d.PutTip(g.Hash(), g.Header.Height); err == nil { + t.Fatal("rollback without recovery journal succeeded") + } + after, err := d.LoadSaleState() + if err != nil || *before != *after { + t.Fatal("failed rollback changed allocation") + } + tip, height, err := d.GetTip() + if err != nil || tip != b.Hash() || height != b.Header.Height { + t.Fatal("failed rollback moved canonical tip") + } +} diff --git a/store/sale_upgrade_preflight.go b/store/sale_upgrade_preflight.go new file mode 100644 index 0000000..ff88a41 --- /dev/null +++ b/store/sale_upgrade_preflight.go @@ -0,0 +1,90 @@ +package store + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +// SaleUpgradeReport is an offline consistency check, not authorization to +// initialize an allocation or proof of global historical issuance. +type SaleUpgradeReport struct { + Schema int `json:"schema"` + Status string `json:"status"` + Mode string `json:"mode"` + TipHeight uint64 `json:"tip_height"` + TipHash string `json:"tip_hash"` +} + +// CheckSaleUpgradeCopy never opens the running database and never writes to the +// copy. expectedHash/Height bind a completed backup manifest to the actual DB. +func CheckSaleUpgradeCopy(copyPath, livePath, migrationPath string, expectedHash crypto.Hash32, expectedHeight uint64) (report SaleUpgradeReport, err error) { + target, err := filepath.EvalSymlinks(copyPath) + if err != nil { + return report, fmt.Errorf("sale preflight: copy unavailable: %w", err) + } + live, liveErr := filepath.EvalSymlinks(livePath) + if liveErr != nil && !os.IsNotExist(liveErr) { + return report, fmt.Errorf("sale preflight: cannot resolve live database") + } + if liveErr == nil { + a, aErr := os.Stat(target) + b, bErr := os.Stat(live) + if aErr != nil || bErr != nil || target == live || os.SameFile(a, b) { + return report, fmt.Errorf("sale preflight: refusing live database or its alias") + } + } + db, err := OpenReadOnly(target) + if err != nil { + return report, err + } + defer func() { + if closeErr := db.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() + // GetTip historically treats absent metadata as zero; reject it explicitly. + heightRaw, err := db.GetMeta("tip/height") + if err != nil || len(heightRaw) != 8 { + return report, fmt.Errorf("sale preflight: canonical tip height missing or malformed") + } + hashRaw, err := db.GetMeta("tip/hash") + if err != nil || len(hashRaw) != 32 { + return report, fmt.Errorf("sale preflight: canonical tip hash missing or malformed") + } + var hash crypto.Hash32 + copy(hash[:], hashRaw) + height := binary.LittleEndian.Uint64(heightRaw) + if hash == (crypto.Hash32{}) || hash != expectedHash || height != expectedHeight { + return report, fmt.Errorf("sale preflight: completed copy manifest does not match canonical tip") + } + raw, err := db.GetRawBlockByHeight(height) + var block core.Block + if err != nil || len(raw) == 0 || json.Unmarshal(raw, &block) != nil || + block.Header.Height != height || block.Hash() != hash || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + return report, fmt.Errorf("sale preflight: canonical tip body/index invalid") + } + // This validates signatures, genesis binding, reconciliation and, for an + // active ledger, checkpoint/height/hash and remaining + sold conservation. + migration, err := db.LoadSaleMigrationFile(migrationPath) + if err != nil { + return report, fmt.Errorf("sale preflight: %w", err) + } + state, err := db.LoadSaleState() + if err != nil { + return report, err + } + mode := "inactive" + if migration != nil { + mode = "activation_pending" + if state != nil { + mode = "active" + } + } + return SaleUpgradeReport{Schema: 1, Status: "ok", Mode: mode, TipHeight: height, TipHash: fmt.Sprintf("%x", hash[:])}, nil +} diff --git a/store/sale_upgrade_preflight_test.go b/store/sale_upgrade_preflight_test.go new file mode 100644 index 0000000..b470198 --- /dev/null +++ b/store/sale_upgrade_preflight_test.go @@ -0,0 +1,163 @@ +package store + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func saleUpgradeRecords(t *testing.T, db *DB) map[string][]byte { + t.Helper() + records := map[string][]byte{} + it := db.db.NewIterator(nil, nil) + for it.Next() { + records[string(it.Key())] = append([]byte{}, it.Value()...) + } + err := it.Error() + it.Release() + if err != nil { + t.Fatal(err) + } + return records +} + +func TestSaleUpgradeCopyReadOnly(t *testing.T) { + for _, scenario := range []string{"aligned", "stale-ledger", "wrong-signature", "wrong-budget", "wrong-manifest", "missing-config", "wrong-tip-body"} { + t.Run(scenario, func(t *testing.T) { + g, m, key, address, records := saleStoreFixture(t) + dir := t.TempDir() + path := filepath.Join(dir, "copy", "chain.db") + db := seedSaleParent(t, path, records) + block, settlement := saleTestBlock(t, db, m, key, address, g.Header.Height+1, g.Hash(), "upgrade") + commitSaleFixture(t, db, block, m, settlement) + hash, height := block.Hash(), block.Header.Height + manifestHash := hash + switch scenario { + case "stale-ledger": + next := core.Block{Header: block.Header} + next.Header.Height++ + next.Header.PrevHash = hash + next.Header.MerkleRoot = core.MerkleRoot(nil) + next.Header.Timestamp++ + if err := next.Header.Sign(key); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(next) + hash, height = next.Hash(), next.Header.Height + manifestHash = hash + if err := db.PutRawBlock(hash, height, raw); err != nil { + t.Fatal(err) + } + var hb [8]byte + binary.LittleEndian.PutUint64(hb[:], height) + if err := db.db.Put([]byte("m/tip/hash"), hash[:], nil); err != nil { + t.Fatal(err) + } + if err := db.db.Put([]byte("m/tip/height"), hb[:], nil); err != nil { + t.Fatal(err) + } + case "wrong-signature": + m.Checkpoint.Attestations[0].Signature[0] ^= 1 + case "wrong-budget": + state, _ := db.LoadSaleState() + state.Remaining-- + raw, _ := json.Marshal(state) + if err := db.db.Put(saleStateKey, raw, nil); err != nil { + t.Fatal(err) + } + case "wrong-manifest": + manifestHash = crypto.HashBytes([]byte("not the canonical copy")) + case "wrong-tip-body": + if err := db.db.Put(append([]byte("b/"), hash[:]...), []byte("{}"), nil); err != nil { + t.Fatal(err) + } + } + configPath := filepath.Join(dir, "sale.json") + raw, _ := json.Marshal(m) + if err := os.WriteFile(configPath, raw, 0600); err != nil { + t.Fatal(err) + } + if scenario == "missing-config" { + configPath = "" + } + before := saleUpgradeRecords(t, db) + if scenario == "aligned" { + // Exercise the real logical exporter used by the private socket, + // not just a filesystem copy. It must include all sale fences. + exportPath := filepath.Join(dir, "exported", "chain.db") + if err := os.Mkdir(filepath.Dir(exportPath), 0700); err != nil { + t.Fatal(err) + } + exportHash, exportHeight, exportErr := db.exportLogicalBackup(context.Background(), exportPath, + func(string) (uint64, error) { return 1 << 50, nil }) + if exportErr != nil { + t.Fatal(exportErr) + } + exportReport, exportErr := CheckSaleUpgradeCopy(exportPath, path, configPath, exportHash, exportHeight) + if exportErr != nil || exportReport.Mode != "active" { + t.Fatal(exportReport, exportErr) + } + exported, exportErr := OpenReadOnly(exportPath) + if exportErr != nil { + t.Fatal(exportErr) + } + exportRecords := saleUpgradeRecords(t, exported) + exported.Close() + if len(before) != len(exportRecords) { + t.Fatal("logical copy omitted records") + } + for k, v := range before { + if !bytes.Equal(v, exportRecords[k]) { + t.Fatalf("logical copy changed record %q", k) + } + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + report, err := CheckSaleUpgradeCopy(path, filepath.Join(dir, "live", "chain.db"), configPath, manifestHash, height) + if scenario == "aligned" { + if err != nil || report.Status != "ok" || report.Mode != "active" || report.TipHeight != height { + t.Fatalf("valid control refused: %+v %v", report, err) + } + } else if err == nil { + t.Fatal("inconsistent copy accepted") + } + // Reopen real LevelDB, not a mock: all accounting and replay fences + // must be byte-identical, including on refusal. + readonly, err := OpenReadOnly(path) + if err != nil { + t.Fatal(err) + } + after := saleUpgradeRecords(t, readonly) + readonly.Close() + if len(before) != len(after) { + t.Fatal("preflight changed record count") + } + for k, v := range before { + if !bytes.Equal(v, after[k]) { + t.Fatalf("preflight changed durable record %q", k) + } + } + if scenario == "aligned" { + if _, err := CheckSaleUpgradeCopy(path, path, configPath, hash, height); err == nil { + t.Fatal("accepted live path") + } + alias := filepath.Join(dir, "live-alias") + if err := os.Symlink(path, alias); err != nil { + t.Fatal(err) + } + if _, err := CheckSaleUpgradeCopy(alias, path, configPath, hash, height); err == nil { + t.Fatal("accepted live symlink alias") + } + } + }) + } +} diff --git a/store/wallet_read_snapshot.go b/store/wallet_read_snapshot.go index d0a989d..92b353a 100644 --- a/store/wallet_read_snapshot.go +++ b/store/wallet_read_snapshot.go @@ -42,6 +42,20 @@ func (s *WalletReadSnapshot) Release() { s.release.Do(s.snapshot.Release) } +// LookupTxIdx is only a locator in this pinned view, not confirmation. The +// caller must still check the real transaction in the canonical block body. +func (s *WalletReadSnapshot) LookupTxIdx(hash crypto.Hash32) (*TxIdxEntry, error) { + value, err := s.get(append(append([]byte{}, prefixTxIdx...), hash[:]...)) + if err != nil || len(value) == 0 { + return nil, err + } + if len(value) != 12 { + return nil, fmt.Errorf("malformed pinned transaction locator") + } + return &TxIdxEntry{Height: binary.BigEndian.Uint64(value[:8]), + TxIdx: int(binary.BigEndian.Uint32(value[8:]))}, nil +} + func (s *WalletReadSnapshot) get(key []byte) ([]byte, error) { value, err := s.snapshot.Get(key, nil) if err == leveldb.ErrNotFound {