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
5 changes: 0 additions & 5 deletions .github/workflows/publication-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ jobs:
with:
go-version-file: go.mod
cache: true
- name: Verify publication guard regression tests
run: |
python3 deploy/test_publication_security.py
python3 deploy/test_publish_public_repo.py
python3 deploy/test_sync_public_repo.py
- name: Reject runtime artifacts and key material across the entire tree
run: |
python3 deploy/publication_security.py --tree HEAD
Expand Down
23 changes: 10 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ wallets, exchange records, or other network/application-layer information.

## ⬇ Install a Full Node

### One-line install (Ubuntu / Debian)
### Installer security notice

```bash
curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-node.sh | sudo bash
```
Do not pipe an installer fetched from the mutable `main` branch into `sudo bash`.
No signed, immutable installer release is currently published. Install only from
a reviewed source commit whose identity and deployment-tools checksum have been
verified independently; otherwise do not run the installer as root.

Supported platforms: **Ubuntu 22.04 · Ubuntu 24.04 LTS · Debian 12 · x86\_64 · ARM64**

Expand Down Expand Up @@ -185,21 +186,17 @@ Block rewards go directly to your **Telegram wallet**. You need an APRO address

### Step 2 — Install the validator node

```bash
curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash
```
Do not run `install-validator.sh` from mutable `main` as root. A signed,
immutable installer release is not currently published.

The installer will:
- Prompt for your APRO reward address
- Generate a **consensus key** (signs blocks only — cannot move funds)
- Configure the node as a `systemd` service and start it

**Non-interactive install (CI / cloud-init):**

```bash
APEROD_REWARD_ADDRESS=<your-apro-address> \
curl -fsSL https://raw.githubusercontent.com/aperod-network/aperod-node/main/deploy/install-validator.sh | sudo bash
```
**CI / cloud-init installation is unavailable** until a signed, immutable
installer release is published. Do not execute a downloaded script with elevated
privileges.

### Step 3 — Register your node

Expand Down
12 changes: 10 additions & 2 deletions api/authority_staking_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import (
func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T) {
var retired crypto.ValidatorPrivKey
db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired)
defer db.Close()
defer func() {
if err := db.Close(); err != nil {
t.Errorf("close authority staking fixture: %v", err)
}
}()
rec := httptest.NewRecorder()
s.restLPoDAuditDaily(rec, httptest.NewRequest(http.MethodGet, "/?date=2024-01-02", nil))
var result struct {
Expand Down Expand Up @@ -79,7 +83,11 @@ func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T)
func TestRotatedLocalStakingUsesCollateralIdentity(t *testing.T) {
var retired crypto.ValidatorPrivKey
db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired)
defer db.Close()
defer func() {
if err := db.Close(); err != nil {
t.Errorf("close authority staking fixture: %v", err)
}
}()
identity := s.validatorAuthorityFork.Identity
got, err := s.localValidatorIdentity(true)
if err != nil || !got.Equals(identity) {
Expand Down
1 change: 1 addition & 0 deletions api/genesis_vesting.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/aperod/aperod/store"
)

// SetGenesisVestingConfig sets the activation height for genesis vesting.
func (s *Server) SetGenesisVestingConfig(height uint64) { s.genesisVestingHeight = height }

