Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

RingCT transaction privacy  ·  CLSAG v5 active  ·  Dynamic base-fee burn  ·  Permissionless BFT-PoS

[![Go](https://img.shields.io/badge/Go-1.26.9-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26)
[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.25)
[![License](https://img.shields.io/badge/License-Mixed-blue.svg)](#-license)
[![Network](https://img.shields.io/badge/Network-Mainnet-brightgreen)](https://aperod.com)
[![Explorer](https://img.shields.io/badge/Explorer-explorer.aperod.com-informational)](https://explorer.aperod.com)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -653,7 +653,3 @@ under their own terms.
<a href="mailto:aperod-network@proton.me">Email</a>
</sub>
</div>

## 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.
81 changes: 81 additions & 0 deletions api/audit_authority.go
Original file line number Diff line number Diff line change
@@ -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))
}
45 changes: 45 additions & 0 deletions api/audit_authority_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
89 changes: 89 additions & 0 deletions api/authority_staking_integration_test.go
Original file line number Diff line number Diff line change
@@ -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") }
}
102 changes: 102 additions & 0 deletions api/genesis_vesting.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading