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
-[](https://go.dev/doc/go1.26)
+[](https://go.dev/doc/go1.25)
[](#-license)
[](https://aperod.com)
[](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 {