// Public execution evidence comes from the committed reserve ledger, never
Expand Down
8 changes: 6 additions & 2 deletions api/genesis_vesting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) {
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close reopened genesis vesting database: %v", err)
}
})
commit(deadline+1, false)
c, err = db.LoadLPoDCheckpoint()
if err != nil || c.GenesisVesting.Paid != amount {
Expand Down Expand Up @@ -179,7 +183,7 @@ func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) {
raw, err := db.GetRawBlockByHeight(b.Header.Height)
var saved core.Block
if err != nil || json.Unmarshal(raw, &saved) != nil || len(saved.Txs) != 2 {
t.Fatal("annual funding/payment evidence was pruned")
t.Fatalf("annual funding/payment evidence at height %d was pruned: txs=%d err=%v", b.Header.Height, len(saved.Txs), err)
}
}
// Canonical tip selection after a reorg selects its reserve checkpoint,
Expand Down
4 changes: 3 additions & 1 deletion api/genesis_vesting_wallet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) {
tx := core.Transaction{Version: core.TxVersionLPoDPayout}
tx.Outputs = append(tx.Outputs, out)
b := &core.Block{Header: core.BlockHeader{Height: i}, Txs: []core.Transaction{tx}}
utxos.ApplyBlock(b)
if err := utxos.ApplyBlock(b); err != nil {
t.Fatal(err)
}
if i == 1 {
u := utxos.Get(tx.Hash(), 0)
hs, err := crypto.ScanForOutput(owner.View.Private, owner.Spend.Public, out.TxPubKey, out.OneTimePub)
Expand Down
84 changes: 75 additions & 9 deletions api/lpod_audit_daily.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ func dailyPositionOutput(p *lpodDailyPositionSum) lpodDailyPosition {
Accrued: strconv.FormatUint(p.accrued, 10), Paid: strconv.FormatUint(p.paid, 10), PrincipalReturned: strconv.FormatUint(p.principal, 10)}
}

func dailyOpeningBoundaryFailure(start time.Time, anchorTimestamp int64, certifiedWindowLimited, candidateAtSearchFloor, parentHashMatches bool) string {
if !time.Unix(0, anchorTimestamp).Before(start) {
if certifiedWindowLimited && candidateAtSearchFloor {
return "certified search window starts after interval opening"
}
return "opening anchor timestamp is not before interval start"
}
if !parentHashMatches {
return "opening boundary is discontinuous"
}
return ""
}

func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
Expand Down Expand Up @@ -123,9 +136,6 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
"opening_anchor": nil, "closing_witness": nil, "certificate": nil, "funding": nil,
"coverage": map[string]interface{}{"blocks": 0, "vaults": 0, "positions": 0},
"vaults": []lpodDailyVault{}, "positions": []lpodDailyPosition{},
"burns_napro": map[string]string{
"protocol_base_fee": "0", "signed_intentional": "0", "avm_gas": "0", "total": "0",
},
}
fail := func(reason string) {
out["status"] = "incomplete"
Expand Down Expand Up @@ -298,10 +308,12 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
}
}
searchFloor := pruneCursor + 1
certifiedWindowLimited := false
if cert.Height >= lpodDailyMaxBlocks {
certifiedFloor := cert.Height - lpodDailyMaxBlocks + 2
if certifiedFloor > searchFloor {
searchFloor = certifiedFloor
certifiedWindowLimited = true
}
}
if searchFloor > cert.Height {
Expand Down Expand Up @@ -349,6 +361,10 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
return
}
if !openingFound || openingHeight == 0 {
if background {
fail("opening boundary not yet certified")
return
}
fail("opening anchor is unavailable")
return
}
Expand All @@ -362,9 +378,14 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
}
return
}
if !time.Unix(0, anchorBlock.Header.Timestamp).Before(start) ||
openingBlock.Header.PrevHash != anchorHash {
fail("opening boundary is discontinuous")
if reason := dailyOpeningBoundaryFailure(
start,
anchorBlock.Header.Timestamp,
certifiedWindowLimited,
openingHeight == searchFloor,
openingBlock.Header.PrevHash == anchorHash,
); reason != "" {
fail(reason)
return
}
witnessHeight, witnessBlock, witnessFound, boundaryErr := firstAtOrAfter(end)
Expand All @@ -376,7 +397,9 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
}
return
}
if !witnessFound {
preclosing := !witnessFound
preclosingReason := "closing witness not yet present"
if preclosing && !background {
tipBlock, _, tipReadErr := readBlock(tipHeight)
if tipReadErr == nil && !time.Unix(0, tipBlock.Header.Timestamp).Before(end) {
fail("verified certificate predates the closing witness")
Expand All @@ -385,6 +408,30 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
}
return
}
if preclosing {
tipBlock, _, tipReadErr := readBlock(tipHeight)
if tipReadErr == nil && !time.Unix(0, tipBlock.Header.Timestamp).Before(end) {
preclosingReason = "verified certificate predates the closing witness"
}
// Before the closing witness is final, use the certificate's last
// authenticated block as a provisional boundary. The worker audits only
// through the preceding block and advances this boundary with each new
// certificate; no daily result is complete until the real witness exists.
witnessHeight = cert.Height
witnessBlock, _, readErr = readBlock(witnessHeight)
if readErr != nil {
if ctx.Err() != nil {
fail(dailyAuditContextReason(ctx.Err()))
} else {
fail(readErr.Error())
}
return
}
if !time.Unix(0, witnessBlock.Header.Timestamp).Before(end) {
fail("closing witness boundary could not be located")
return
}
}
witnessHash := witnessBlock.Hash()
witnessTimestamp := witnessBlock.Header.Timestamp
anchorTimestamp := anchorBlock.Header.Timestamp
Expand Down Expand Up @@ -446,7 +493,7 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
var expectedNextRegistry *core.RegistrySnapshot
nextHeight := anchorHeight
if background && progress.Next != 0 {
if progress.Anchor != anchorHash || progress.Witness != witnessHash || progress.Marker != markerDigest ||
if progress.Anchor != anchorHash || (!progress.Preclosing && progress.Witness != witnessHash) || progress.Marker != markerDigest ||
!bytes.Equal(progress.Prune, pruneCursorRaw) || progress.Next <= anchorHeight || cert.Height == ^uint64(0) || progress.Next > cert.Height+1 {
fail("saved boundary or prune binding changed")
return
Expand Down Expand Up @@ -477,6 +524,7 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
checkpointProgress := func(next uint64) {
progress.Certificate, progress.Anchor, progress.Witness = cert, anchorHash, witnessHash
progress.AnchorHeight, progress.WitnessHeight, progress.Marker = anchorHeight, witnessHeight, markerDigest
progress.Preclosing = preclosing
progress.Prune = append([]byte(nil), pruneCursorRaw...)
progress.Next, progress.Previous, progress.PriorTS = next, prevHash, priorTS
progress.Rewards = rewards.clone()
Expand All @@ -494,7 +542,11 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
progress.Digest, progress.HaveDigest, progress.Registry = lastAuditDigest, haveLastAuditDigest, expectedNextRegistry
}
batchStarted := time.Now()
for h := nextHeight; h <= cert.Height; h++ {
processThrough := cert.Height
if preclosing {
processThrough = witnessHeight - 1
}
for h := nextHeight; h <= processThrough; h++ {
// Cooperative budget at verified block boundaries. A single block is not
// preemptible; the outer deadline cancels between verifier operations.
if background && h > nextHeight && (h-nextHeight >= dailyBatchBlocks || time.Since(batchStarted) >= dailyBatchTime) {
Expand Down Expand Up @@ -638,6 +690,20 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) {
}
previousBlock = block
}
if preclosing {
if err := ctx.Err(); err != nil {
fail(dailyAuditContextReason(err))
return
}
if !s.dailyBindingValid(batchBinding) {
fail("canonical certificate or finality changed during batch")
return
}
checkpointProgress(witnessHeight)
out["coverage"] = map[string]interface{}{"blocks": included, "vaults": len(vaults), "positions": len(positions)}
fail(preclosingReason)
return
}
if included == 0 {
fail("interval has no included canonical blocks")
return
Expand Down
9 changes: 9 additions & 0 deletions api/lpod_audit_daily_limits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package api

import "testing"

func TestDailyAuditBlockLimitRemainsFortyThousand(t *testing.T) {
if lpodDailyMaxBlocks != 40_000 {
t.Fatalf("daily audit block limit = %d, want 40000", lpodDailyMaxBlocks)
}
}
8 changes: 6 additions & 2 deletions api/lpod_audit_daily_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestLPoDAuditDailyFailsClosed(t *testing.T) {
wantReason string
}{
{name: "missing exact row", certificateHeight: 4, wantReason: "exact block audit record missing"},
{name: "certificate before closing witness", certificateHeight: 2, wantReason: "predates the closing witness"},
{name: "certificate before closing witness with missing audit row", certificateHeight: 2, wantReason: "exact block audit record missing"},
{name: "same-height certificate reorg", certificateHeight: 4, reorg: true, wantReason: "not canonical"},
{name: "pruned body", certificateHeight: 4, prune: true, wantReason: "metadata is unavailable"},
{name: "partial first day", certificateHeight: 4, markerHeight: 2, wantReason: "starts after the interval opening"},
Expand Down Expand Up @@ -745,7 +745,11 @@ func dailyAuditGet(t *testing.T, srv *api.Server, path string) (int, map[string]
t.Fatalf("decode response: %v (%s)", err, recorder.Body.String())
}
reason, _ := body["reason"].(string)
if reason == "queued for bounded background verification" || reason == "bounded background verification in progress" {
if reason == "queued for bounded background verification" ||
reason == "bounded background verification in progress" ||
reason == "opening boundary not yet certified" ||
reason == "closing witness not yet present" ||
reason == "verified certificate predates the closing witness" {
if time.Now().After(deadline) {
t.Fatal("background audit did not finish")
}
Expand Down
3 changes: 3 additions & 0 deletions api/lpod_daily_job_integrity.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ func validateDailyJob(j *dailyJob) error {
len(p.Vaults)+len(p.Positions) > lpodDailyMaxEntries {
return fail()
}
if p.Preclosing && j.State == "complete" {
return fail()
}
lastExclusive := p.Next
if lastExclusive > p.WitnessHeight {
lastExclusive = p.WitnessHeight
Expand Down
37 changes: 29 additions & 8 deletions api/lpod_daily_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type dailyProgress struct {
Certificate *store.FinalityCertificate
Anchor, Witness crypto.Hash32
AnchorHeight, WitnessHeight uint64
Preclosing bool
Marker crypto.Hash32
Prune []byte
Next uint64
Expand Down Expand Up @@ -163,7 +164,6 @@ func dailyPending(date, reason string) map[string]interface{} {
"vaults": []lpodDailyVault{}, "positions": []lpodDailyPosition{},
"coverage": map[string]int{"blocks": 0, "vaults": 0, "positions": 0},
"opening_anchor": nil, "closing_witness": nil, "certificate": nil, "funding": nil,
"burns_napro": map[string]string{"protocol_base_fee": "0", "signed_intentional": "0", "avm_gas": "0", "total": "0"},
}
loc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
Expand Down Expand Up @@ -228,13 +228,15 @@ func (s *Server) restLPoDDailyJob(w http.ResponseWriter, r *http.Request) {
return
}
if j != nil && j.Date == date {
missingRewards := dailyRewardsNeedReplay(j.Progress)
if missingRewards || (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) ||
(j.State == "complete" && (!s.dailyBindingValid(j.Progress) ||
!s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash))) {
missingRewards := j.State != "failed" && dailyRewardsNeedReplay(j.Progress)
invalidComplete := j.State == "complete" &&
(!s.dailyBindingValid(j.Progress) ||
!s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash))
if missingRewards || invalidComplete {
// Re-attest an older complete report by verifying only its unprocessed
// descendant tail. Invalidated/failed progress is never reused.
if missingRewards || j.State == "failed" || !s.dailyBindingValid(j.Progress) {
// descendant tail. Invalidated progress is never reused. Failed results
// remain visible until an authenticated DELETE explicitly resets them.
if missingRewards || !s.dailyBindingValid(j.Progress) {
j.Progress = &dailyProgress{}
}
j.ID, j.State, j.Result = time.Now().UnixNano(), "queued", nil
Expand Down Expand Up @@ -345,7 +347,7 @@ func (s *Server) runDailyJobBatch(ctx context.Context) {
}
if result.Status == "complete" {
j.State = "complete"
} else if j.Progress.Next == before {
} else if j.Progress.Next == before && !dailyJobResultIsWaiting(j.Result) {
j.State = "failed"
}
d.mu.Lock()
Expand All @@ -363,3 +365,22 @@ func (s *Server) runDailyJobBatch(ctx context.Context) {
}
}
}

func dailyJobResultIsWaiting(raw []byte) bool {
var result struct {
Status string `json:"status"`
Reason string `json:"reason"`
}
if json.Unmarshal(raw, &result) != nil || result.Status != "incomplete" {
return false
}
switch result.Reason {
case "opening boundary not yet certified",
"closing witness not yet present",
"verified certificate predates the closing witness",
"bounded background verification in progress":
return true
default:
return false
}
}
Loading
Loading