diff --git a/.golangci.yml b/.golangci.yml index e1f7bda1..1324429a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,57 +1,58 @@ version: "2" linters: + default: none enable: - errcheck - govet - staticcheck - - gosimple - ineffassign - unused - - gofmt - - goimports - misspell - revive - gosec - unconvert - unparam - godot - disable: - - exhaustruct - - exhaustive - - wrapcheck - -linters-settings: - gofmt: - simplify: true - goimports: - local-prefixes: github.com/aperod/aperod - revive: + settings: + revive: + rules: + - name: exported + severity: warning + - name: var-naming + severity: warning + - name: error-return + severity: warning + gosec: + excludes: + - G404 + misspell: + locale: US + godot: + scope: declarations + period: true + exclusions: rules: - - name: exported - severity: warning - - name: var-naming - severity: warning - - name: error-return - severity: warning - gosec: - excludes: - - G404 - misspell: - locale: US - godot: - scope: declarations - period: true + - path: "_test\\.go" + linters: + - gosec + - unparam + - godot + - path: "fuzz_test\\.go" + linters: + - godot issues: - exclude-rules: - - path: "_test\\.go" - linters: - - gosec - - unparam - - godot - - path: "fuzz_test\\.go" - linters: - - godot max-issues-per-linter: 50 max-same-issues: 10 + +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/aperod/aperod diff --git a/README.md b/README.md index baed85c5..1e7001c2 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,12 @@ RingCT transaction privacy  ·  CLSAG v5 active  ·  Dynamic LPoD is an Aperod protocol subsystem developed and owned by the web3 **Aperod APRO team**. It coordinates opt-in positions, validator-linked accrual, -canonical accounting and confirmed Guardian-principal refunds. LPoD v3 is **ACTIVE on mainnet from canonical height 2,493,218**. Its -1B APRO Guardian reserve belongs within the existing 10B APRO nominal -allocation; this is not additional issuance. The coordinated v3 migration -is active on mainnet, with funding and finalized pool accounting visible at this -[address](https://aperod.com/vaults). -Standalone nodes require an authenticated migration witness to follow this -activated chain. The validator stake lock remains governed separately by the -validator protocol. +canonical accounting and confirmed Guardian-principal refunds. **Current public +network status:** LPoD v3 is active on Aperod from canonical height **2493218**, +and its pool is funded. This live status is distinct from the repository's +fail-closed, default-disabled configuration and historical development/rehearsal +snapshots; neither is a report that the current public network is inactive. +The validator stake lock remains governed separately by the validator protocol. **Detailed functionality guide:** [LPOD.md — protocol behavior, status, and source scope](LPOD.md). @@ -252,29 +250,34 @@ See [**VALIDATORS.md**](VALIDATORS.md) for the complete rule set and protocol sp Aperod starts with a fixed genesis allocation and uses a deflationary fee model: -> **LPoD v3 activated at canonical height 2,493,218.** The 1B APRO -> Guardian reserve is part of the existing 10B APRO nominal allocation, -> not a new mint. Active funding and a finalized tip-bound checkpoint can -> be checked through [`GET /api/v1/lpod-pool`](https://aperod.com/api/v1/lpod-pool). -> The live response reports the funding height, finalized height, pool balance, -> reward inflows and current position count. +> **Historical development/rehearsal snapshot — not current Aperod public-network +> status:** **Guardian Fund preparation is not active.** The documented nominal +> allocations already total 10B APRO, and no allocation debit for the proposed +> 1B APRO protocol lock has been approved. Node startup therefore rejects every +> nonzero Guardian activation setting. The node API may report +> `canonical_detected` when it sees the canonical transaction, but that is not +> BFT finality and must not reduce circulating supply; finality wiring is still +> incomplete. This preparation does not assert that the actual production +> genesis is empty, nor that global UTXO supply has been proven. -### Coordinated LPoD protocol — active from height 2,493,218 +### Coordinated LPoD protocol — repository/config default-disabled `consensus.lpod_migration_file` is an opt-in **coordinated consensus fork**, not -an administrative mint switch. The mainnet v3 activation is canonically funded at -height 2,493,218. A node without the authenticated migration configuration -remains disabled; this repository does not contain private operator signing -material and must never fabricate a witness. - -A complete independent audit of historical issuance would require legacy -coinbase commitment openings, unambiguous historical body coverage and -validator attestations. Pruned or unattested history cannot be silently -reconstructed or presented as audited global supply. The active v3 migration -uses a trusted, signed nominal-budget reconciliation and a finalized -canonical checkpoint; it does not itself establish a global-issuance audit. -The validator budget remains tied to the existing durable pool balance: it is -never reset or silently reduced. +an administrative mint switch. The repository/config defaults remain +fail-closed: no production reconciliation witness or approved activation is +included in the repository's default configuration. This describes the +repository and historical development/rehearsal snapshots, not the active +Aperod public network. The legacy Guardian activation must remain disabled. + +Activation on a new network requires the complete historical coinbase +commitment openings and a strictly greater-than-two-thirds quorum of the +**trusted genesis validator set** attesting the genesis, activation height, +unambiguous full-body root, issued total, validator reserve remaining, and +reconciliation witness root. Historical transaction hashes alone do not commit +unambiguously to legacy bodies. Auditors must independently approve those +bodies and budget; a self-hashed witness is not an audit. Missing, pruned, +altered, or unattested history fails closed. The attested validator budget must +equal the existing durable pool balance: it is never reset or silently reduced. Available funding is computed as 10B minus historical issuance, the attested **remaining** validator reserve, and an additional protected 1B development @@ -316,14 +319,16 @@ reservation is conservative; this fork grants no authority to spend it. than the attested funding parent requires additional historical budget evidence and fails closed; it does not guess the old reserve. -`GET /api/v1/lpod-pool` reports active monetary state only when the exact current -canonical checkpoint hash has live finality evidence. Restart does not invent -finality from height alone. Native position capability is reported separately -from activation; pending/disabled balances remain null. The mainnet v3 activation occurred at canonical height 2,493,218; any -future migration still requires source attestations, protocol review, -coordinated node upgrades and explicit deployment authorization. Such activation is a -protocol/governance event; availability under Apache License 2.0 does not itself -activate LPoD on any chain. +**Current public network:** LPoD v3 is active on Aperod from canonical height +**2493218**, and the pool is funded. `GET /api/v1/lpod-pool` reports active +monetary state only when the exact current canonical checkpoint hash has live +finality evidence. Restart does not invent finality from height alone. Native +position capability is reported separately from activation; pending/disabled +balances remain null. For a new network, source attestations, protocol review, +coordinated node upgrades and deployment authorization remain prerequisites +before activation. Repository/config defaults stay fail-closed; activation is a +protocol/governance event, and availability under Apache License 2.0 does not +itself activate LPoD on any chain. ``` Genesis supply: 10,000,000,000 APRO (10B) diff --git a/api/rest.go b/api/rest.go index 3d2425a4..f1842129 100644 --- a/api/rest.go +++ b/api/rest.go @@ -66,6 +66,7 @@ func (s *Server) registerRESTRoutes() { s.mux.HandleFunc("/api/v1/utxos/decoys", s.restUTXODecoys) s.mux.HandleFunc("/api/v1/utxo/", s.restUTXO) s.mux.HandleFunc("/api/v1/wallet/snapshot", s.restWalletSnapshot) + s.mux.HandleFunc("/api/v1/wallet/changes", s.restWalletChanges) s.mux.HandleFunc("/api/v1/keyimage/", s.restKeyImageIsSpent) s.mux.HandleFunc("/api/v1/stake", s.restStakeBroadcast) s.mux.HandleFunc("/api/v1/status", s.restStatus) diff --git a/api/wallet_changes.go b/api/wallet_changes.go new file mode 100644 index 00000000..23491760 --- /dev/null +++ b/api/wallet_changes.go @@ -0,0 +1,586 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +const ( + walletChangesMaxEvents = 128 + walletChangesMaxHeights = 128 + walletChangesMaxBlockBytes = 2 << 20 + walletChangesMaxDecodeBytes = 8 << 20 + walletChangesMaxResponseSize = 512 << 10 + walletChangesMaxPendingKeys = 4096 + walletChangesMaxResumeEvents = 16384 +) + +type walletChangesCursor struct { + SnapshotID string `json:"s"` + Address string `json:"a"` + FromHeight uint64 `json:"f"` + FromHash string `json:"x"` + Target uint64 `json:"t"` + TargetHash string `json:"h"` + Height uint64 `json:"n"` + EventIndex int `json:"i"` +} + +func signWalletChangesCursor(key []byte, c walletChangesCursor) string { + payload, _ := json.Marshal(c) + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(payload) + return base64.RawURLEncoding.EncodeToString(append(payload, mac.Sum(nil)...)) +} + +func parseWalletChangesCursor(key []byte, id, text string) (walletChangesCursor, error) { + var c walletChangesCursor + raw, err := base64.RawURLEncoding.DecodeString(text) + if err != nil || len(raw) <= sha256.Size { + return c, fmt.Errorf("invalid cursor") + } + payload, signature := raw[:len(raw)-sha256.Size], raw[len(raw)-sha256.Size:] + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(payload) + if !hmac.Equal(signature, mac.Sum(nil)) || json.Unmarshal(payload, &c) != nil { + return c, fmt.Errorf("invalid cursor signature") + } + if c.SnapshotID != id || c.Height == 0 || c.EventIndex < 0 { + return c, fmt.Errorf("cursor belongs to a different snapshot") + } + return c, nil +} + +func parseHash32(text string) (crypto.Hash32, error) { + var hash crypto.Hash32 + raw, err := hex.DecodeString(text) + if err != nil || len(raw) != len(hash) || len(text) != 64 { + return hash, fmt.Errorf("expected 64 hexadecimal characters") + } + copy(hash[:], raw) + return hash, nil +} + +func walletBlockEvents( + block *core.Block, + publicAmount func(crypto.Hash32, uint32) (uint64, bool, error), +) ([]map[string]interface{}, error) { + events := make([]map[string]interface{}, 0) + var zeroPoint crypto.Point32 + for _, tx := range block.Txs { + txHash := tx.Hash() + txHashText := fmt.Sprintf("%x", txHash[:]) + for i, output := range tx.Outputs { + outputData := map[string]interface{}{ + "tx_hash": txHashText, "out_idx": uint32(i), "block_height": block.Header.Height, + "one_time_pub": fmt.Sprintf("%x", output.OneTimePub[:]), + "tx_pub_key": fmt.Sprintf("%x", output.TxPubKey[:]), + "amount_commit": fmt.Sprintf("%x", output.AmountCommit[:]), + "enc_amount": fmt.Sprintf("%x", output.EncAmount[:]), + } + if output.TxPubKey == zeroPoint { + var amount uint64 + var known bool + var err error + if publicAmount != nil { + amount, known, err = publicAmount(txHash, uint32(i)) + } + if err != nil { + return nil, fmt.Errorf("read public amount metadata at block %d: %w", block.Header.Height, err) + } + if known && amount > 0 { + outputData["amount_napr"] = fmt.Sprintf("%d", amount) + } else { + outputData["opening_status"] = "OUTPUT_OPENING_UNAVAILABLE" + } + } + events = append(events, map[string]interface{}{ + "kind": "output", + "output": outputData, + }) + } + for _, input := range tx.Inputs { + keyImage, err := crypto.CanonicalKeyImage(input.KeyImage) + if err != nil { + return nil, fmt.Errorf("invalid key image in canonical block %d", block.Header.Height) + } + events = append(events, map[string]interface{}{ + "kind": "spent_key_image", "key_image_hex": fmt.Sprintf("%x", keyImage[:]), + "block_height": block.Header.Height, + }) + } + if tx.IsLPoDPosition() { + action, err := tx.LPoDPositionAction() + if err != nil { + return nil, fmt.Errorf("invalid native position spend at height %d", block.Header.Height) + } + if action.Action == core.LPoDDeposit { + events = append(events, map[string]interface{}{ + "kind": "spent_ref", "tx_hash": fmt.Sprintf("%x", action.SourceTx[:]), + "out_idx": action.SourceIndex, "block_height": block.Header.Height, + }) + } + } + if tx.IsStake() && len(tx.Extra) > 0 && core.StakeAction(tx.Extra[0]) == core.StakeDeposit { + var source crypto.Hash32 + var index uint32 + switch len(tx.Extra) { + case core.StakePayloadSizeV2: + action, _, _, _, hash, outIndex, _, err := core.DecodeStakeExtraV2(tx.Extra) + if err != nil || action != core.StakeDeposit { + return nil, fmt.Errorf("invalid direct stake spend at height %d", block.Header.Height) + } + source, index = hash, outIndex + case core.StakePayloadSizeV3: + action, _, _, _, hash, outIndex, _, _, err := core.DecodeStakeExtraV3(tx.Extra) + if err != nil || action != core.StakeDeposit { + return nil, fmt.Errorf("invalid direct stake spend at height %d", block.Header.Height) + } + source, index = hash, outIndex + default: + // V1 deposits do not carry an explicit source reference. + continue + } + events = append(events, map[string]interface{}{ + "kind": "spent_ref", "tx_hash": fmt.Sprintf("%x", source[:]), + "out_idx": index, "block_height": block.Header.Height, + }) + } + } + return events, nil +} + +func walletSnapshotBlockEvents(entry *walletSnapshotEntry, block *core.Block) ([]map[string]interface{}, error) { + return walletBlockEvents(block, func(txHash crypto.Hash32, index uint32) (uint64, bool, error) { + utxo, err := entry.read.GetUTXO(txHash, index) + if err != nil || utxo == nil { + return 0, false, err + } + return utxo.AmountNAPRO, utxo.AmountNAPRO > 0, nil + }) +} + +func (s *Server) pendingKeyImagesFor(entry *walletSnapshotEntry) ([]string, error) { + entry.pendingOnce.Do(func() { + images := make(map[string]struct{}) + if s.mempool != nil { + for _, hash := range s.mempool.Hashes() { + tx, ok := s.mempool.Get(hash) + if !ok { + continue + } + for _, input := range tx.Inputs { + image, err := crypto.CanonicalKeyImage(input.KeyImage) + if err != nil { + continue + } + images[hex.EncodeToString(image[:])] = struct{}{} + if len(images) > walletChangesMaxPendingKeys { + entry.pendingErr = fmt.Errorf("pending key-image set exceeds %d entries", walletChangesMaxPendingKeys) + return + } + } + } + } + entry.pendingKeyImages = make([]string, 0, len(images)) + for image := range images { + entry.pendingKeyImages = append(entry.pendingKeyImages, image) + } + sort.Strings(entry.pendingKeyImages) + }) + return entry.pendingKeyImages, entry.pendingErr +} + +func reconciliationError(w http.ResponseWriter, from, to uint64, message string) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "code": "RECONCILIATION_REQUIRED", "error": message, + "missing_range": map[string]uint64{"from_height": from, "to_height": to}, + }) +} + +func reorgError(w http.ResponseWriter, from uint64, message string) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "code": "REORG", "error": message, "from_height": from, + }) +} + +func (s *Server) restWalletChanges(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "GET only") + return + } + q := r.URL.Query() + address, err := validSnapshotAddress(q.Get("address")) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid address") + return + } + fromText, fromHashText := q.Get("from_height"), q.Get("from_hash") + fromHeight, err := strconv.ParseUint(fromText, 10, 64) + if err != nil || fromText == "" { + writeJSONError(w, http.StatusBadRequest, "from_height must be an unsigned integer") + return + } + fromHash, err := parseHash32(fromHashText) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "from_hash must be 64 hexadecimal characters") + return + } + limit := walletChangesMaxEvents + if text := q.Get("limit"); text != "" { + limit, err = strconv.Atoi(text) + if err != nil || limit < 1 || limit > walletChangesMaxEvents { + writeJSONError(w, http.StatusBadRequest, "limit must be between 1 and 128") + return + } + } + if q.Has("witness_height") != q.Has("witness_hash") { + writeJSONError(w, http.StatusBadRequest, "witness_height and witness_hash must be supplied together") + return + } + witnessProvided := q.Has("witness_height") + var requestedWitnessHeight uint64 + var requestedWitnessHash crypto.Hash32 + if witnessProvided { + requestedWitnessHeight, err = strconv.ParseUint(q.Get("witness_height"), 10, 64) + if err != nil || requestedWitnessHeight < fromHeight { + writeJSONError(w, http.StatusBadRequest, "invalid witness_height") + return + } + requestedWitnessHash, err = parseHash32(q.Get("witness_hash")) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "witness_hash must be 64 hexadecimal characters") + return + } + } + id := q.Get("snapshot_id") + resumeProvided := q.Has("resume_height") || q.Has("resume_hash") || q.Has("resume_event_index") + if resumeProvided && + (!q.Has("resume_height") || !q.Has("resume_hash") || !q.Has("resume_event_index")) { + writeJSONError(w, http.StatusBadRequest, "resume_height, resume_hash, and resume_event_index must be supplied together") + return + } + var resumeHeight uint64 + var resumeHash crypto.Hash32 + var resumeEventIndex uint64 + if resumeProvided { + if id != "" || !witnessProvided || q.Get("cursor") != "" { + writeJSONError(w, http.StatusBadRequest, "resume coordinates require a new snapshot and a witness") + return + } + resumeHeight, err = strconv.ParseUint(q.Get("resume_height"), 10, 64) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid resume_height") + return + } + resumeHash, err = parseHash32(q.Get("resume_hash")) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "resume_hash must be 64 hexadecimal characters") + return + } + 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 + } + } + + var lease *walletSnapshotLease + created := id == "" + if s.walletSnapshots == nil { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_UNAVAILABLE", "wallet snapshot manager unavailable") + return + } + if created { + if q.Get("cursor") != "" { + writeJSONError(w, http.StatusBadRequest, "cursor requires snapshot_id") + return + } + lease, err = s.walletSnapshots.capture(r.Context(), s, address) + } else { + lease, err = s.walletSnapshots.acquire(id, address) + } + if err != nil { + writeSnapshotLeaseError(w, err) + return + } + if created { + id = lease.entry.id + } + delivered := !created + defer func() { + if created && !delivered { + _ = s.walletSnapshots.close(lease.entry.id, address) + } + lease.Release() + }() + + entry := lease.entry + if witnessProvided { + if requestedWitnessHeight > entry.height { + reorgError(w, requestedWitnessHeight, "witness target is above the newly captured checkpoint") + return + } + canonical, found, readErr := entry.read.GetCanonicalHash(requestedWitnessHeight) + if readErr != nil || !found { + reconciliationError(w, requestedWitnessHeight, requestedWitnessHeight, "witness canonical header is unavailable") + return + } + if canonical != requestedWitnessHash { + reorgError(w, requestedWitnessHeight, "witness_hash is not canonical in the newly captured snapshot") + return + } + if created { + _, _, err = s.walletSnapshots.bindWitness(entry, requestedWitnessHeight, requestedWitnessHash) + } else { + boundHeight, boundHash, bound := s.walletSnapshots.witnessFor(entry) + if !bound || boundHeight != requestedWitnessHeight || boundHash != requestedWitnessHash { + err = fmt.Errorf("witness does not match the existing snapshot") + } + } + if err != nil { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", err.Error()) + return + } + } + pendingKeyImages, err := s.pendingKeyImagesFor(entry) + if err != nil { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "PENDING_SET_LIMIT", err.Error()) + return + } + fromHashText = fmt.Sprintf("%x", fromHash[:]) + targetHashText := fmt.Sprintf("%x", entry.hash[:]) + cursor := walletChangesCursor{ + SnapshotID: id, Address: string(address), FromHeight: fromHeight, FromHash: fromHashText, + Target: entry.height, TargetHash: targetHashText, Height: fromHeight + 1, + } + if cursor.Height == 0 { // from_height overflow + writeJSONError(w, http.StatusBadRequest, "from_height is out of range") + return + } + if resumeProvided { + if !witnessProvided || resumeHeight < cursor.Height || + (resumeHeight > requestedWitnessHeight && resumeHeight-requestedWitnessHeight > 1) { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "resume position is outside the witnessed range") + return + } + if resumeHeight > requestedWitnessHeight { + if resumeHash != requestedWitnessHash || resumeEventIndex != 0 { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "resume position past the witness must use its hash and a zero event index") + return + } + } else { + canonical, ok, readErr := entry.read.GetCanonicalHash(resumeHeight) + if readErr != nil || !ok { + reconciliationError(w, resumeHeight, resumeHeight, "resume canonical header is unavailable") + return + } + if canonical != resumeHash { + reorgError(w, resumeHeight, "resume_hash is not canonical in the captured snapshot") + return + } + block, readErr := entry.read.ReadCanonicalBlockBounded(resumeHeight, walletChangesMaxBlockBytes) + if readErr != nil { + reconciliationError(w, resumeHeight, resumeHeight, "resume canonical block body is unavailable or exceeds bounds") + return + } + resumeEvents, eventErr := walletSnapshotBlockEvents(entry, block) + if eventErr != nil { + reconciliationError(w, resumeHeight, resumeHeight, "resume block contains malformed public spend metadata") + return + } + if len(resumeEvents) > walletChangesMaxResumeEvents { + reconciliationError(w, resumeHeight, resumeHeight, "resume block exceeds the supported event index") + return + } + encodedEvents, marshalErr := json.Marshal(resumeEvents) + if marshalErr != nil || len(encodedEvents) > walletChangesMaxDecodeBytes { + reconciliationError(w, resumeHeight, resumeHeight, "resume block event list exceeds decode bounds") + return + } + 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 = int(resumeEventIndex) + } + if token := q.Get("cursor"); token != "" { + cursor, err = parseWalletChangesCursor(entry.cursorKey[:], id, token) + if err != nil { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", err.Error()) + return + } + if cursor.Address != string(address) || cursor.FromHeight != fromHeight || + cursor.FromHash != fromHashText || cursor.Target != entry.height || cursor.TargetHash != targetHashText { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "cursor parameters do not match snapshot") + return + } + } + if fromHeight > entry.height { + writeJSONError(w, http.StatusBadRequest, "from_height is above the snapshot checkpoint") + return + } + canonicalStart, found, err := entry.read.GetCanonicalHash(fromHeight) + if err != nil || !found { + reconciliationError(w, fromHeight, fromHeight, "starting canonical header is unavailable") + return + } + if canonicalStart != fromHash { + reorgError(w, fromHeight, "from_hash is not canonical in the captured snapshot") + return + } + if cursor.Height > entry.height && cursor.Height-entry.height > 1 { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "cursor is beyond snapshot checkpoint") + return + } + + events := make([]map[string]interface{}, 0, limit) + next := cursor + expectedParent := fromHash + if cursor.Height > fromHeight+1 { + parent, ok, err := entry.read.GetCanonicalHash(cursor.Height - 1) + if err != nil || !ok { + reconciliationError(w, cursor.Height-1, cursor.Height-1, "canonical parent header is unavailable") + return + } + expectedParent = parent + } + heightsExamined, decodedBytes := 0, 0 + more := false + for height := cursor.Height; height <= entry.height && heightsExamined < walletChangesMaxHeights; height++ { + if decodedBytes >= walletChangesMaxDecodeBytes { + next.Height, next.EventIndex = height, 0 + more = true + break + } + block, bodyBytes, err := entry.read.ReadCanonicalBlockBoundedWithSize(height, walletChangesMaxBlockBytes) + if err != nil { + reconciliationError(w, height, height, "canonical block body is unavailable or exceeds bounds") + return + } + if decodedBytes+bodyBytes > walletChangesMaxDecodeBytes { + next.Height, next.EventIndex, more = height, 0, true + break + } + canonicalHash, ok, err := entry.read.GetCanonicalHash(height) + if err != nil || !ok { + reconciliationError(w, height, height, "canonical header is unavailable") + return + } + if block.Header.PrevHash != expectedParent { + reorgError(w, height, "captured canonical ancestry is discontinuous") + return + } + if block.Hash() != canonicalHash { + reorgError(w, height, "captured canonical block hash changed") + return + } + expectedParent = canonicalHash + heightsExamined++ + decodedBytes += bodyBytes + blockEvents, err := walletSnapshotBlockEvents(entry, block) + if err != nil { + reconciliationError(w, height, height, "canonical block contains malformed public spend metadata") + return + } + if len(blockEvents) > walletChangesMaxResumeEvents { + reconciliationError(w, height, height, "canonical block exceeds the supported event index") + return + } + start := 0 + if height == cursor.Height { + start = cursor.EventIndex + } + if start > len(blockEvents) { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "cursor event position is invalid") + return + } + for index := start; index < len(blockEvents); index++ { + candidate := append(events, blockEvents[index]) + response := walletChangesResponse(id, entry, fromHeight, fromHashText, candidate, "", false) + encoded, _ := json.Marshal(response) + if len(candidate) > limit || len(encoded) > walletChangesMaxResponseSize { + if len(events) == 0 { + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "EVENT_TOO_LARGE", "a single wallet event exceeds response bounds") + return + } + next.Height, next.EventIndex, more = height, index, true + break + } + events = candidate + next.Height, next.EventIndex = height, index+1 + } + if more { + break + } + next.Height, next.EventIndex = height+1, 0 + if len(events) >= limit { + more = height < entry.height + break + } + } + if !more && next.Height <= entry.height { + more = true + } + complete := !more && next.Height > entry.height + nextCursor := "" + if more { + nextCursor = signWalletChangesCursor(entry.cursorKey[:], next) + } + response := walletChangesResponse(id, entry, fromHeight, fromHashText, events, nextCursor, complete) + response["pending_complete"] = complete + if complete { + response["pending_key_images"] = pendingKeyImages + } else { + if next.Height > entry.height { + reconciliationError(w, entry.height, entry.height, "incomplete page has no canonical resume height") + return + } + resumeHash, found, readErr := entry.read.GetCanonicalHash(next.Height) + if readErr != nil || !found { + reconciliationError(w, next.Height, next.Height, "resume canonical header is unavailable") + return + } + response["resume_height"] = next.Height + response["resume_hash"] = fmt.Sprintf("%x", resumeHash[:]) + response["resume_event_index"] = next.EventIndex + } + if created { + delivered = writeCreatedWalletSnapshot(w, r, response) + return + } + writeJSON(w, http.StatusOK, response) +} + +func walletChangesResponse(id string, entry *walletSnapshotEntry, fromHeight uint64, fromHash string, events []map[string]interface{}, cursor string, complete bool) map[string]interface{} { + response := map[string]interface{}{ + "snapshot_id": id, "checkpoint_hash": fmt.Sprintf("%x", entry.hash[:]), + "checkpoint_height": entry.height, "from_height": fromHeight, "from_hash": fromHash, + "changes": events, "page_complete": true, "complete": complete, + } + if cursor != "" { + response["cursor"] = cursor + } + if genesis, found, err := entry.read.GetCanonicalHash(0); err == nil && found { + response["genesis_hash"] = fmt.Sprintf("%x", genesis[:]) + } + return response +} diff --git a/api/wallet_changes_test.go b/api/wallet_changes_test.go new file mode 100644 index 00000000..90b0181d --- /dev/null +++ b/api/wallet_changes_test.go @@ -0,0 +1,556 @@ +package api + +import ( + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" + "github.com/syndtr/goleveldb/leveldb" +) + +func TestWalletBlockEventsIncludeOrdinaryOutputsAndPublicSpends(t *testing.T) { + var pub crypto.Point32 + copy(pub[:], crypto.HashToCurvePoint([]byte("wallet delta test output")).Bytes()) + var keyImage crypto.KeyImage + copy(keyImage[:], crypto.HashToCurvePoint([]byte("wallet delta test key image")).Bytes()) + source := crypto.Hash32{4, 5, 6} + extra, err := core.EncodeStakeExtraV3( + core.StakeDeposit, crypto.ValidatorPubKey(make([]byte, 32)), 17, + make([]byte, 64), source, 3, crypto.BlindFactor{}, make([]byte, 64), + ) + if err != nil { + t.Fatal(err) + } + block := &core.Block{ + Header: core.BlockHeader{Height: 12}, + Txs: []core.Transaction{ + { + Version: core.TxVersionBase, + Inputs: []core.RingInput{{KeyImage: keyImage}}, + Outputs: []core.Output{{OneTimePub: pub}}, + }, + {Version: core.TxVersionStake, Extra: extra}, + }, + } + events, err := walletBlockEvents(block, nil) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("got %d events, want output + key image + direct stake ref", len(events)) + } + if events[0]["kind"] != "output" { + t.Fatalf("event order starts with %v, want output", events[0]["kind"]) + } + output := events[0]["output"].(map[string]interface{}) + if output["block_height"] != uint64(12) || output["out_idx"] != uint32(0) { + t.Fatalf("unexpected output metadata: %+v", output) + } + if output["opening_status"] != "OUTPUT_OPENING_UNAVAILABLE" { + t.Fatalf("transparent output without an indexed public amount must fail closed: %+v", output) + } + if events[1]["kind"] != "spent_key_image" || + events[1]["key_image_hex"] != hex.EncodeToString(keyImage[:]) { + t.Fatalf("unexpected key image event: %+v", events[1]) + } + if events[2]["kind"] != "spent_ref" || events[2]["tx_hash"] != hex.EncodeToString(source[:]) || + events[2]["out_idx"] != uint32(3) { + t.Fatalf("unexpected direct stake spend event: %+v", events[2]) + } +} + +func TestWalletBlockEventsExposeOnlyAuthoritativeTransparentMintAmount(t *testing.T) { + ownerKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + foreignKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + ownerAddress := crypto.AddressFromKeys(crypto.MainnetByte, ownerKeys) + foreignAddress := crypto.AddressFromKeys(crypto.MainnetByte, foreignKeys) + const amount = uint64(987654321) + ownerMint, err := core.BuildMintTx(ownerAddress, amount, 22) + if err != nil { + t.Fatal(err) + } + foreignMint, err := core.BuildMintTx(foreignAddress, amount, 22) + if err != nil { + t.Fatal(err) + } + var stealthTxPub crypto.Point32 + copy(stealthTxPub[:], crypto.HashToCurvePoint([]byte("private stealth tx pub")).Bytes()) + block := &core.Block{Header: core.BlockHeader{Height: 22}, Txs: []core.Transaction{ + *ownerMint, + *foreignMint, + {Version: core.TxVersionBase, Outputs: []core.Output{{TxPubKey: stealthTxPub}}}, + }} + events, err := walletBlockEvents(block, func(txHash crypto.Hash32, _ uint32) (uint64, bool, error) { + if txHash == ownerMint.Hash() { + return amount, true, nil + } + return 0, false, nil + }) + if err != nil { + t.Fatal(err) + } + ownerOutput := events[0]["output"].(map[string]interface{}) + if ownerOutput["amount_napr"] != fmt.Sprint(amount) { + t.Fatalf("known public mint opening was not emitted exactly: %+v", ownerOutput) + } + foreignOutput := events[1]["output"].(map[string]interface{}) + if foreignOutput["opening_status"] != "OUTPUT_OPENING_UNAVAILABLE" { + t.Fatalf("foreign/unknown mint output was silently discarded: %+v", foreignOutput) + } + stealthOutput := events[2]["output"].(map[string]interface{}) + if _, exists := stealthOutput["amount_napr"]; exists { + t.Fatalf("confidential stealth amount leaked in public event: %+v", stealthOutput) + } + if _, exists := stealthOutput["opening_status"]; exists { + t.Fatalf("ordinary stealth output incorrectly failed transparent-opening check: %+v", stealthOutput) + } +} + +func TestWalletChangesCursorIsAuthenticatedAndSnapshotBound(t *testing.T) { + serverKey := []byte("private per-snapshot server key, not the public session ID") + cursor := walletChangesCursor{ + SnapshotID: "snapshot", Address: "address", FromHeight: 2, + FromHash: "from", Target: 8, TargetHash: "target", Height: 4, EventIndex: 9, + } + token := signWalletChangesCursor(serverKey, cursor) + parsed, err := parseWalletChangesCursor(serverKey, "snapshot", token) + if err != nil || parsed != cursor { + t.Fatalf("cursor round trip=%+v err=%v", parsed, err) + } + if _, err := parseWalletChangesCursor(serverKey, "another-snapshot", token); err == nil { + t.Fatal("accepted cursor signed for another snapshot") + } + forged := signWalletChangesCursor([]byte("snapshot"), cursor) + if _, err := parseWalletChangesCursor(serverKey, "snapshot", forged); err == nil { + t.Fatal("accepted cursor forged using the known public snapshot ID") + } + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + t.Fatal(err) + } + raw[0] ^= 1 + if _, err := parseWalletChangesCursor(serverKey, "snapshot", base64.RawURLEncoding.EncodeToString(raw)); err == nil { + t.Fatal("accepted tampered cursor") + } +} + +func TestWalletChangesPagesAreIdempotentAndIncludeNativeStakeSpend(t *testing.T) { + db, migration, address, path, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + parent, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + baseHash, found, err := db.GetCanonicalHash(height - 1) + if err != nil || !found || baseHash == (crypto.Hash32{}) { + t.Fatalf("read bootstrap base hash: %x err=%v", baseHash, err) + } + witnessServer := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + witnessServer.SetStore(db) + witnessServer.SetLPoDConfig(migration, func(candidateHeight uint64, candidateHash crypto.Hash32) bool { + return candidateHeight == height && candidateHash == parent + }) + witnessServer.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + witnessHTTP := httptest.NewServer(witnessServer) + defer witnessHTTP.Close() + witnessQuery := url.Values{ + "address": {string(address)}, + "from_height": {fmt.Sprint(height - 1)}, + "from_hash": {fmt.Sprintf("%x", baseHash[:])}, + "witness_height": {fmt.Sprint(height)}, + "witness_hash": {fmt.Sprintf("%x", parent[:])}, + "limit": {"1"}, + } + witnessURL := witnessHTTP.URL + "/api/v1/wallet/changes?" + witnessQuery.Encode() + status, witnessResponse := snapshotRequest(t, witnessHTTP.Client(), http.MethodGet, witnessURL, nil) + if status != http.StatusOK || witnessResponse["complete"] != true { + t.Fatalf("canonical descendant witness should permit new lease: status=%d body=%#v", status, witnessResponse) + } + releaseURL := witnessHTTP.URL + "/api/v1/wallet/snapshot?address=" + url.QueryEscape(string(address)) + + "&snapshot_id=" + url.QueryEscape(witnessResponse["snapshot_id"].(string)) + if status, released := snapshotRequest(t, witnessHTTP.Client(), http.MethodDelete, releaseURL, nil); status != http.StatusOK || released["released"] != true { + t.Fatalf("release witnessed lease: status=%d body=%#v", status, released) + } + witnessQuery.Set("resume_height", fmt.Sprint(height)) + witnessQuery.Set("resume_hash", fmt.Sprintf("%x", parent[:])) + witnessQuery.Set("resume_event_index", "1") + status, resumedWitness := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusOK || resumedWitness["complete"] != true || + len(resumedWitness["changes"].([]interface{})) != 0 { + t.Fatalf("new witnessed lease did not resume the persisted prefix: status=%d body=%#v", status, resumedWitness) + } + resumedRelease := witnessHTTP.URL + "/api/v1/wallet/snapshot?address=" + url.QueryEscape(string(address)) + + "&snapshot_id=" + url.QueryEscape(resumedWitness["snapshot_id"].(string)) + if status, released := snapshotRequest(t, witnessHTTP.Client(), http.MethodDelete, resumedRelease, nil); status != http.StatusOK || released["released"] != true { + t.Fatalf("release resumed lease: status=%d body=%#v", status, released) + } + witnessQuery.Set("resume_height", fmt.Sprint(height+1)) + witnessQuery.Set("resume_hash", fmt.Sprintf("%x", parent[:])) + witnessQuery.Set("resume_event_index", "0") + status, nextHeightResume := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusOK || nextHeightResume["complete"] != true { + t.Fatalf("resume at witness+1 did not use the prior canonical hash: status=%d body=%#v", status, nextHeightResume) + } + nextHeightRelease := witnessHTTP.URL + "/api/v1/wallet/snapshot?address=" + url.QueryEscape(string(address)) + + "&snapshot_id=" + url.QueryEscape(nextHeightResume["snapshot_id"].(string)) + if status, released := snapshotRequest(t, witnessHTTP.Client(), http.MethodDelete, nextHeightRelease, nil); status != http.StatusOK || released["released"] != true { + t.Fatalf("release witness+1 lease: status=%d body=%#v", status, released) + } + witnessQuery.Set("resume_height", fmt.Sprint(height)) + witnessQuery.Set("resume_hash", fmt.Sprintf("%x", parent[:])) + witnessQuery.Set("resume_event_index", "2") + status, badResumeOffset := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusConflict || badResumeOffset["code"] != "SNAPSHOT_CURSOR_MISMATCH" { + t.Fatalf("resume offset beyond stable event list accepted: status=%d body=%#v", status, badResumeOffset) + } + witnessQuery.Set("resume_event_index", "1") + witnessQuery.Set("resume_height", fmt.Sprint(height-1)) + status, badResumeHeight := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusConflict || badResumeHeight["code"] != "SNAPSHOT_CURSOR_MISMATCH" { + t.Fatalf("resume height below the original base accepted: status=%d body=%#v", status, badResumeHeight) + } + witnessQuery.Set("resume_height", fmt.Sprint(height)) + witnessQuery.Set("resume_hash", hex.EncodeToString(make([]byte, 32))) + status, badResumeHash := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusConflict || badResumeHash["code"] != "REORG" { + t.Fatalf("resume hash mismatch accepted: status=%d body=%#v", status, badResumeHash) + } + witnessQuery.Set("resume_hash", fmt.Sprintf("%x", parent[:])) + witnessQuery.Set("witness_hash", hex.EncodeToString(make([]byte, 32))) + status, reorgedWitness := snapshotRequest( + t, witnessHTTP.Client(), http.MethodGet, + witnessHTTP.URL+"/api/v1/wallet/changes?"+witnessQuery.Encode(), nil, + ) + if status != http.StatusConflict || reorgedWitness["code"] != "REORG" { + t.Fatalf("noncanonical partial-target witness did not return REORG: status=%d body=%#v", status, reorgedWitness) + } + + source := crypto.Hash32{8, 9, 10} + stakeExtra, err := core.EncodeStakeExtraV3( + core.StakeDeposit, crypto.ValidatorPubKey(make([]byte, 32)), 23, + make([]byte, 64), source, 6, crypto.BlindFactor{}, make([]byte, 64), + ) + if err != nil { + t.Fatal(err) + } + mintTx, err := core.BuildMintTx(address, 23, height+1) + if err != nil { + t.Fatal(err) + } + block := &core.Block{ + Header: core.BlockHeader{Height: height + 1, PrevHash: parent, Timestamp: int64(height + 2)}, + Txs: []core.Transaction{ + *mintTx, + {Version: core.TxVersionStake, Extra: stakeExtra}, + }, + } + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + rawDB, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + hash := block.Hash() + var heightBytes [8]byte + binary.LittleEndian.PutUint64(heightBytes[:], block.Header.Height) + var heightKeyBytes [8]byte + binary.BigEndian.PutUint64(heightKeyBytes[:], block.Header.Height) + for key, value := range map[string][]byte{ + "b/" + string(hash[:]): raw, + "h/" + string(heightKeyBytes[:]): hash[:], + "m/tip/hash": hash[:], + "m/tip/height": heightBytes[:], + } { + if err := rawDB.Put([]byte(key), value, nil); err != nil { + t.Fatal(err) + } + } + if err := rawDB.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.PutUTXO(mintTx.Hash(), 0, &store.StoredUTXO{ + TxHash: mintTx.Hash(), OutputIndex: 0, OneTimePub: mintTx.Outputs[0].OneTimePub, + TxPubKey: mintTx.Outputs[0].TxPubKey, AmountCommit: mintTx.Outputs[0].AmountCommit, + BlockHeight: height + 1, AmountNAPRO: 23, + }); err != nil { + t.Fatal(err) + } + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + id := "public-session-id" + entry := &walletSnapshotEntry{ + id: id, address: address, read: read, hash: block.Hash(), height: block.Header.Height, + cursorKey: crypto.HashBytes([]byte("private cursor key")), expiresAt: time.Now().Add(time.Hour), + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := &Server{ + mux: http.NewServeMux(), log: logger, walletSnapshots: newWalletSnapshotManager(), + mempool: core.NewMempool(core.DefaultMempoolConfig()), + hub: &Hub{clients: make(map[*wsClient]struct{}), connPerIP: make(map[string]int), log: logger}, + rateLimiter: NewRateLimiter(), + } + server.walletSnapshots.entries[id] = entry + server.walletSnapshots.active = 1 + server.walletSnapshots.activeByAddress[address] = 1 + server.registerRoutes() + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + _ = server.walletSnapshots.close(id, address) + httpServer.Close() + }) + query := url.Values{ + "address": {string(address)}, + "snapshot_id": {id}, + "from_height": {fmt.Sprint(height)}, + "from_hash": {fmt.Sprintf("%x", parent[:])}, + "limit": {"1"}, + } + firstURL := httpServer.URL + "/api/v1/wallet/changes?" + query.Encode() + status, first := snapshotRequest(t, httpServer.Client(), http.MethodGet, firstURL, nil) + if status != http.StatusOK || first["complete"] != false { + t.Fatalf("first delta page status=%d body=%#v", status, first) + } + firstEvents, ok := first["changes"].([]interface{}) + if !ok || len(firstEvents) != 1 || firstEvents[0].(map[string]interface{})["kind"] != "output" { + t.Fatalf("first delta page events=%#v", first["changes"]) + } + if first["resume_height"] != float64(height+1) || + first["resume_hash"] != fmt.Sprintf("%x", hash[:]) || + first["resume_event_index"] != float64(1) { + t.Fatalf("incomplete event page lacks an exact next-event position: %#v", first) + } + firstOutput := firstEvents[0].(map[string]interface{})["output"].(map[string]interface{}) + if firstOutput["amount_napr"] != "23" { + t.Fatalf("snapshot-indexed transparent mint amount missing: %+v", firstOutput) + } + reorgQuery := url.Values{} + for key, values := range query { + reorgQuery[key] = append([]string(nil), values...) + } + reorgQuery.Set("from_hash", hex.EncodeToString(make([]byte, 32))) + reorgURL := httpServer.URL + "/api/v1/wallet/changes?" + reorgQuery.Encode() + status, reorg := snapshotRequest(t, httpServer.Client(), http.MethodGet, reorgURL, nil) + if status != http.StatusConflict || reorg["code"] != "REORG" { + t.Fatalf("noncanonical from_hash did not return REORG: status=%d body=%#v", status, reorg) + } + cursor := first["cursor"].(string) + query.Set("cursor", cursor) + lastURL := httpServer.URL + "/api/v1/wallet/changes?" + query.Encode() + status, last := snapshotRequest(t, httpServer.Client(), http.MethodGet, lastURL, nil) + if status != http.StatusOK || last["complete"] != true || last["pending_complete"] != true { + t.Fatalf("final delta page status=%d body=%#v", status, last) + } + lastEvents, ok := last["changes"].([]interface{}) + if !ok || len(lastEvents) != 1 || lastEvents[0].(map[string]interface{})["kind"] != "spent_ref" { + t.Fatalf("final delta page events=%#v", last["changes"]) + } + status, repeated := snapshotRequest(t, httpServer.Client(), http.MethodGet, lastURL, nil) + if status != http.StatusOK || repeated["complete"] != true || + repeated["changes"].([]interface{})[0].(map[string]interface{})["kind"] != "spent_ref" { + t.Fatalf("repeated cursor page is not idempotent: status=%d body=%#v", status, repeated) + } + if len(last["pending_key_images"].([]interface{})) != 0 { + t.Fatalf("empty mempool pending image set=%#v", last["pending_key_images"]) + } +} + +func TestWalletChangesMissingWitnessHeightRequiresReconciliation(t *testing.T) { + path := t.TempDir() + db, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + var parent crypto.Hash32 + hashes := make([]crypto.Hash32, 3) + for height := uint64(0); height < 3; height++ { + block := &core.Block{Header: core.BlockHeader{ + Height: height, PrevHash: parent, MerkleRoot: core.MerkleRoot(nil), Timestamp: int64(height + 1), + }} + hash := block.Hash() + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.CommitRawBlockWithAVM(hash, height, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + hashes[height] = hash + parent = hash + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + rawDB, err := leveldb.OpenFile(path, nil) + if err != nil { + t.Fatal(err) + } + var missingHeight [8]byte + binary.BigEndian.PutUint64(missingHeight[:], 1) + if err := rawDB.Delete(append([]byte("h/"), missingHeight[:]...), nil); err != nil { + t.Fatal(err) + } + if err := rawDB.Close(); err != nil { + t.Fatal(err) + } + db, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + addressKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + address := crypto.AddressFromKeys(crypto.MainnetByte, addressKeys) + id := "missing-witness-session" + entry := &walletSnapshotEntry{ + id: id, address: address, read: read, hash: hashes[2], height: 2, + cursorKey: crypto.HashBytes([]byte("private witness cursor secret")), expiresAt: time.Now().Add(time.Hour), + } + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.walletSnapshots.entries[id] = entry + server.walletSnapshots.active = 1 + server.walletSnapshots.activeByAddress[address] = 1 + httpServer := httptest.NewServer(server) + defer func() { + _ = server.walletSnapshots.close(id, address) + httpServer.Close() + }() + query := url.Values{ + "address": {string(address)}, + "snapshot_id": {id}, + "from_height": {"0"}, + "from_hash": {fmt.Sprintf("%x", hashes[0][:])}, + "witness_height": {"1"}, + "witness_hash": {fmt.Sprintf("%x", hashes[1][:])}, + } + status, response := snapshotRequest( + t, httpServer.Client(), http.MethodGet, + httpServer.URL+"/api/v1/wallet/changes?"+query.Encode(), nil, + ) + if status != http.StatusConflict || response["code"] != "RECONCILIATION_REQUIRED" { + t.Fatalf("missing witness canonical index did not stop replay: status=%d body=%#v", status, response) + } +} + +func TestWalletChangesEmptyBlocksReturnBoundedResumeProgress(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var parent crypto.Hash32 + hashes := make([]crypto.Hash32, 130) + for height := uint64(0); height <= 130; height++ { + block := &core.Block{Header: core.BlockHeader{ + Height: height, PrevHash: parent, MerkleRoot: core.MerkleRoot(nil), Timestamp: int64(height + 1), + }} + hash := block.Hash() + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.CommitRawBlockWithAVM(hash, height, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + if height < uint64(len(hashes)) { + hashes[height] = hash + } + parent = hash + } + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + addressKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + address := crypto.AddressFromKeys(crypto.MainnetByte, addressKeys) + id := "empty-block-progress-session" + entry := &walletSnapshotEntry{ + id: id, address: address, read: read, hash: parent, height: 130, + cursorKey: crypto.HashBytes([]byte("private empty-block cursor secret")), expiresAt: time.Now().Add(time.Hour), + } + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.walletSnapshots.entries[id] = entry + server.walletSnapshots.active = 1 + server.walletSnapshots.activeByAddress[address] = 1 + httpServer := httptest.NewServer(server) + defer func() { + _ = server.walletSnapshots.close(id, address) + httpServer.Close() + }() + query := url.Values{ + "address": {string(address)}, + "snapshot_id": {id}, + "from_height": {"0"}, + "from_hash": {fmt.Sprintf("%x", hashes[0][:])}, + "limit": {"1"}, + } + firstURL := httpServer.URL + "/api/v1/wallet/changes?" + query.Encode() + status, first := snapshotRequest(t, httpServer.Client(), http.MethodGet, firstURL, nil) + if status != http.StatusOK || first["complete"] != false || + first["resume_height"] != float64(walletChangesMaxHeights+1) || + first["resume_event_index"] != float64(0) || + first["resume_hash"] != fmt.Sprintf("%x", hashes[walletChangesMaxHeights+1][:]) { + t.Fatalf("empty-block page did not return bounded forward progress: status=%d body=%#v", status, first) + } + query.Set("cursor", first["cursor"].(string)) + lastURL := httpServer.URL + "/api/v1/wallet/changes?" + query.Encode() + status, last := snapshotRequest(t, httpServer.Client(), http.MethodGet, lastURL, nil) + if status != http.StatusOK || last["complete"] != true || len(last["changes"].([]interface{})) != 0 { + t.Fatalf("empty-block cursor page did not finish cleanly: status=%d body=%#v", status, last) + } +} diff --git a/api/wallet_snapshot.go b/api/wallet_snapshot.go index ed1a2a34..1b57f40f 100644 --- a/api/wallet_snapshot.go +++ b/api/wallet_snapshot.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -31,16 +32,26 @@ const ( var errWalletSnapshotExpired = errors.New("wallet snapshot expired or not found") type walletSnapshotEntry struct { - id string - address crypto.Address - read *store.WalletReadSnapshot - checkpoint *store.LPoDCheckpoint - hash crypto.Hash32 - height uint64 - bytes int - expiresAt time.Time - inflight int - closed bool + id string + address crypto.Address + read *store.WalletReadSnapshot + checkpoint *store.LPoDCheckpoint + hash crypto.Hash32 + height uint64 + cursorKey [32]byte + bytes int + expiresAt time.Time + inflight int + closed bool + pendingOnce sync.Once + pendingKeyImages []string + pendingErr error + outputTargetSet bool + outputTarget uint64 + outputTargetHash crypto.Hash32 + witnessSet bool + witnessHeight uint64 + witnessHash crypto.Hash32 } type walletSnapshotManager struct { @@ -160,6 +171,48 @@ func (m *walletSnapshotManager) close(id string, address crypto.Address) error { return nil } +func (m *walletSnapshotManager) bindOutputTarget(entry *walletSnapshotEntry, height uint64, hash crypto.Hash32) (uint64, crypto.Hash32, error) { + m.mu.Lock() + defer m.mu.Unlock() + if entry.outputTargetSet { + if entry.outputTarget != height || entry.outputTargetHash != hash { + return 0, crypto.Hash32{}, errors.New("bootstrap target does not match snapshot") + } + return entry.outputTarget, entry.outputTargetHash, nil + } + entry.outputTargetSet = true + entry.outputTarget = height + entry.outputTargetHash = hash + return height, hash, nil +} + +func (m *walletSnapshotManager) outputTargetFor(entry *walletSnapshotEntry) (uint64, crypto.Hash32, bool) { + m.mu.Lock() + defer m.mu.Unlock() + return entry.outputTarget, entry.outputTargetHash, entry.outputTargetSet +} + +func (m *walletSnapshotManager) bindWitness(entry *walletSnapshotEntry, height uint64, hash crypto.Hash32) (uint64, crypto.Hash32, error) { + m.mu.Lock() + defer m.mu.Unlock() + if entry.witnessSet { + if entry.witnessHeight != height || entry.witnessHash != hash { + return 0, crypto.Hash32{}, errors.New("witness does not match snapshot") + } + return entry.witnessHeight, entry.witnessHash, nil + } + entry.witnessSet = true + entry.witnessHeight = height + entry.witnessHash = hash + return height, hash, nil +} + +func (m *walletSnapshotManager) witnessFor(entry *walletSnapshotEntry) (uint64, crypto.Hash32, bool) { + m.mu.Lock() + defer m.mu.Unlock() + return entry.witnessHeight, entry.witnessHash, entry.witnessSet +} + func (m *walletSnapshotManager) capture(ctx context.Context, s *Server, address crypto.Address) (*walletSnapshotLease, error) { m.createMu.Lock() defer m.createMu.Unlock() @@ -270,10 +323,14 @@ func (m *walletSnapshotManager) capture(ctx context.Context, s *Server, address if _, err := rand.Read(randomID[:]); err != nil { return fail(fmt.Errorf("generate wallet snapshot id: %w", err)) } + var cursorKey [32]byte + if _, err := rand.Read(cursorKey[:]); err != nil { + return fail(fmt.Errorf("generate wallet snapshot cursor key: %w", err)) + } id := hex.EncodeToString(randomID[:]) entry := &walletSnapshotEntry{ id: id, address: address, read: read, checkpoint: checkpoint, - hash: hash, height: height, bytes: checkpointSize, + hash: hash, height: height, cursorKey: cursorKey, bytes: checkpointSize, expiresAt: time.Now().Add(walletSnapshotTTL), inflight: 1, } @@ -364,6 +421,7 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re } id := q.Get("snapshot_id") cursor := q.Get("cursor") + resumeCursor := q.Get("resume_cursor") creating := q.Get("snapshot") == "1" var lease *walletSnapshotLease if creating { @@ -377,6 +435,10 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re } lease, err = s.walletSnapshots.capture(r.Context(), s, address) } else if q.Has("snapshot_id") { + if cursor != "" && resumeCursor != "" { + writeJSONError(w, http.StatusBadRequest, "cursor and resume_cursor are mutually exclusive") + return + } cursor, err = unwrapSnapshotCursor(id, cursor) if err != nil { writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", err.Error()) @@ -405,26 +467,132 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re } lease.Release() }() + entry := lease.entry + throughHeight, throughHash, targetBound := s.walletSnapshots.outputTargetFor(entry) + if !targetBound { + throughHeight, throughHash = entry.height, entry.hash + } + if q.Has("through_height") != q.Has("through_hash") { + writeJSONError(w, http.StatusBadRequest, "through_height and through_hash must be supplied together") + return + } + if q.Has("through_height") { + requestedThroughHeight, parseErr := strconv.ParseUint(q.Get("through_height"), 10, 64) + if parseErr != nil { + writeJSONError(w, http.StatusBadRequest, "invalid through_height") + return + } + requestedThroughHash, parseErr := parseHash32(q.Get("through_hash")) + if parseErr != nil { + writeJSONError(w, http.StatusBadRequest, "through_hash must be 64 hexadecimal characters") + return + } + if targetBound && (requestedThroughHeight != throughHeight || requestedThroughHash != throughHash) { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", "through target does not match the snapshot bootstrap") + return + } + throughHeight, throughHash = requestedThroughHeight, requestedThroughHash + if throughHeight > entry.height { + writeWalletSnapshotError(w, http.StatusConflict, "REORG", "through_height is above the captured checkpoint") + return + } + canonical, found, readErr := entry.read.GetCanonicalHash(throughHeight) + if readErr != nil || !found { + reconciliationError(w, throughHeight, throughHeight, "through_height canonical header is unavailable") + return + } + if canonical != throughHash { + reorgError(w, throughHeight, "through_hash is not canonical in the captured snapshot") + return + } + throughHeight, throughHash, err = s.walletSnapshots.bindOutputTarget(entry, throughHeight, throughHash) + if err != nil { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", err.Error()) + return + } + } else { + throughHeight, throughHash, err = s.walletSnapshots.bindOutputTarget(entry, throughHeight, throughHash) + if err != nil { + writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_CURSOR_MISMATCH", err.Error()) + return + } + } + afterHeight, hasAfterHeight := uint64(0), q.Has("after_height") + if hasAfterHeight != q.Has("after_hash") { + writeJSONError(w, http.StatusBadRequest, "after_height and after_hash must be supplied together") + return + } + if hasAfterHeight { + afterHeight, err = strconv.ParseUint(q.Get("after_height"), 10, 64) + if err != nil || afterHeight > throughHeight { + writeJSONError(w, http.StatusBadRequest, "invalid after_height") + return + } + afterHash, parseErr := parseHash32(q.Get("after_hash")) + if parseErr != nil { + writeJSONError(w, http.StatusBadRequest, "after_hash must be 64 hexadecimal characters") + return + } + canonical, found, readErr := entry.read.GetCanonicalHash(afterHeight) + if readErr != nil || !found { + reconciliationError(w, afterHeight, afterHeight, "after_height canonical header is unavailable") + return + } + if canonical != afterHash { + reorgError(w, afterHeight, "after_hash is not canonical in the captured snapshot") + return + } + } if r.Context().Err() != nil { return } - rows, next, err := lease.entry.read.LPoDWalletOutputs(address, cursor, 128) + if resumeCursor != "" { + if cursor != "" { + writeJSONError(w, http.StatusBadRequest, "cursor and resume_cursor are mutually exclusive") + return + } + cursor = resumeCursor + } + rows, next, lastExamined, err := entry.read.LPoDWalletOutputsAfterHeight( + address, afterHeight, hasAfterHeight, cursor, throughHeight, 128, + ) if err != nil { + var missing *store.WalletReconciliationError + if errors.As(err, &missing) { + reconciliationError(w, missing.Height, missing.Height, missing.Error()) + return + } + var cursorReorg *store.WalletCursorReorgError + if errors.As(err, &cursorReorg) { + reorgError(w, cursorReorg.Height, cursorReorg.Error()) + return + } writeWalletSnapshotError(w, http.StatusBadRequest, "SNAPSHOT_QUERY_FAILED", err.Error()) return } outputs := make([]map[string]interface{}, 0, len(rows)) for _, u := range rows { - outputs = append(outputs, map[string]interface{}{ + output := map[string]interface{}{ "tx_hash": fmt.Sprintf("%x", u.TxHash[:]), "out_idx": u.OutputIndex, "block_height": u.BlockHeight, "one_time_pub": fmt.Sprintf("%x", u.OneTimePub[:]), "tx_pub_key": fmt.Sprintf("%x", u.TxPubKey[:]), "amount_commit": fmt.Sprintf("%x", u.AmountCommit[:]), "enc_amount": fmt.Sprintf("%x", u.EncAmount[:]), - }) + } + var zeroPoint crypto.Point32 + if u.TxPubKey == zeroPoint { + if u.AmountNAPRO > 0 { + output["amount_napr"] = fmt.Sprintf("%d", u.AmountNAPRO) + } else { + output["opening_status"] = "OUTPUT_OPENING_UNAVAILABLE" + } + } + outputs = append(outputs, output) } response := map[string]interface{}{ "state": "active", "snapshot_id": id, "checkpoint_height": lease.entry.height, "checkpoint_hash": fmt.Sprintf("%x", lease.entry.hash[:]), - "outputs": outputs, "next_cursor": snapshotCursor(id, next), + "through_height": throughHeight, "through_hash": fmt.Sprintf("%x", throughHash[:]), + "outputs": outputs, "next_cursor": snapshotCursor(id, next), + "resume_cursor": lastExamined, } if creating { delivered = writeCreatedWalletSnapshot(w, r, response) @@ -530,6 +698,8 @@ func (s *Server) restWalletKeyImagesSnapshot( } statuses := make(map[string]bool) + canonicalSpent := make(map[string]bool) + pendingLocked := make(map[string]bool) for i, text := range images { raw, err := hex.DecodeString(text) if err != nil || len(raw) != 32 { @@ -570,10 +740,13 @@ func (s *Server) restWalletKeyImagesSnapshot( writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_SOURCE_MISSING", "source is not indexed at the pinned checkpoint") return } - statuses[text] = spent || utxo == nil || directSpent || pending[hex.EncodeToString(ki[:])] + canonicalSpent[text] = spent || utxo == nil || directSpent + pendingLocked[text] = pending[hex.EncodeToString(ki[:])] + statuses[text] = canonicalSpent[text] || pendingLocked[text] } writeJSON(w, http.StatusOK, map[string]interface{}{ - "spent": statuses, "snapshot_id": lease.entry.id, + "spent": statuses, "canonical_spent": canonicalSpent, "pending_locked": pendingLocked, + "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 3d341372..3395a9a2 100644 --- a/api/wallet_snapshot_integration_test.go +++ b/api/wallet_snapshot_integration_test.go @@ -16,6 +16,7 @@ import ( "net/http" "net/http/httptest" "net/url" +"os" "sync/atomic" "testing" "time" @@ -151,8 +152,51 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { if len(snapshotID) != 64 { t.Fatalf("snapshot id length=%d, want 64 hex chars", len(snapshotID)) } + if fixturePath := os.Getenv("APERO_NODE_WALLET_PAGE_FIXTURE"); fixturePath != "" { + firstCursor := stringField(t, first, "next_cursor") + secondURL := snapshotURL( + httpServer.URL, + "/api/v1/lpod/wallet-outputs", + address, + snapshotID, + firstCursor, + ) + secondStatus, second := snapshotRequest(t, httpServer.Client(), http.MethodGet, secondURL, nil) + if secondStatus != http.StatusOK { + t.Fatalf("continue actual wallet output HTTP page: status=%d body=%#v", secondStatus, second) + } + if second["snapshot_id"] != snapshotID || + second["checkpoint_hash"] != first["checkpoint_hash"] || + second["checkpoint_height"] != first["checkpoint_height"] || + second["through_height"] != first["through_height"] || + second["through_hash"] != first["through_hash"] { + t.Fatalf("continuation page changed its pinned checkpoint metadata: first=%#v second=%#v", first, second) + } + for _, field := range []string{"resume_height", "resume_hash", "resume_event_index"} { + if _, found := second[field]; found { + t.Fatalf("native output page must expose its opaque raw resume cursor, not synthetic %s", field) + } + } + secondPage, err := json.Marshal(second) + if err != nil { + t.Fatalf("encode actual second wallet output HTTP page: %v", err) + } + fixture, err := json.Marshal(map[string]json.RawMessage{ + "first_page": successfulResponse.Body.Bytes(), + "second_page": secondPage, + }) + if err != nil { + t.Fatalf("encode actual wallet output HTTP page pair: %v", err) + } + if err := os.WriteFile(fixturePath, fixture, 0o600); err != nil { + t.Fatalf("write actual wallet output HTTP page fixture: %v", err) + } + t.Skip("actual Go wallet output HTTP page pair exported for the Node adapter test") + } if first["checkpoint_height"] != float64(initialHeight) || - first["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) { + first["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + first["through_height"] != float64(initialHeight) || + first["through_hash"] != fmt.Sprintf("%x", initialHash[:]) { t.Fatalf("initial page checkpoint anchor: %#v", first) } firstRows := first["outputs"].([]interface{}) @@ -163,6 +207,63 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { if cursor == "" { t.Fatal("first page should return a continuation cursor") } + resumeCursor := stringField(t, first, "resume_cursor") + if resumeCursor == "" { + t.Fatal("initial native page should return a lease-independent resume_cursor") + } + for _, field := range []string{"resume_height", "resume_hash", "resume_event_index"} { + if _, found := first[field]; found { + t.Fatalf("native output page must expose its opaque raw resume cursor, not synthetic %s", field) + } + } + +// A replacement lease must accept the real opaque address-index cursor +// emitted by the prior HTTP page while keeping its original through anchor. +resumeQuery := url.Values{ +"address": {string(address)}, +"snapshot": {"1"}, +"through_height": {fmt.Sprint(initialHeight)}, +"through_hash": {fmt.Sprintf("%x", initialHash[:])}, +"resume_cursor": {resumeCursor}, +} +resumeURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + resumeQuery.Encode() +resumeStatus, resumedPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, resumeURL, nil) +if resumeStatus != http.StatusOK { +t.Fatalf("resume native outputs from real raw index cursor: status=%d body=%#v", resumeStatus, resumedPage) +} +resumeSnapshotID := stringField(t, resumedPage, "snapshot_id") +snapshotIDs = append(snapshotIDs, resumeSnapshotID) +if resumedPage["through_height"] != float64(initialHeight) || +resumedPage["through_hash"] != fmt.Sprintf("%x", initialHash[:]) || +stringField(t, resumedPage, "resume_cursor") == "" { +t.Fatalf("renewed native page changed its anchor or omitted its raw cursor: %#v", resumedPage) +} +priorRefs := make(map[string]struct{}, len(firstRows)) +for _, value := range firstRows { +row := value.(map[string]interface{}) +priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])] = struct{}{} +} +renewedRows := resumedPage["outputs"].([]interface{}) +if len(renewedRows) == 0 { +t.Fatal("renewed native page unexpectedly had no continuation outputs") +} +for _, value := range renewedRows { +row := value.(map[string]interface{}) +if _, duplicated := priorRefs[fmt.Sprintf("%v:%v", row["tx_hash"], row["out_idx"])]; duplicated { +t.Fatalf("raw-cursor renewal repeated prior native output: %#v", row) +} +} +releaseStatus, releaseResponse := snapshotRequest( + t, + httpServer.Client(), + http.MethodDelete, + snapshotURL(httpServer.URL, "/api/v1/wallet/snapshot", address, resumeSnapshotID, ""), + nil, +) +if releaseStatus != http.StatusOK || releaseResponse["released"] != true { + t.Fatalf("release raw-cursor renewal snapshot: status=%d body=%#v", releaseStatus, releaseResponse) +} +snapshotIDs = snapshotIDs[:len(snapshotIDs)-1] // A second independent session lets the test prove the opaque cursor is // bound to its originating snapshot token. @@ -246,6 +347,9 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { } if pageTwo["checkpoint_height"] != float64(initialHeight) || pageTwo["checkpoint_hash"] != fmt.Sprintf("%x", initialHash[:]) || + pageTwo["through_height"] != float64(initialHeight) || + pageTwo["through_hash"] != fmt.Sprintf("%x", initialHash[:]) || + pageTwo["resume_cursor"] == "" || len(pageTwo["outputs"].([]interface{})) == 0 { t.Fatalf("continuation mixed live tip with pinned page: %#v", pageTwo) } @@ -298,8 +402,11 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { t.Fatalf("pinned spentness request: status=%d body=%#v", code, initialStatuses) } 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 || - initialSpentMap[pendingKIHex] != false { + initialSpentMap[pendingKIHex] != false || + initialCanonicalMap[pendingKIHex] != false || initialPendingMap[pendingKIHex] != false { t.Fatalf("pinned KI/direct-spent statuses: %#v", initialSpentMap) } pendingTx := core.Transaction{ @@ -317,7 +424,9 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { t.Fatalf("add structurally-valid test mempool transaction: %v", err) } code, freshStatuses := snapshotRequest(t, httpServer.Client(), http.MethodPost, postURL, statusRequest) - if code != http.StatusOK || freshStatuses["spent"].(map[string]interface{})[pendingKIHex] != true { + if code != http.StatusOK || freshStatuses["spent"].(map[string]interface{})[pendingKIHex] != true || + freshStatuses["canonical_spent"].(map[string]interface{})[pendingKIHex] != false || + freshStatuses["pending_locked"].(map[string]interface{})[pendingKIHex] != true { t.Fatalf("pending key image did not refresh for pinned session: status=%d body=%#v", code, freshStatuses) } @@ -338,8 +447,8 @@ func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { if code != http.StatusConflict || expired["code"] != "SNAPSHOT_EXPIRED" { t.Fatalf("released session remained usable: status=%d body=%#v", code, expired) } - if got := finalityCalls.Load(); got != 18 { - t.Fatalf("finality callback reran for pinned reads: calls=%d, want 18 creation checks", got) + if got := finalityCalls.Load(); got != 19 { + t.Fatalf("finality callback reran for pinned reads: calls=%d, want 19 creation checks including raw-cursor renewal", got) } } @@ -750,3 +859,141 @@ func stringField(t *testing.T, body map[string]interface{}, field string) string } return value } + +func TestWalletOutputSnapshotRenewsFromRawResumeCursorAtFixedTarget(t *testing.T) { + db, migration, address, path, validator, validatorPub := syntheticWalletSnapshotLPoDV3Funding(t) + db, _, _, _, _, throughHash, throughHeight := seedWalletSnapshotAPIFixture(t, db, path, address) + finalized := map[uint64]crypto.Hash32{throughHeight: throughHash} + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(height uint64, hash crypto.Hash32) bool { + return finalized[height] == hash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { + server.walletSnapshots.closeAll() + httpServer.Close() + }) + + firstQuery := url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + "through_height": {fmt.Sprint(throughHeight)}, + "through_hash": {fmt.Sprintf("%x", throughHash[:])}, + } + firstURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + firstQuery.Encode() + status, first := snapshotRequest(t, httpServer.Client(), http.MethodGet, firstURL, nil) + if status != http.StatusOK { + t.Fatalf("initial native output page: status=%d body=%#v", status, first) + } + firstID := stringField(t, first, "snapshot_id") + firstRows := first["outputs"].([]interface{}) + if len(firstRows) != 128 || + first["through_height"] != float64(throughHeight) || + first["through_hash"] != fmt.Sprintf("%x", throughHash[:]) || + first["checkpoint_height"] != float64(throughHeight) || + first["checkpoint_hash"] != fmt.Sprintf("%x", throughHash[:]) { + t.Fatalf("initial native page did not expose exact checkpoint/target metadata: %#v", first) + } + rawResumeCursor := stringField(t, first, "resume_cursor") + if rawResumeCursor == stringField(t, first, "next_cursor") { + t.Fatalf("raw native resume cursor was confused with lease cursor: %#v", first) + } + firstSeen := make(map[string]bool, len(firstRows)) + outputIdentity := func(row interface{}) string { + output := row.(map[string]interface{}) + return fmt.Sprintf("%s/%v", output["tx_hash"], output["out_idx"]) + } + for _, row := range firstRows { + identity := outputIdentity(row) + if firstSeen[identity] { + t.Fatalf("initial native page duplicated output %s", identity) + } + firstSeen[identity] = true + } + firstNextCursor := stringField(t, first, "next_cursor") + expectedSuffixURL := snapshotURL(httpServer.URL, "/api/v1/lpod/wallet-outputs", address, firstID, firstNextCursor) + status, expectedSuffixPage := snapshotRequest(t, httpServer.Client(), http.MethodGet, expectedSuffixURL, nil) + if status != http.StatusOK { + t.Fatalf("read original-lease suffix for renewal comparison: status=%d body=%#v", status, expectedSuffixPage) + } + expectedSuffix := expectedSuffixPage["outputs"].([]interface{}) + + // Expire the original short lease. Only the opaque native address-index + // position and immutable through anchor cross into the replacement lease. + server.walletSnapshots.mu.Lock() + oldEntry := server.walletSnapshots.entries[firstID] + oldEntry.expiresAt = time.Now().Add(-time.Second) + server.walletSnapshots.mu.Unlock() + server.walletSnapshots.expire() + if got := activeSnapshotSessions(server.walletSnapshots); got != 0 { + t.Fatalf("expired native lease retained %d active sessions", got) + } + + advancedHash, advancedHeight := advanceSyntheticLPoDV3(t, db, address, validator, validatorPub) + finalized[advancedHeight] = advancedHash + reorgedCursorBytes, err := hex.DecodeString(rawResumeCursor) + if err != nil { + t.Fatal(err) + } + walletPrefixLength := len("lpod/wallet/v1/") + len(crypto.Hash32{}) + reorgedCursorBytes[walletPrefixLength+12] ^= 1 + reorgedCursorQuery := url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + "through_height": {fmt.Sprint(throughHeight)}, + "through_hash": {fmt.Sprintf("%x", throughHash[:])}, + "resume_cursor": {hex.EncodeToString(reorgedCursorBytes)}, + } + reorgedCursorURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + reorgedCursorQuery.Encode() + status, reorgedCursor := snapshotRequest(t, httpServer.Client(), http.MethodGet, reorgedCursorURL, nil) + if status != http.StatusConflict || reorgedCursor["code"] != "REORG" { + t.Fatalf("noncanonical raw native resume cursor did not fail closed: status=%d body=%#v", status, reorgedCursor) + } + renewQuery := url.Values{ + "address": {string(address)}, + "snapshot": {"1"}, + "through_height": {fmt.Sprint(throughHeight)}, + "through_hash": {fmt.Sprintf("%x", throughHash[:])}, + "resume_cursor": {rawResumeCursor}, + } + renewURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + renewQuery.Encode() + status, renewed := snapshotRequest(t, httpServer.Client(), http.MethodGet, renewURL, nil) + if status != http.StatusOK { + t.Fatalf("renew native output page from raw cursor: status=%d body=%#v", status, renewed) + } + renewedID := stringField(t, renewed, "snapshot_id") + renewedRows := renewed["outputs"].([]interface{}) + nextCursor, _ := renewed["next_cursor"].(string) + if renewedID == firstID || len(renewedRows) != len(expectedSuffix) || + renewed["checkpoint_height"] != float64(advancedHeight) || + renewed["checkpoint_hash"] != fmt.Sprintf("%x", advancedHash[:]) || + renewed["through_height"] != float64(throughHeight) || + renewed["through_hash"] != fmt.Sprintf("%x", throughHash[:]) || nextCursor != "" { + t.Fatalf("renewed page changed its fixed target or failed to return the exact suffix: %#v", renewed) + } + if len(renewedRows) != len(expectedSuffix) { + t.Fatalf("renewed native suffix duplicated or skipped an output: %#v", renewedRows) + } + for i, row := range renewedRows { + identity := outputIdentity(row) + if firstSeen[identity] || identity != outputIdentity(expectedSuffix[i]) { + t.Fatalf("renewed suffix row %d duplicated or changed identity: got=%s want=%s", i, identity, outputIdentity(expectedSuffix[i])) + } + firstSeen[identity] = true + } + wrongTargetQuery := url.Values{ + "address": {string(address)}, + "snapshot_id": {renewedID}, + "through_height": {fmt.Sprint(advancedHeight)}, + "through_hash": {fmt.Sprintf("%x", advancedHash[:])}, + } + wrongTargetURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?" + wrongTargetQuery.Encode() + status, wrongTarget := snapshotRequest(t, httpServer.Client(), http.MethodGet, wrongTargetURL, nil) + if status != http.StatusConflict || wrongTarget["code"] != "SNAPSHOT_CURSOR_MISMATCH" { + t.Fatalf("renewed lease silently expanded its original through target: status=%d body=%#v", status, wrongTarget) + } +} diff --git a/cmd/wallet-wasm/identities.go b/cmd/wallet-wasm/identities.go new file mode 100644 index 00000000..badb01cb --- /dev/null +++ b/cmd/wallet-wasm/identities.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/wallet" +) + +const maxIdentityOutputs = 256 + +type identityOutput struct { + TxHash [32]byte + OutIdx uint32 + OneTimePub crypto.Point32 + TxPubKey crypto.Point32 + BlockHeight uint64 +} + +type outputIdentity struct { + TxHash [32]byte + OutIdx uint32 + KeyImage crypto.KeyImage +} + +// identifyOutputRecords establishes ownership using only public output keys and +// spend-key derivation. It deliberately does not read amount, encrypted amount, +// blind, or commitment fields. +func identifyOutputRecords(keys *wallet.DerivedKeys, outputs []identityOutput) ([]outputIdentity, error) { + if len(outputs) > maxIdentityOutputs { + return nil, fmt.Errorf("outputs exceeds the %d-output identity chunk limit", maxIdentityOutputs) + } + result := make([]outputIdentity, 0, len(outputs)) + var zero crypto.Point32 + for i, output := range outputs { + var hs *crypto.Scalar32 + if output.TxPubKey == zero { + heightPub, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(output.BlockHeight)) + if err != nil { + return nil, fmt.Errorf("outputs[%d].block_height: %w", i, err) + } + expected, err := crypto.AddPoints(keys.Keys.Spend.Public, heightPub) + if err != nil { + return nil, fmt.Errorf("outputs[%d] mint public key: %w", i, err) + } + if output.OneTimePub != expected { + continue + } + mintHS := crypto.ScalarFromUint64(output.BlockHeight) + hs = &mintHS + } else { + found, err := crypto.ScanForOutput( + keys.Keys.View.Private, + keys.Keys.Spend.Public, + output.TxPubKey, + output.OneTimePub, + ) + if err != nil { + return nil, fmt.Errorf("outputs[%d] public key scan: %w", i, err) + } + if found == nil { + continue + } + hs = found + } + + oneTimePrivate, err := crypto.AddScalars(*hs, keys.Keys.Spend.Private) + if err != nil { + return nil, fmt.Errorf("outputs[%d] spend-key derivation: %w", i, err) + } + keyImage, err := crypto.ComputeKeyImage(oneTimePrivate, output.OneTimePub) + if err != nil { + return nil, fmt.Errorf("outputs[%d] key-image derivation: %w", i, err) + } + keyImage, err = crypto.CanonicalKeyImage(keyImage) + if err != nil { + return nil, fmt.Errorf("outputs[%d] key-image canonicalization: %w", i, err) + } + result = append(result, outputIdentity{ + TxHash: output.TxHash, + OutIdx: output.OutIdx, + KeyImage: keyImage, + }) + } + return result, nil +} diff --git a/cmd/wallet-wasm/identities_js_wasm.go b/cmd/wallet-wasm/identities_js_wasm.go new file mode 100644 index 00000000..afbc5492 --- /dev/null +++ b/cmd/wallet-wasm/identities_js_wasm.go @@ -0,0 +1,116 @@ +//go:build js && wasm + +package main + +import ( + "encoding/json" + "fmt" + "syscall/js" + + "github.com/aperod/aperod/crypto" +) + +type identityRequestJSON struct { + Mnemonic string `json:"mnemonic"` + Outputs []json.RawMessage `json:"outputs"` +} + +type identityOutputJSON struct { + TxHash *string `json:"tx_hash"` + OutIdx *uint32 `json:"out_idx"` + OneTimePub *string `json:"one_time_pub"` + TxPubKey *string `json:"tx_pub_key"` + BlockHeight *uint64 `json:"block_height"` +} + +func parseIdentityOutput(raw json.RawMessage, index int) (identityOutput, error) { + var fields identityOutputJSON + if err := json.Unmarshal(raw, &fields); err != nil { + return identityOutput{}, fmt.Errorf("outputs[%d] must be an object with valid public identity fields: %w", index, err) + } + if fields.TxHash == nil || fields.OutIdx == nil || fields.OneTimePub == nil || + fields.TxPubKey == nil || fields.BlockHeight == nil { + return identityOutput{}, fmt.Errorf("outputs[%d] requires tx_hash, out_idx, one_time_pub, tx_pub_key, and block_height", index) + } + txHash, err := hex32(*fields.TxHash, fmt.Sprintf("outputs[%d].tx_hash", index)) + if err != nil { + return identityOutput{}, err + } + oneTimePub, err := hex32(*fields.OneTimePub, fmt.Sprintf("outputs[%d].one_time_pub", index)) + if err != nil { + return identityOutput{}, err + } + txPubKey, err := hex32(*fields.TxPubKey, fmt.Sprintf("outputs[%d].tx_pub_key", index)) + if err != nil { + return identityOutput{}, err + } + if _, err := crypto.PointFromBytes(oneTimePub[:]); err != nil { + return identityOutput{}, fmt.Errorf("outputs[%d].one_time_pub: %w", index, err) + } + var zero crypto.Point32 + if crypto.Point32(txPubKey) != zero { + if _, err := crypto.PointFromBytes(txPubKey[:]); err != nil { + return identityOutput{}, fmt.Errorf("outputs[%d].tx_pub_key: %w", index, err) + } + } + return identityOutput{ + TxHash: txHash, + OutIdx: *fields.OutIdx, + OneTimePub: crypto.Point32(oneTimePub), + TxPubKey: crypto.Point32(txPubKey), + BlockHeight: *fields.BlockHeight, + }, nil +} + +func identifyOutputs(_ js.Value, args []js.Value) any { + if len(args) != 1 || args[0].Type() != js.TypeObject || args[0].IsNull() { + return promiseResult(nil, fmt.Errorf("exactly one identity request object is required")) + } + rawRequest := js.Global().Get("JSON").Call("stringify", args[0]).String() + if len(rawRequest) > 1<<20 { + return promiseResult(nil, fmt.Errorf("identity request exceeds the 1 MiB limit")) + } + var request identityRequestJSON + if err := json.Unmarshal([]byte(rawRequest), &request); err != nil { + return promiseResult(nil, fmt.Errorf("invalid identity request: %w", err)) + } + if request.Mnemonic == "" { + return promiseResult(nil, fmt.Errorf("mnemonic is required")) + } + if request.Outputs == nil { + return promiseResult(nil, fmt.Errorf("outputs must be an array")) + } + if len(request.Outputs) > maxIdentityOutputs { + return promiseResult(nil, fmt.Errorf("outputs exceeds the %d-output identity chunk limit", maxIdentityOutputs)) + } + records := make([]identityOutput, len(request.Outputs)) + for i, raw := range request.Outputs { + record, err := parseIdentityOutput(raw, i) + if err != nil { + return promiseResult(nil, err) + } + records[i] = record + } + keys, err := derivedKeys(wasmRequest{Mnemonic: request.Mnemonic}) + if err != nil { + return promiseResult(nil, err) + } + identities, err := identifyOutputRecords(keys, records) + if err != nil { + return promiseResult(nil, err) + } + type identityResult struct { + TxHash string `json:"tx_hash"` + OutIdx uint32 `json:"out_idx"` + KeyImageHex string `json:"key_image_hex"` + } + outputs := make([]identityResult, len(identities)) + for i, identity := range identities { + outputs[i] = identityResult{ + TxHash: fmt.Sprintf("%x", identity.TxHash), + OutIdx: identity.OutIdx, + KeyImageHex: fmt.Sprintf("%x", identity.KeyImage), + } + } + return promiseResult(wasmValue(map[string]any{"outputs": outputs}), nil) +} diff --git a/cmd/wallet-wasm/identities_test.go b/cmd/wallet-wasm/identities_test.go new file mode 100644 index 00000000..69cd1491 --- /dev/null +++ b/cmd/wallet-wasm/identities_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/wallet" +) + +const identityTestMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +func identityTestStealth(t *testing.T, keys *wallet.DerivedKeys, ephemeral byte) *crypto.StealthOutput { + t.Helper() + var r [32]byte + r[0] = ephemeral + return identityTestStealthBytes(t, keys, r) +} + +func identityTestStealthBytes(t *testing.T, keys *wallet.DerivedKeys, r [32]byte) *crypto.StealthOutput { + t.Helper() + output, err := crypto.CreateStealthOutputFromEphemeralBytes(keys.Keys.Spend.Public, keys.Keys.View.Public, r) + if err != nil { + t.Fatalf("create test stealth output: %v", err) + } + return output +} + +func TestIdentifyOutputRecordsRecognizesOwnedStealthAndMintOnly(t *testing.T) { + owner, err := wallet.DeriveFromMnemonic(identityTestMnemonic, "", 0, 0) + if err != nil { + t.Fatal(err) + } + foreign, err := wallet.DeriveFromMnemonic(identityTestMnemonic, "", 0, 1) + if err != nil { + t.Fatal(err) + } + stealth := identityTestStealth(t, owner, 7) + foreignStealth := identityTestStealth(t, foreign, 13) + mintTx, err := core.BuildMintTx( + crypto.AddressFromKeys(crypto.MainnetByte, owner.Keys), + 5000, + 37, + ) + if err != nil { + t.Fatal(err) + } + var txHash [32]byte + stealthHash := crypto.HashBytes([]byte("identity-owned-stealth")) + copy(txHash[:], stealthHash[:]) + owned := identityOutput{ + TxHash: txHash, OutIdx: 3, OneTimePub: stealth.OneTimePub, + TxPubKey: stealth.TxPubKey, BlockHeight: 41, + } + mintHash := mintTx.Hash() + mint := identityOutput{ + TxHash: [32]byte(mintHash), OutIdx: 0, OneTimePub: mintTx.Outputs[0].OneTimePub, + TxPubKey: mintTx.Outputs[0].TxPubKey, BlockHeight: 37, + } + foreignOutput := identityOutput{ + TxHash: [32]byte(crypto.HashBytes([]byte("identity-foreign"))), OutIdx: 9, + OneTimePub: foreignStealth.OneTimePub, TxPubKey: foreignStealth.TxPubKey, BlockHeight: 44, + } + + identities, err := identifyOutputRecords(owner, []identityOutput{owned, foreignOutput, mint}) + if err != nil { + t.Fatalf("identify public outputs: %v", err) + } + if len(identities) != 2 { + t.Fatalf("identified %d outputs, want owned stealth and mint only", len(identities)) + } + if identities[0].TxHash != owned.TxHash || identities[0].OutIdx != owned.OutIdx { + t.Fatalf("stealth output reference changed: %+v", identities[0]) + } + if identities[1].TxHash != mint.TxHash || identities[1].OutIdx != mint.OutIdx { + t.Fatalf("mint output reference changed: %+v", identities[1]) + } + + for i, pair := range []struct { + output identityOutput + hs crypto.Scalar32 + }{{owned, stealth.HsScalar}, {mint, crypto.ScalarFromUint64(mint.BlockHeight)}} { + priv, err := crypto.AddScalars(pair.hs, owner.Keys.Spend.Private) + if err != nil { + t.Fatal(err) + } + expected, err := crypto.ComputeKeyImage(priv, pair.output.OneTimePub) + if err != nil { + t.Fatal(err) + } + expected, err = crypto.CanonicalKeyImage(expected) + if err != nil { + t.Fatal(err) + } + if identities[i].KeyImage != expected { + t.Fatalf("output %d key image mismatch", i) + } + } +} + +func TestIdentifyOutputRecordsEnforcesChunkBound(t *testing.T) { + keys, err := wallet.DeriveFromMnemonic(identityTestMnemonic, "", 0, 0) + if err != nil { + t.Fatal(err) + } + if _, err := identifyOutputRecords(keys, make([]identityOutput, maxIdentityOutputs+1)); err == nil { + t.Fatal("oversized identity chunk accepted") + } +} + +func makeIdentityWASMProbeFixture(t *testing.T) map[string]any { + t.Helper() + owner, err := wallet.DeriveFromMnemonic(identityTestMnemonic, "", 0, 0) + if err != nil { + t.Fatal(err) + } + foreign, err := wallet.DeriveFromMnemonic(identityTestMnemonic, "", 0, 1) + if err != nil { + t.Fatal(err) + } + owned := make([]map[string]any, 4096) + for i := range owned { + var ephemeral [32]byte + binary.LittleEndian.PutUint64(ephemeral[:8], uint64(i+1)) + stealth := identityTestStealthBytes(t, owner, ephemeral) + amount := uint64(1_000_000 + i) + blind, err := crypto.DeterministicPaymentBlind(stealth.HsScalar, amount) + if err != nil { + t.Fatalf("derive fixture payment blind %d: %v", i, err) + } + commitment, err := crypto.Commit(amount, blind) + if err != nil { + t.Fatalf("commit fixture output %d: %v", i, err) + } + encryptedAmount := core.EncryptAmount(amount, &stealth.HsScalar) + txHash := crypto.HashBytes([]byte(fmt.Sprintf("identity-probe-owned-%04d", i))) + owned[i] = map[string]any{ + "tx_hash": hex.EncodeToString(txHash[:]), "out_idx": uint32(i % 5), "block_height": uint64(100 + i), + "one_time_pub": hex.EncodeToString(stealth.OneTimePub[:]), + "tx_pub_key": hex.EncodeToString(stealth.TxPubKey[:]), + "amount_commit": hex.EncodeToString(commitment[:]), + "enc_amount": hex.EncodeToString(encryptedAmount[:]), "amount_napr": amount, + } + } + + const legacyAmount = uint64(1234567) + legacyStealth := identityTestStealth(t, owner, 17) + legacyBlind, err := crypto.NewBlindFactor() + if err != nil { + t.Fatal(err) + } + legacyCommit, err := crypto.Commit(legacyAmount, legacyBlind) + if err != nil { + t.Fatal(err) + } + legacyHash := crypto.HashBytes([]byte("identity-probe-legacy-random-blind")) + legacyEncryptedAmount := core.EncryptAmount(legacyAmount, &legacyStealth.HsScalar) + legacy := map[string]any{ + "tx_hash": hex.EncodeToString(legacyHash[:]), "out_idx": uint32(4), "block_height": uint64(71), + "one_time_pub": hex.EncodeToString(legacyStealth.OneTimePub[:]), + "tx_pub_key": hex.EncodeToString(legacyStealth.TxPubKey[:]), + "amount_commit": hex.EncodeToString(legacyCommit[:]), + "enc_amount": hex.EncodeToString(legacyEncryptedAmount[:]), "amount_napr": legacyAmount, + } + + const mintAmount = uint64(500000000000000000) + mintTx, err := core.BuildMintTx(crypto.AddressFromKeys(crypto.MainnetByte, owner.Keys), mintAmount, 83) + if err != nil { + t.Fatal(err) + } + mint := mintTx.Outputs[0] + mintHash := mintTx.Hash() + mintRecord := map[string]any{ + "tx_hash": hex.EncodeToString(mintHash[:]), "out_idx": uint32(0), "block_height": uint64(83), + "one_time_pub": hex.EncodeToString(mint.OneTimePub[:]), "tx_pub_key": hex.EncodeToString(mint.TxPubKey[:]), + "amount_commit": hex.EncodeToString(mint.AmountCommit[:]), "enc_amount": "0000000000000000", + "amount_napr": fmt.Sprintf("%d", mintAmount), + } + + foreignStealth := identityTestStealth(t, foreign, 19) + foreignHash := crypto.HashBytes([]byte("identity-probe-foreign")) + foreignAmount := uint64(9001) + foreignBlind, err := crypto.DeterministicPaymentBlind(foreignStealth.HsScalar, foreignAmount) + if err != nil { + t.Fatal(err) + } + foreignCommit, err := crypto.Commit(foreignAmount, foreignBlind) + if err != nil { + t.Fatal(err) + } + foreignEncrypted := core.EncryptAmount(foreignAmount, &foreignStealth.HsScalar) + foreignRecord := map[string]any{ + "tx_hash": hex.EncodeToString(foreignHash[:]), "out_idx": uint32(8), "block_height": uint64(91), + "one_time_pub": hex.EncodeToString(foreignStealth.OneTimePub[:]), + "tx_pub_key": hex.EncodeToString(foreignStealth.TxPubKey[:]), + "amount_commit": hex.EncodeToString(foreignCommit[:]), + "enc_amount": hex.EncodeToString(foreignEncrypted[:]), "amount_napr": foreignAmount, + } + return map[string]any{ + "mnemonic": identityTestMnemonic, "owned": owned, + "legacy": legacy, "mint": mintRecord, "foreign": foreignRecord, + } +} + +func TestIdentityWASMFixtureHas4096UniqueOwnedReferencesAndKeys(t *testing.T) { + fixture := makeIdentityWASMProbeFixture(t) + outputs := fixture["owned"].([]map[string]any) + if len(outputs) != 4096 { + t.Fatalf("fixture has %d owned records, want 4096", len(outputs)) + } + references, publicKeys := make(map[string]struct{}, len(outputs)), make(map[string]struct{}, len(outputs)) + for _, output := range outputs { + reference := fmt.Sprintf("%s/%d", output["tx_hash"], output["out_idx"]) + publicKey := output["one_time_pub"].(string) + if _, exists := references[reference]; exists { + t.Fatalf("duplicate owned output reference %s", reference) + } + if _, exists := publicKeys[publicKey]; exists { + t.Fatal("duplicate owned output public key") + } + references[reference], publicKeys[publicKey] = struct{}{}, struct{}{} + } +} + +// Optional public BIP-39 fixture consumed by the Node/WASM API probe. +func TestIdentityWASMFixture(t *testing.T) { + path := os.Getenv("IDENTITY_WASM_TEST_FIXTURE") + if path == "" { + t.Skip("cross-runtime identity fixture not requested") + } + fixture := makeIdentityWASMProbeFixture(t) + data, err := json.Marshal(fixture) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/wallet-wasm/main_js_wasm.go b/cmd/wallet-wasm/main_js_wasm.go index efa6d672..b18d2a46 100644 --- a/cmd/wallet-wasm/main_js_wasm.go +++ b/cmd/wallet-wasm/main_js_wasm.go @@ -130,6 +130,8 @@ func main() { api.Set("buildSignedTransaction", callbacks[6]) callbacks = append(callbacks, js.FuncOf(buildLPoDTransaction)) api.Set("buildLPoDTransaction", callbacks[7]) + callbacks = append(callbacks, js.FuncOf(identifyOutputs)) + api.Set("identifyOutputs", callbacks[8]) js.Global().Set("AperodWalletWasm", api) select {} } diff --git a/cmd/wallet-wasm/public_amount.go b/cmd/wallet-wasm/public_amount.go new file mode 100644 index 00000000..7a510bdd --- /dev/null +++ b/cmd/wallet-wasm/public_amount.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +const maxSafeJSONInteger = uint64(1<<53 - 1) + +// parsePublicAmount accepts exact decimal strings up to uint64 and JSON +// numbers only when they are canonical integer tokens safely representable by +// JavaScript. It never converts through float64. +func parsePublicAmount(raw json.RawMessage) (uint64, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return 0, fmt.Errorf("amount_napr must be a canonical decimal string or safe integer") + } + var digits string + isString := raw[0] == '"' + if isString { + if err := json.Unmarshal(raw, &digits); err != nil { + return 0, fmt.Errorf("amount_napr must be a canonical decimal string: %w", err) + } + } else { + digits = string(raw) + for _, r := range digits { + if r < '0' || r > '9' { + return 0, fmt.Errorf("numeric amount_napr must be an unsigned integer token") + } + } + } + if !canonicalDecimal(digits) { + return 0, fmt.Errorf("amount_napr must use canonical unsigned decimal notation") + } + amount, err := strconv.ParseUint(digits, 10, 64) + if err != nil { + return 0, fmt.Errorf("amount_napr is outside the uint64 range") + } + if !isString && amount > maxSafeJSONInteger { + return 0, fmt.Errorf("numeric amount_napr exceeds JavaScript's safe integer range; send it as a decimal string") + } + return amount, nil +} + +func canonicalDecimal(s string) bool { + if s == "0" { + return true + } + if len(s) == 0 || s[0] < '1' || s[0] > '9' { + return false + } + for i := 1; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/cmd/wallet-wasm/public_amount_test.go b/cmd/wallet-wasm/public_amount_test.go new file mode 100644 index 00000000..18fb1c11 --- /dev/null +++ b/cmd/wallet-wasm/public_amount_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestWasmOutputAmountAcceptsExactAndLegacySafeValues(t *testing.T) { + tests := []struct { + name string + raw string + want uint64 + }{ + {name: "legacy safe integer", raw: `{"amount_napr":123456}`, want: 123456}, + {name: "zero string", raw: `{"amount_napr":"0"}`, want: 0}, + {name: "large exact mint string", raw: `{"amount_napr":"500000000000000000"}`, want: 500000000000000000}, + {name: "maximum uint64 string", raw: `{"amount_napr":"18446744073709551615"}`, want: ^uint64(0)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output wasmOutput + if err := json.Unmarshal([]byte(tt.raw), &output); err != nil { + t.Fatalf("decode output amount: %v", err) + } + if output.Amount != tt.want { + t.Fatalf("decoded amount %d, want %d", output.Amount, tt.want) + } + }) + } +} + +func TestWasmOutputAmountRejectsMalformedOrUnsafeNumbers(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "negative number", raw: `{"amount_napr":-1}`}, + {name: "fractional number", raw: `{"amount_napr":1.5}`}, + {name: "integer written as decimal", raw: `{"amount_napr":1.0}`}, + {name: "exponent number", raw: `{"amount_napr":1e2}`}, + {name: "unsafe rounded number", raw: `{"amount_napr":9007199254740992}`}, + {name: "large JS mint number", raw: `{"amount_napr":500000000000000000}`}, + {name: "negative string", raw: `{"amount_napr":"-1"}`}, + {name: "leading zero string", raw: `{"amount_napr":"01"}`}, + {name: "plus sign string", raw: `{"amount_napr":"+1"}`}, + {name: "uint64 overflow string", raw: `{"amount_napr":"18446744073709551616"}`}, + {name: "null", raw: `{"amount_napr":null}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output wasmOutput + if err := json.Unmarshal([]byte(tt.raw), &output); err == nil { + t.Fatalf("malformed amount accepted: %s", tt.raw) + } + }) + } +} + +func TestWasmOutputAmountOmittedRemainsZero(t *testing.T) { + var output wasmOutput + if err := json.Unmarshal([]byte(`{"tx_hash":"fixture"}`), &output); err != nil { + t.Fatal(err) + } + if output.Amount != 0 || output.TxHash != "fixture" { + t.Fatalf("missing amount changed legacy zero default or other output fields: %+v", output) + } +} diff --git a/cmd/wallet-wasm/transactions_js_wasm.go b/cmd/wallet-wasm/transactions_js_wasm.go index 1913dc3e..f05b92db 100644 --- a/cmd/wallet-wasm/transactions_js_wasm.go +++ b/cmd/wallet-wasm/transactions_js_wasm.go @@ -17,17 +17,6 @@ import ( "github.com/aperod/aperod/wallet" ) -type wasmOutput struct { - TxHash string `json:"tx_hash"` - OutIdx uint32 `json:"out_idx"` - OneTimePub string `json:"one_time_pub"` - TxPubKey string `json:"tx_pub_key"` - AmountCommit string `json:"amount_commit"` - EncAmount string `json:"enc_amount"` - BlockHeight uint64 `json:"block_height"` - Amount uint64 `json:"amount_napr,omitempty"` - BlindHex string `json:"blind_hex,omitempty"` -} type wasmDecoy struct { OneTimePub string `json:"one_time_pub"` AmountCommit string `json:"amount_commit"` diff --git a/cmd/wallet-wasm/wasm_output.go b/cmd/wallet-wasm/wasm_output.go new file mode 100644 index 00000000..51e8d12b --- /dev/null +++ b/cmd/wallet-wasm/wasm_output.go @@ -0,0 +1,44 @@ +package main + +import "encoding/json" + +type wasmOutput struct { + TxHash string `json:"tx_hash"` + OutIdx uint32 `json:"out_idx"` + OneTimePub string `json:"one_time_pub"` + TxPubKey string `json:"tx_pub_key"` + AmountCommit string `json:"amount_commit"` + EncAmount string `json:"enc_amount"` + BlockHeight uint64 `json:"block_height"` + Amount uint64 `json:"amount_napr,omitempty"` + BlindHex string `json:"blind_hex,omitempty"` +} + +// UnmarshalJSON keeps uint64 mint values exact when JavaScript supplies them +// as decimal strings, while preserving safe integer number fixtures. +func (o *wasmOutput) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + amountRaw, hasAmount := fields["amount_napr"] + delete(fields, "amount_napr") + withoutAmount, err := json.Marshal(fields) + if err != nil { + return err + } + type wasmOutputWithoutAmount wasmOutput + var parsed wasmOutputWithoutAmount + if err := json.Unmarshal(withoutAmount, &parsed); err != nil { + return err + } + *o = wasmOutput(parsed) + if hasAmount { + amount, err := parsePublicAmount(amountRaw) + if err != nil { + return err + } + o.Amount = amount + } + return nil +} diff --git a/config/rehearsal-offline.yaml b/config/rehearsal-offline.yaml new file mode 100644 index 00000000..b58ae533 --- /dev/null +++ b/config/rehearsal-offline.yaml @@ -0,0 +1,31 @@ +# Isolated rehearsal only. Never use this configuration for a live validator. +# Its data directory is a disposable copy of an offline production backup. +network: mainnet +data_dir: /tmp/aperod-rehearsal-data +memory_limit_bytes: 2684354560 +p2p: + listen_addr: "/ip4/127.0.0.1/tcp/0" + bootnodes: [] + max_peers: 1 + min_peers: 0 +consensus: + non_validator: true + block_time: "3s" + block_reward_napro: 300000000 + staking_pool_napro: 200000000000000000 + tail_reward_napro: 100000000 + ringct_v4_activation_height: 1750000 + ring_ct_clsag_activation_height: 1769500 + reward_authorization_activation_height: 1750000 + avm_activation_height: 1850000 +api: + enabled: false + listen_addr: "" +genesis: + file: config/genesis-testnet.yaml +pruning: + mode: light + keep_blocks: 200000 +snapshot: + periodic_snapshot_interval: 0 + utxo_count_tolerance_pct: 100 \ No newline at end of file diff --git a/config/testnet.yaml b/config/testnet.yaml index 28a50cea..48e4fccf 100644 --- a/config/testnet.yaml +++ b/config/testnet.yaml @@ -17,7 +17,7 @@ p2p: consensus: validator_key: "" # path to ED25519 private key file (leave empty for non-validator) reward_address: "" # APRO wallet address for block rewards — get yours at https://t.me/aperod_bot - block_reward_napro: 300000000 # 3 APRO from the pre-allocated staking pool + block_reward_napro: 10000000 # 0.1 APRO (10_000_000 nAPRO); 0 = use default # 0 = disabled. Set the same future height on every validator. reward_authorization_activation_height: 0 block_time: "3s" @@ -31,7 +31,7 @@ consensus: # of minting new tokens. Total Supply stays at 10B genesis and DECREASES as # EIP-1559 base fees are burned. After the pool is exhausted, tail_reward_napro # is minted per block (tail emission phase). - # Pool rewards stay at 3 APRO/block with no halving. + # Set staking_pool_napro: 0 to revert to the legacy halving-mint schedule. staking_pool_napro: 200000000000000000 # 2 000 000 000 APRO × 10^8 nAPRO/APRO tail_reward_napro: 100000000 # 1 APRO/block tail emission after pool exhaustion oracle_url: "http://localhost:8080/api/v1/oracle/price" # APRO/USD price endpoint (Node.js API server). diff --git a/deploy/INVESTORS.md b/deploy/INVESTORS.md index 7a77045b..62464d53 100644 --- a/deploy/INVESTORS.md +++ b/deploy/INVESTORS.md @@ -22,8 +22,8 @@ Aperod is a privacy-preserving BFT-PoS blockchain built for real-world payments and gaming. Confidential transfers use **RingCT** with Pedersen commitments, -Bulletproof range proofs, and stealth addresses. **CLSAG v5** is implemented in -the public node and remains height-gated until a coordinated network activation. +Bulletproof range proofs, and stealth addresses. **CLSAG v5** is active on the +live network from coordinated activation block 1,769,500. Coinbase and staking transactions follow separate consensus formats. Key differentiators: @@ -31,7 +31,7 @@ Key differentiators: | Property | Value | |---|---| | Consensus | Permissionless BFT-PoS (stake-weighted active set, rotating proposer, signed ≥2/3 finality) | -| Privacy | RingCT; activation-gated CLSAG v5 with 16-member rings | +| Privacy | RingCT; active CLSAG v5 with 16-member rings | | Native token | APRO (1 APRO = 10⁸ nAPRO) | | Total supply (genesis) | **10 000 000 000 APRO** (10 billion) | | Fee model | EIP-1559: 100 % of base fee is **permanently burned** | diff --git a/deploy/JOIN-NETWORK.md b/deploy/JOIN-NETWORK.md index cf11a01d..77b9b56b 100644 --- a/deploy/JOIN-NETWORK.md +++ b/deploy/JOIN-NETWORK.md @@ -38,8 +38,36 @@ sudo bash /opt/aperod/deploy/aperod-join.sh 89.169.53.128:8545 --api-key <ваш | 4 | Скачивает `chain.db` через `GET /api/v1/chaindb/export` (~1–2 ГБ) | | 5 | Скачивает UTXO-snapshot через `GET /api/v1/snapshot/export` | | 6 | Удаляет `p2p_identity.key` (нода генерирует новый при старте) | -| 7 | Применяет drop-in конфиги systemd (TimeoutStopSec, GOMEMLIMIT) | -| 8 | Запускает `aperod-node` и ждёт готовности API | +| 7 | Прописывает `/ip4//tcp/30303` в `p2p.bootnodes` в `/etc/aperod/node.yaml` | +| 8 | Применяет drop-in конфиги systemd (TimeoutStopSec, GOMEMLIMIT) | +| 9 | Запускает `aperod-node` и ждёт готовности API | + +--- + +## Рекомендуемый порядок установки + +Если IP основного (primary) узла известен заранее, передайте его флагом `--primary-ip` +прямо при установке — это сразу пропишет bootnode и избавит от шага join: + +```bash +# Установка + автоматическое добавление bootnode +sudo bash /opt/aperod/deploy/install-node.sh --primary-ip 89.169.53.128 +``` + +Если IP стал известен позже или нужна полная синхронизация chain.db — запустите +`aperod-join.sh` отдельно (он заменит данные и пропишет bootnode сам): + +```bash +# Сначала установка без флага (bootnode не прописан — нода не стартует в сеть) +sudo bash /opt/aperod/deploy/install-node.sh + +# Затем, когда IP станет известен — подключение к сети: +sudo bash /opt/aperod/deploy/aperod-join.sh 89.169.53.128:8545 +``` + +> ⚠️ **Не запускайте ноду до выполнения одного из двух шагов выше.** +> Нода без bootnode может сформировать блок с несовместимым genesis-хэшем, +> после чего ре-join потребует полного удаления данных. --- @@ -67,6 +95,7 @@ aperod-join.sh : [OPTIONS] --api-key X-API-Key для аутентификации на основном узле --data-dir Директория данных (по умолчанию: /var/lib/aperod) --user Пользователь-владелец данных (по умолчанию: aperod) + --p2p-port P2P-порт основного узла (по умолчанию: 30303) --skip-start Не запускать ноду после загрузки (только данные) --no-chaindb Пропустить загрузку chain.db (только snapshot) ``` @@ -174,7 +203,23 @@ rm -f /var/lib/aperod/p2p_identity.key > ⚠️ Без этого оба сервера используют одинаковый TLS-ключ и видят друг друга как self-connection. `peer_count` останется 0 навсегда. -### Шаг 5: Настроить права и запустить +### Шаг 5: Прописать bootnode в node.yaml + +```bash +# Через node-config.sh (рекомендуется) +sudo bash /opt/aperod/blockchain/deploy/node-config.sh \ + add-bootnode /ip4//tcp/30303 + +# Или вручную — добавить в /etc/aperod/node.yaml: +# p2p: +# bootnodes: +# - /ip4//tcp/30303 +``` + +> ⚠️ Без bootnode оба узла ждут **входящего** подключения и никогда не устанавливают соединение — `peer_count` остаётся 0 бесконечно. +> Подробнее: [раздел «Bootnode — почему он обязателен»](#bootnode--почему-он-обязателен). + +### Шаг 6: Настроить права и запустить ```bash chown -R aperod:aperod /var/lib/aperod/ @@ -220,6 +265,43 @@ consensus: --- +## Bootnode — почему он обязателен + +После копирования цепи у нового узла в `node.yaml` нет записей в `p2p.bootnodes`. +Без хотя бы одного bootnode оба узла (основной и новый) ждут **входящего** подключения +и никогда не устанавливают соединение — `peer_count` остаётся 0 бесконечно. + +**Оба скрипта** автоматически прописывают основной узел как bootnode: + +- `aperod-join.sh` — шаг 7/8, на новом сервере (использует IP из первого аргумента) +- `join-network.sh` — шаг 5/7, по SSH с основного сервера + +Результирующий `node.yaml`: + +```yaml +p2p: + bootnodes: + - /ip4//tcp/30303 +``` + +Оба формата адреса — `host:port` и `/ip4/…/tcp/…` — принимаются `resolveBootnode()` +в `p2p/dns.go`. + +**Если нестандартный P2P-порт** (не 30303), передайте его явно при вызове `aperod-join.sh`: + +```bash +sudo bash aperod-join.sh 89.169.53.128:8545 --p2p-port 30304 +``` + +**Для `join-network.sh`** (rsync-путь): если PRIMARY_IP определяется неверно (например, +возвращается внутренний 10.x вместо внешнего адреса), переопределите его явно: + +```bash +PRIMARY_IP=89.169.53.128 sudo bash join-network.sh +``` + +--- + ## Частые ошибки | Ошибка | Причина | Решение | @@ -228,7 +310,7 @@ consensus: | `connection refused` | Основной узел недоступен | Откройте порт 8545 в firewall основного узла | | `permission denied` при старте | Файлы принадлежат root | `chown -R aperod:aperod /var/lib/aperod/` | | `block at height N missing` | Неполная загрузка chain.db | Запустите скрипт заново (он очищает старые данные) | -| `peer_count: 0` навсегда | Скопированный `p2p_identity.key` | `rm /var/lib/aperod/p2p_identity.key`, restart | +| `peer_count: 0` навсегда | Нет bootnode **или** скопированный `p2p_identity.key` | Проверьте `p2p.bootnodes` в `/etc/aperod/node.yaml`; `rm /var/lib/aperod/p2p_identity.key`, restart | | Нода расходится с сетью | Нет `non_validator: true`, ключ не в validator set | Добавить `non_validator: true` в node.yaml | --- @@ -274,6 +356,24 @@ sudo bash /opt/aperod/deploy/join-network.sh Этот скрипт требует SSH-доступ с основного узла на новый. Используйте `aperod-join.sh` (HTTP) как предпочтительный метод. +### ⚠ Кратковременный простой (~60 с) + +`join-network.sh` **останавливает `aperod-node` на основном узле** перед rsync и +перезапускает его сразу после завершения. + +**Почему это необходимо:** LevelDB небезопасно копировать в работающем состоянии. +Во время rsync движок непрерывно пишет WAL-записи и компактирует `.ldb`-файлы. +Скопированная директория оказывается внутренне несогласованной: при старте LevelDB +откатывается на меньшую высоту, чем источник, и блок на этой высоте имеет другой +хэш. P2P-протокол не может автоматически устранить такое расхождение — нода +застревает в цикле «подключиться → отвергнуть → отключиться». + +Если остановить основную ноду не удаётся (нет SSH-доступа, ошибка systemctl), +скрипт **прерывается** вместо того, чтобы выполнять rsync поверх живой базы. + +**Типичное время простоя:** остановка (`TimeoutStopSec=300`, фактически ~5–15 с) + +rsync (~30–60 с) + запуск (~5 с) = **≈60–90 с**. + --- *Последнее обновление: Август 2026 · [aperod-network](https://github.com/aperod-network/aperod-node)* diff --git a/deploy/LPOD.md b/deploy/LPOD.md new file mode 100644 index 00000000..66707fd2 --- /dev/null +++ b/deploy/LPOD.md @@ -0,0 +1,1001 @@ + + + +# LPoD: a guide to positions, rewards, withdrawals, and activation + +LPoD is an Aperod protocol subsystem developed and owned by the web3 +**Aperod APRO team**. It lets a Guardian reserve eligible on-chain APRO +with a selected validator vault, accrue validator-linked rewards, and +request the return of some or all of that principal. +This guide uses the protocol name without inventing an acronym expansion. + +**Guardian principal is liquid reserved principal, not validator bonded stake.** +A valid full or partial Guardian withdrawal creates its refund in the same +canonical block that includes the request. There is no Guardian unbonding period. +The validator's own stake retains its separate validator-protocol lock. + +This document describes implemented rules and current integration boundaries. +Operator prerequisites are in section 11; authorship and licensing are at the end. + +**Current public-network status:** LPoD v3 is active on Aperod from canonical +height **2493218**, and its pool is funded. This live status is distinct from +fail-closed, default-disabled repository/config settings and historical +development/rehearsal snapshots, which do not describe the current public +network. + +## 1. The problem LPoD addresses + +A block's scheduled proposer receives actual protocol income, but Guardians +attached to other active validators also have time-based accrual. +Those two streams need not match in any individual block. +LPoD records them separately, routes available income through a deterministic +settlement, and uses a dedicated APR reserve to cover eligible shortfalls. +Unfunded amounts remain explicit liabilities instead of being silently minted. + +Every position must start with a real, eligible UTXO and authenticated ownership. +No web session, database balance, administrator label, or claimed deposit +amount creates principal. Checkpoints bind the resulting accounting and +the outputs that actually pay recipients. + +### Glossary + +| Term | Meaning here | +| --- | --- | +| Validator | A validator registered under the existing chain rules, with its own validator stake and status. | +| Leader / proposer | The validator proposing the particular block; its configured beneficiary receives that block's leader share. | +| Guardian | The owner of wallet-backed principal reserved in an LPoD position. | +| Angels payment | The accounting/code name for funded Guardian rewards, not another type of principal. | +| Vault | The accounting group identified by a validator public key, combining its own stake and active Guardian principal for tier selection. | +| Position | One authenticated source-output deposit, identified independently of a web account. | +| Principal | Deposited APRO that belongs to the Guardian, distinct from earned interest. | +| APR reserve | The separately conserved 1B APRO allocation used for reward shortfalls. | +| Arrears | Earned rewards recorded as due but not yet funded and paid. | +| UTXO | An on-chain transaction output that can be spent with the required ownership proof. | +| Key image | The cryptographic spent-source identifier used to prevent reuse. | +| Canonical | Selected as part of the node's current chain. | +| Finalized | Supported by the required consensus evidence for the exact block hash and height. | + +## 2. Three balances that must not be confused + +1. **Spendable wallet outputs:** ordinary eligible outputs the owner can spend. +2. **Reserved Guardian principal:** consumed source outputs represented by + canonical positions. They are no longer ordinary spendable UTXOs while reserved. +3. **The APR reserve:** protocol funds available for Guardian reward shortfalls. + This is not the sum of Guardian deposits. + +A deposit reduces ordinary spendable sources and increases reserved principal. +A withdrawal reduces reserved principal and creates a new spendable refund. +Earned rewards create separate value transfers from authorized reward income +and, if necessary, the APR reserve. + +**Principal refunds do not debit the APR reserve.** Their backing is the +Guardian's previously consumed deposit. Debiting the reserve as well would +charge the same principal twice. +Consequently, `principal_locked_napro` can fall while `balance_napro` +increases because that block also has reward surplus. + +Validator self-stake is a fourth, separate category governed by +[validator staking](core/staking.go). +Full validator withdrawal still uses `UnbondingBlocks = 144000`; +the separate partial-validator withdrawal rule uses `43200` blocks. +Neither waiting period applies to a Guardian position. + +### Validator operator proof and locally authorized exits + +Validator management is separate from Guardian ownership. A connected wallet +session or a typed validator public key alone does not prove possession of a +validator signing key. The account flow requests a short-lived challenge bound +to the wallet session, wallet identity/address, validator public key, chain +anchor, challenge ID, and expiry. The operator signs its domain-separated +`aperod/validator-session/v1` message locally, then uploads only the signed proof. +An accepted possession proof enables the session's operator controls; it is +not a stake-withdrawal signature and does not bypass canonical eligibility. + +For an authorized operator using the matching account integration: + +1. Connect the wallet session, choose the validator, and download the possession + challenge. Check the displayed identity and chain before signing locally: + + ```sh + aperod validator prove-session --challenge challenge.json --key-file validator.key --out proof.json + ``` + +2. Upload `proof.json` to that same session. The private `validator.key` file + stays on the operator's machine; never upload it, paste it into the website, + or send it to the backend. +3. Choose a full or exact partial validator withdrawal, review the consequences, + and download the separate exit challenge. Sign it locally: + + ```sh + aperod validator approve-lpod-exit --challenge exit.json --key-file validator.key --out signed-exit.json + ``` + +4. Review the CLI's action, validator, exact nAPRO amount, current collateral, + chain, nonce, generation, and expiry. Its interactive confirmation requires + `EXIT ` followed by the last eight characters of the validator public key. + Cancellation creates no approval. Upload only `signed-exit.json`, containing + the signed transaction, for submission through the bound session. +5. Follow canonical inclusion and the actual unbonding queue. Submission is + not confirmation, and an expired or stale challenge needs fresh material. + +The v2 withdrawal authorization is a **193-byte payload**, not a new Guardian +transaction version. Its signed hash uses +`aperod/stake-withdrawal/authorization/v2` and binds action, validator public +key, exact amount, chain genesis, next nonce, collateral generation, expiry +height, and stake reference. The reference separately binds genesis, validator, +generation, and current collateral under +`aperod/stake-withdrawal/reference/v2`. This prevents an old approval from +authorizing a different chain, changed collateral, or another nonce. + +At inclusion height `H`, expiry must satisfy `H <= expiry_height <= H + 100`. +The maximum is **100 blocks**, not a wall-clock promise. The registry checks the +current reference and generation, the next nonce, authentic registration, and +the signature again; merely uploading well-formed JSON grants no authority. + +A full validator withdrawal enters unbonding with an end height of +`inclusion_height + 144000`. A partial withdrawal queues the requested portion +until `inclusion_height + 43200`; it must leave enough validator collateral and +can retain the validator's active/pending status. Do not interpret a generic +exit warning as a guarantee that all rewards on eligible remaining collateral +cease after every partial withdrawal. Actual eligibility and subsequent +canonical lifecycle transitions control routing and rewards. +These block-height rules are authoritative; estimated dates are not. + +At the attested lifecycle activation, legacy unbound validator withdrawal +authorization is disabled. The new authorization does not remove either +validator cooldown or impose one on Guardians. Implementation: +[shared validator staking](core/staking.go) and +[local operator CLI](cmd/cli/main.go). + +## 3. Choosing a validator and entering a vault + +Use current canonical validator and position data, not a remembered UI total. +New deposits require an authenticated active validator entry; seeded entries +are not eligible under the native LPoD checks. + +The vault total used for eligibility is: + +```text +validator own stake + all Guardians' remaining active principal in that vault +``` + +The owner chooses the validator public key and signs that choice. +It is not a transfer to the validator's personal wallet. +The beneficiary must belong to the signing Guardian's wallet spend key. + +A deposit consumes exactly its declared source amount. +The resulting vault must stay within the 100M APRO cap, and the deposit must +meet the minimum for the tier selected by that resulting total. +An increase is another authenticated deposit and another source-derived +position, not an unsigned edit of a balance. +Existing minimums and capacity limits still apply to additional deposits. + +New deposits do not receive retroactive interest for the interval before +their inclusion. The prior canonical principal determines that block's +accrual and proposer tier; new principal affects subsequent accrual. + +### Exact tier table + +Amounts below are **APRO**, not nAPRO. +The selected tier is the highest threshold not exceeding the eligible total. +The final row applies at the 100M cap for new-deposit eligibility. + +| Vault threshold | Minimum new deposit | Guardian annual APR | Proposer leader share of actual income | +| ---: | ---: | ---: | ---: | +| 100,000 | 100 | 3% | 8% | +| 500,000 | 1,000 | 5% | 10% | +| 1,000,000 | 10,000 | 6% | 12% | +| 5,000,000 | 20,000 | 8% | 15% | +| 10,000,000 | 50,000 | 9% | 16% | +| 30,000,000 | 80,000 | 9% | 16% | +| 50,000,000 | 100,000 | 9% | 16% | +| 80,000,000 | 150,000 | 9% | 16% | +| 100,000,000 | 200,000 | 10% | 20% | + +The APR applies annually to Guardian principal, not to a block reward. +The leader percentage applies to the proposer's actual block income, +not annually to the Guardian deposit. +Changing a vault total within a tier does not change its percentage at every coin. +Crossing a threshold changes the applicable tier. +No automatic post-2088 percentage change is inferred from projections. + +Source of truth: [tier definitions and accounting](lpod/accounting.go). + +### When the selected validator exits + +A position keeps its original signed `Deposit`, including `Deposit.Vault`, +source identity, owner, and beneficiary. A separate **effective vault** records +where its remaining principal is currently accounted. `store.LPoDEffectiveVault` +resolves that current destination; a reassignment never rewrites the owner's +original signature or creates another deposit. + +When the effective validator exits, becomes inactive, or falls below the +100,000 APRO validator minimum, the canonical transition handles the remaining +principal as follows: + +1. Apply valid owner-signed operations first. A full withdrawal leaves nothing + to reroute or refund again; after a partial withdrawal, only the remainder + is considered. New deposits targeting a validator exiting in that same + block are rejected. +2. Build the eligible top-21 view from canonically active, minimum-stake + validators, ranked by validator stake plus effective Guardian principal. + Equal combined totals use ascending validator public-key order. +3. Process affected positions in ascending position-ID order. Select the + eligible destination with the nearest **equal or lower combined total** + relative to the source's pre-exit comparison total. This comparison uses + the source validator's previous stake plus the remaining Guardian totals + after signed operations, before automatic routing. +4. Require that the entire remaining position fits without exceeding the + destination's 100M APRO cap. Equal destination totals use ascending + public-key order. Earlier routes update destination totals and capacity + before later positions are considered; positions are never split. +5. If a destination qualifies, update the effective route and its height. + Otherwise, return the entire remaining principal through a real protocol + payout in that same canonical block. This automatic refund reduces locked + principal, not the APR reserve, and preserves earned unpaid `Due`. + +The exit-block entitlement remains associated with the prior effective route; +later accrual follows the new route and its applicable tier. A route change +does not erase accrued rewards, reset source ownership, or require a new +owner signature. Existing withdrawal authorization continues to refer to the +original signed deposit and the next position nonce. + +The top-21 view is **LPoD vault eligibility and display ranking, not a BFT +quorum or grant of block-signing authority**. Effective Guardian totals must +exclude returned principal and group live positions by their effective route, +not by the original `Deposit.Vault`. See +[ranking and effective totals](store/lpod_vault_rank.go) and +[canonical position transitions](store/lpod_positions.go). +The storage helpers are `LPoDEligibleVaults` and +`LPoDCheckpoint.LPoDEffectiveGuardianTotals`; these views must not be +substituted for the consensus validator signing set. + +## 4. Accrual and the settlement waterfall + +Every relevant vault is considered, including a vault whose validator did +not propose the block. New Guardian APR accrues only while its validator is active. +Previously earned arrears remain due even after validator inactivity or exit. + +Only the actual proposer supplies block reward income to this settlement. +It is not valid to assign a full block reward to every vault. +The reward schedule remains 3 APRO while the validator reserve supports it, +the remaining partial draw when smaller than 3 APRO, and then the existing +1 APRO tail. Tail issuance is tracked separately. + +For each vault, in deterministic sorted vault-ID order: + +1. Calculate the leader share of actual income, retaining its integer remainder. + A non-proposer has no leader payment from nonexistent block income. +2. Determine newly earned Guardian APR and add previously unpaid arrears. +3. Use income remaining after the leader share for those Guardian entitlements. + Only an excess over entitlements is credited as reserve surplus. +4. If that residual income is insufficient, draw the shortfall from the + available APR reserve, without taking it below zero. +5. Record any still-unfunded amount as arrears, not as a paid reward. + +Within a vault, funded Guardian payments are allocated proportionally to +position-specific amounts due, with deterministic position-ID ordering and +integer rounding. Beneficiaries are aggregated for actual output construction. + +This is a sequential waterfall, not a claim that every vault shares all +future income immediately. At reserve exhaustion, vault order matters: +surplus arriving from a later vault need not pay an earlier vault's arrears +until a subsequent settlement. No universal payout guarantee follows from APR. + +### Time, smallest units, and rounding + +```text +1 APRO = 100,000,000 nAPRO +year = 365 × 24 × 60 × 60 = 31,536,000 seconds +elapsed_ns = min(block_timestamp − parent_timestamp, 15,000,000,000) +D = 100 × 31,536,000 × 1,000,000,000 +N = remaining_principal_napro × APR_percent × elapsed_ns + prior_APR_carry +new_due_napro = floor(N / D) +next_APR_carry = N mod D +``` + +Canonical timestamps must advance. The 15-second cap means a long outage +does not accrue an unlimited catch-up interval in the next block. +Individual integer carries persist across ordinary settlements and partial exits. +Fractional nAPRO is not an independently spendable output. +APR is simple accrual on remaining principal; paid rewards are not automatically +redeposited or compounded. + +## 5. Worked accounting examples + +These are arithmetic illustrations with stated assumptions, not production balances. + +### A. Small first-tier position, reward surplus + +Assume an already-active 100 APRO position in a first-tier vault, no prior +carry or arrears, and a three-second interval in a block proposed by its validator. + +```text +Actual income: 3 APRO +Leader: 3 × 8% 0.24 APRO +Residual for Guardian settlement: 2.76 APRO +New Guardian due: 28 nAPRO = 0.00000028 APRO +Reserve surplus: 2.75999972 APRO +Next individual APR carry: 1,699,200,000,000,000,000 +``` + +The reward is not rounded up to a whole APRO. +The carry preserves the division remainder for future accrual. +The 100 APRO principal remains reserved and is not part of this reward split. + +### B. Top-tier position, funded deficit + +Assume validator self-stake of 100,000 APRO and Guardian principal of +99,900,000 APRO: total 100M, APR 10%, leader share 20%. +For a 15-second interval with zero initial carry and no arrears: + +```text +Actual income: 3 APRO +Leader: 0.6 APRO +Residual: 2.4 APRO +New Guardian due: 4.75171232 APRO +Reserve draw required: 2.35171232 APRO +``` + +If the reserve can supply the draw, the Guardian receives 4.75171232 APRO. +If only 1 APRO is available, the Guardian receives 3.4 APRO; +1.35171232 APRO remains arrears. The leader still receives 0.6 APRO. +This does not authorize borrowing the Guardian's principal to pay interest. + +### C. A beginner's deposit, decrease, increase, and exit + +Alice owns eligible outputs of 500 APRO and 100 APRO. +She chooses an active first-tier validator with sufficient capacity. + +1. Alice locally signs a deposit consuming the 500 APRO output. + After canonical inclusion, reserved principal is 500 APRO. +2. She signs a 200 APRO partial withdrawal with the next position nonce. + That block returns 200 APRO plus any funded reward included for her. + Remaining principal is 300 APRO and continues to accrue. +3. She deposits her separate 100 APRO output into the same vault. + Reserved principal is now 400 APRO across two positions. + The topup is backed by its own consumed output. +4. She signs full withdrawals for both positions. + The inclusion block returns the remaining 400 APRO, not the original + 500 APRO again. Earned arrears, if any, remain claimable through settlement. + +Cumulative deposited principal is 600 APRO and cumulative returned principal +is 600 APRO after the final exits. Current reserved principal is zero. +Interest and ordinary spending fees are separate from those principal totals. + +## 6. Full and partial withdrawal rules + +There is no Guardian freeze, including when the associated validator becomes +inactive. A withdrawal does not require that validator's private key or approval. +It does require the Guardian owner's valid signature and canonical position state. + +For a native withdrawal: + +- Keep the original deposit/source identity, owner, beneficiary, and signed + deposit vault unchanged, even if the effective accounting route has changed. +- Use `Nonce = current position nonce + 1`. +- Set `withdraw_amount_napro` to a positive requested partial amount, + or omit it/use zero to request all remaining principal. +- Do not request more than the remaining principal. +- Sign and submit the actual transaction; a form submission alone changes nothing. + +The original `Amount` remains the deposit's source-opening amount. +It is not rewritten after a partial exit. +`Withdrawn` is the position's cumulative returned amount: + +```text +remaining principal = Deposit.Amount − Withdrawn +``` + +The elapsed interval through the withdrawal block is accrued first. +Thereafter only any remaining principal earns new APR. +A full exit sets `Returned = true` and records its inclusion height in +`UnlockHeight`; that legacy field name does not create a waiting period. + +The same canonical block creates the refund output. +It can be used in a subsequent ordinary spend after the wallet discovers it +and normal spend validation succeeds. There is no promise of spending an +unconfirmed mempool request or of spending an output before it exists. + +Closed records with unpaid arrears remain. +Fully returned records with no arrears can be pruned on a later transition. +The pool API's `position_count` counts positions with remaining principal, +not all retained records. + +## 7. Native transactions and ownership + +The relevant transaction versions are: + +| Version | Role | +| ---: | --- | +| 8 | Checkpoint commitment to the resulting LPoD accounting and position state. | +| 9 | Signed Guardian deposit or withdrawal operation. | +| 10 | Zero-input protocol payout containing the authorized leader, Guardian, and principal-refund outputs. | + +A version-10 zero-input payout is not a general-purpose user mint. +Consensus recomputes the complete entitlement plan and requires exactly the +corresponding outputs and checkpoint. Extra protocol payouts are rejected. +Existing validator-stake transactions retain their own classification and validation. + +Deposits use the existing MLSAG-v4 direct ownership/opening proof and a linked +source key image. A separate proof authenticates the beneficiary spend key. +The signed action binds genesis, source, position, vault, owner, beneficiary, +amount, withdrawal amount where applicable, action, and nonce. + +`LPoDPositionID` derives identity from genesis, source transaction, and output index. +An additional source creates an additional position ID. +Spent key images prevent reusing the original source even after a closed +position record is pruned. +Withdrawals use advancing position nonces and remaining-principal checks. +Neither a replayed full exit nor an overdraw becomes a second refund. + +Payout outputs use wallet-scannable keys derived deterministically from +the parent/checkpoint context. Beneficiaries are aggregated and sorted; +the protocol rejects duplicate output keys within the block. +The deterministic ephemeral values are public, not privacy secrets. +Ordinary CLSAG spending of the resulting outputs is exercised in tests. + +### Important privacy warning + +Native version-9 positions are **public protocol records**. +The source reference, amount opening/blind, beneficiary, vault, and action +are disclosed as part of the deposit protocol. +Do not describe an LPoD deposit as an unlinkable private transfer. +An opening is not a wallet spend key, but it reveals information about +that source and the amount reserved. +Version-10 beneficiaries and amounts are likewise protocol-visible. + +## 8. Wallet operation and live totals + +The native wallet builder performs signing locally in WASM. +Mnemonic-derived private keys belong in local signing memory, not an HTTP +request to a node or an application server. +Public unsigned position data can come from the API; the signer verifies +the owner, beneficiary, genesis, and source/position relationship. + +The deposit builder requires **exactly one owned source output**. +It deposits that output's full amount and does not silently split an arbitrary +requested amount, choose hidden change, or round a balance. +If suitable outputs are unavailable, a separately reviewed ordinary wallet +transaction may be needed first. Automatic arbitrary splitting is not supplied +by this LPoD builder. + +Wallet reserved totals must come from canonical positions: + +- Per owner: sum remaining principal for that beneficiary. +- Per vault: validator self-stake plus remaining Guardian principal currently + assigned to that effective vault, including canonical reassignments. +- Network Guardian total: canonical `PrincipalLocked`. +- Spendable funds: eligible discovered outputs, excluding consumed and pending-spent sources. + +The wallet-facing integration refreshes canonical data on a roughly ten-second +poll and when focus returns, and refreshes after transaction activity. +That is UI synchronization, not a ten-second protocol settlement schedule. +Missing/failing canonical data must not be displayed as an invented zero balance. +Percentages change at tier thresholds, not after every individual coin change. + +The wallet integration indexes authentic version-10 outputs in the same +canonical commit and rollback batches as the protocol state. +Finalized beneficiary-indexed pages are merged into signing material only +when their checkpoints agree. They do not create fabricated SQL wallet records. +Local WASM scanning verifies ownership and derives key images; the node +checks canonical spent status, output references, and pending spends. +Native spendable balances shown on wallet home are locally scanned results, +not authoritative SQL credits. Snapshot misalignment fails closed. + +The full-workspace integration test has passed with an initially empty SQL +wallet index, the actual WASM signer/scanner, and a native test chain: +deposit, partial refund, rollback/replay, rewards, full refund, ordinary +version-5 spending, and a version-9 redeposit. +This is integration evidence, not a multi-node finality claim. + +The current wallet scan has a **4,096-native-output bound**. +Exceeding it returns an explicit error, rather than silently truncating a +balance or claiming unlimited scalability. +This per-wallet scan bound is distinct from the protocol's position capacity. +An old activated store without the wallet-index readiness marker requires +canonical replay from activation; an index-not-ready error is not proof of +an empty wallet. +Verify deployment of the matching node, WASM bundle, discovery integration, +and index together before relying on a deployed wallet. + +## 9. Pending, included, finalized, and reorganized + +A broadcast hash means the request was submitted, not that money moved. +Mempool admission is not canonical inclusion. +State-dependent stale requests can be rejected or evicted without changing principal. + +Canonical inclusion applies the operation, records the new checkpoint, and +creates any funded output. The read APIs additionally require finality evidence +for the exact current canonical tip before exposing active monetary projections. +An included refund and an API still reporting `pending` are therefore different +stages, not necessarily contradictory observations. + +Checkpoints are keyed by block hash rather than an independently mutable balance. +Funding, positions, principal counters, carries, payouts, relevant indices, +and the canonical block/AVM commit use the atomic persistence path. +Restart loads committed state instead of defaulting to a 1B balance. +It does not invent finality evidence from height alone. + +Ancestor rollback restores the selected accounting and native indices; +orphaned refunds must disappear from the spendable set. +Alternate blocks must be accepted through normal validation. +The implemented rollback path requires rewinding to a common ancestor rather +than arbitrarily selecting an unrelated stored tip. +Going earlier than the attested funding parent requires additional historical +budget evidence and otherwise fails closed. + +These controls address replay and branch consistency. +They are not a claim that forks, compromised trusted authorities, or operator +misconfiguration are cryptographically impossible. + +## 10. Conservation and the 1B allocation + +All following identities use exact nAPRO integers, not floating-point UI amounts. +Let `F` be the once-only 1B allocation, `R` accumulated authorized reward income, +`B` reserve balance, `L` paid leader rewards, and `G` paid Guardian rewards: + +```text +F + R = B + L + G +B = F + cumulative surplus − cumulative deficit draws +accrued Guardian liability = paid Guardian rewards + unfunded liability +cumulative principal deposited = principal locked + principal returned +``` + +Principal is not added to the first equation. +Reserving an already-issued coin changes custody/accounting, not issuance. +Arrears are liabilities, not spendable outputs and not an extra supply allocation. + +### Funding inside the existing 10B budget + +Let `I` be reconciled historical issuance and `V0` the authenticated remaining +validator reward budget at activation. The implementation protects an additional +1B development reservation: + +```text +available before LPoD = 10B − I − V0 − 1B development reservation +allocation remaining after funding = available before LPoD − 1B LPoD debit +``` + +There must be enough proved availability. +The validator budget must equal its existing durable value; activation may not +reset it to 2B or silently reduce it to create room. +Historical validator issuance is already part of `I`; it is not charged again +as though the original whole validator allocation were still unspent. + +The general gross allocation identity is: + +```text +I + protected development reservation + allocation remaining + + current validator budget + B + L + G + = 10B + explicitly recorded tail issuance +``` + +These are gross issuance/allocation identities, not claims about circulating +free float. Canonical fee burns must be considered separately when interpreting +circulating supply. Locked Guardian principal must not be counted a second time. + +The 1B reserve is an allocation debit and corresponding protocol credit, +not permission to mint an extra 1B on top of the supply plan. +It becomes a spendable wallet output only through an authorized funded payout. + +## 11. Historical reconciliation and activation + +**Historical development/rehearsal snapshot — not current Aperod public-network +status:** The following audit statement records the state at the time of that +work; it is not a statement that the currently active Aperod network is +unfunded or inactive. + +Production funding and activation have not been performed by this work. +For activation on a new network, an approved reconciliation, trusted-validator +attestations, coordinated compatible upgrade, canonical activation block, and +appropriate deployment authorization remain prerequisites. Code, wallet +controls, API capabilities, and passing tests are not evidence of a funded +production reserve. +Before enabling validator exit controls, verify that the deployed account +gateway and CLI consume the exact node authorization fields below, require +the active canonical proof, and obey the advertised expiry-height bounds. +Stale field aliases or longer-lived challenges are not compatible approvals. + +There is no self-declared “set pool to 1B” configuration path. +The witness must cover every historical coinbase output in canonical order +through activation height minus one, with amount openings verified against +the actual commitments. +Pruned or missing required bodies cannot simply be replaced by supply estimates. + +Legacy transaction hashes alone are not an unambiguous historical body proof. +The reconciliation additionally commits to a length-framed canonical full-body +root, historical issuance, validator budget, genesis, height, and openings. +The witness requires signatures from **strictly more than two thirds of +distinct trusted genesis validators**. +Trusted authorities are injected from node configuration, not supplied by +the witness's own assertions about who may approve it. +This remains an explicit governance trust boundary requiring independent review. + +### Witness representation + +The `LPoDMigration` JSON fields are: + +```text +version, position_lifecycle_version, height, genesis, +reconciliation_root, openings, body_root, +historical_issued_napro, validator_remaining_napro, attestations +``` + +The current signed migration requires `position_lifecycle_version = 1`. +This explicitly authorizes deterministic effective-vault reassignment and +same-block automatic principal refunds when no destination qualifies. +The lifecycle version is part of the validator-attested migration message; +it is not an unsigned wallet preference or an optional local routing toggle. +An older witness without the required version cannot authorize these rules. + +Each opening contains `height`, `tx_index`, `output_index`, +`amount_napro`, and `blind`. +Each attestation contains `validator` and `signature`. +Monetary witness fields are decimal strings. +With the current Go encoding, hashes and blind factors are 32-byte numeric +arrays; validator public keys and signatures are base64. +Do not substitute API-style hex strings into this witness format. +Generate and verify it with the actual Go types and hash helpers. + +Useful helpers in [migration verification](store/lpod_migration.go) are +`LPoDBodyRootStep`, `LPoDMigration.Root`, `AttestationMessage`, +`VerifyAuthorization`, and `DB.VerifyLPoDMigration`. +The exact root serialization and domain separation are defined there. +They must not be re-created by guessing JSON field order or hash algorithms. + +### Conceptual activation checklist + +1. Confirm the software license, deployment plan, and chain governance approval. + Apache 2.0 does not itself activate LPoD on a network. +2. Review the implementation, consensus change, threat model, and wallet path. + Exercise independent multi-node acceptance, restart, rollback, and discovery. +3. Agree on the exact chain/genesis and an activation checkpoint. + Arrange coordinated checkpoint/signing timing: the witness includes bodies + through `H−1`, which cannot be truthfully invented for unknown future blocks. +4. Obtain complete authentic canonical history and valid issuance openings. + Reconcile the existing durable validator budget without changing entitlement. +5. Independently review the full-body commitment and gross allocation equation. + Confirm the protected development reservation and available 1B debit. +6. Obtain the required distinct trusted-validator attestations using authorized + key-management procedures. Never place private validator keys in a witness. +7. Distribute and independently verify the identical approved witness and + compatible software/configuration across participating nodes. +8. Configure `consensus.lpod_migration_file` to the approved local witness. + Keep the incompatible legacy Guardian allocation disabled. + Reward authorization must activate no later than this fork; reward + parameters must match the supported validator-pool and tail schedule. +9. At the agreed height, normal canonical block validation and atomic commit + perform the one-time debit. Startup or loading a JSON file does not fund it. +10. Verify exact-hash finality, reserve/source conservation, validator budget, + wallet-index readiness, real output discovery, and an authorized spend. + Keep alerts and an approved recovery plan in place. + +Before scheduling a migration, run +`go run ./cmd/lpod-audit --copied-db /path/to/offline-copy/chain.db --through-height H_MINUS_ONE` +from the +`blockchain` directory against a **consistent, separate, stopped-node copy**. +The command opens LevelDB read-only and reports the first missing, pruned, or +structurally invalid historical body. It does not authenticate historical +signatures or proofs, recover issuance amounts or blinds, calculate a funded +balance, or authorize the allocation. Do not aim it at a live node database, +and do not treat a complete scan as a migration witness. The migration also +refuses a signer set that differs between configured genesis authorities and +the node's effective validators, including a zero-key placeholder. + +If an old indexed block fails the current header-hash check, use +`go run ./cmd/lpod-diagnose --copied-db /path/to/offline-copy/chain.db --height N` +to compare the signed historical header-hash formats on the same offline copy. +This is diagnostic only: matching an old header hash does not validate the +historical transaction/Merkle rules, fill missing block bodies, or approve +funding. Never disable reconciliation checks to make an old archive pass. + +No complete reconciliation-generation/attestation CLI is supplied. +The existing `cmd/mintblind` utility is a limited legacy mint-candidate tool, +not a migration tool; its floating-point argument path is not an appropriate +source of exact funding arithmetic. +This guide intentionally supplies no fake witness, key-export instruction, +remote-validator access procedure, or bypass of a failed verification. + +## 12. Public node API + +These are native node routes, not a promise that every web gateway uses +the same URL. See [route registration](api/rest.go). +Monetary quantities are decimal nAPRO strings; do not coerce them into +JavaScript floating-point arithmetic. + +### `GET /api/v1/lpod-pool` + +The response includes `version`, `protocol_version`, `state`, +`accounting_basis: "canonical_protocol_ledger"`, and +`initial_napro: "100000000000000000"`. +The initial value is a protocol target, not evidence of funding. + +| State | Interpretation | +| --- | --- | +| `disabled` | No configured LPoD migration. | +| `pending` | Awaiting funding/checkpoint or exact-tip finality; a concurrent tip change can also defer publication. | +| `unavailable` | Required store or canonical/configuration evidence cannot be established. | +| `active` | Matching canonical checkpoint and exact-tip finality passed the read checks. | + +Monetary/state projection fields are explicitly null before active publication: +`balance_napro`, `reward_inflow_napro`, `leader_paid_napro`, +`angel_paid_napro`, `surplus_inflow_napro`, `deficit_outflow_napro`, +`unfunded_liability_napro`, `funding_debit_napro`, +`total_guardian_stake_napro`, `principal_deposited_napro`, +`principal_locked_napro`, `principal_returned_napro`, +`allocation_remaining_napro`, `validator_remaining_napro`, and `tail_issued_napro`. + +Metadata includes `last_settled_height`, `finalized_height`, `position_count`, +`funding_height`, `funding_block_hash`, `chain_anchor`, and `reconciliation_root`. +`position_count` counts remaining-principal positions; retained arrears records +can exist when this count and locked principal are both zero. + +`guardian_membership_supported`, `immediate_exit_supported`, +`partial_exit_supported`, and `additional_deposits_supported` describe code +capabilities. They do not activate the protocol. + +### `GET /api/v1/lpod/positions?address=...` + +A valid address is required. The projection includes `state`, `address`, +`reserved_napro`, `positions`, `checkpoint_hash`, `finalized_height`, +and `wallet_mutations_supported`. +Active data additionally exposes `chain_anchor` and eligible `vaults`. + +Each position has `id`, `vault`, `principal_napro` (remaining), +`returned_napro` (cumulative), `due_napro`, `nonce`, and +`deposit_action_json` (the already-public original native action). +It also exposes `effective_vault`, `route_height`, and `auto_returned`. +Both `vault` and `effective_vault` identify the current accounting destination; +`deposit_action_json` retains the original signed vault and remains the +authorization source. `auto_returned` identifies the no-destination lifecycle +refund, not an unfunded promise of future principal. +Rows are sorted by position ID. +Vault rows include `id`, `total_napro`, `minimum_napro`, +`apr_percent`, and `leader_percent`. +Clients must still allow the node to revalidate eligibility at inclusion. + +### Validator registry authorization contract + +`GET /api/v1/validators` and +`GET /api/v1/validators/{pubkey}/unbonding` expose the validator authorization +material. Preserve the exact spelling and types; do not invent compatibility +aliases or derive an authorization from floating-point APRO displays. + +| Field | Encoding and meaning | +| --- | --- | +| `stake_napr` | Decimal string: current validator collateral in nAPRO; the existing field spelling is intentional. | +| `stake_generation` | Decimal string: collateral generation. | +| `stake_auth_nonce` | Decimal string: last accepted authorization nonce. | +| `stake_auth_next_nonce` | Decimal string: required next nonce; empty if the counter is exhausted. | +| `stake_reference` | Hex-encoded current v2 stake reference. | +| `stake_auth_genesis` | Hex-encoded configured chain genesis. | +| `stake_auth_activation_height` | Decimal string: configured attested activation height. | +| `stake_auth_current_height` | Decimal string: current canonical height used for the response. | +| `stake_auth_expiry_min_height` | Decimal string: earliest advertised next-block expiry height. | +| `stake_auth_expiry_max_height` | Decimal string: upper advertised expiry bound for that next-block window. | +| `stake_withdrawal_v2_active` | Boolean: the node's current canonical activation/finality checks passed. | + +The expiry bounds start at the next height and extend by at most 100 blocks, +with integer-overflow handling. Clients must refresh stale material rather than +substitute a longer authorization period. Keep all nonce, generation, height, +and monetary arithmetic exact. + +`stake_withdrawal_v2_active` is not merely “this binary supports v2.” +It requires matching registry configuration and lifecycle-version-1 migration, +the activation height to have been reached, a matching canonical funded +checkpoint/allocation and reconciliation root, exact-tip finality, and a stable +tip through the check. Missing evidence leaves it false. Conversely, true +does not prove that a website visitor possesses the validator key: the local +session proof and separately signed exit are still required. +See [REST registration and projections](api/rest.go). + +### Payout discovery and spent-source checks + +`GET /api/v1/lpod/wallet-outputs?address=...&cursor=...` is a bounded, +address-indexed discovery route, with `outputs`, `next_cursor`, and +an active `checkpoint_hash`. A page examines at most 128 index entries. +Clients must bind pagination to one consistent checkpoint. +Output records include `tx_hash`, `out_idx`, `block_height`, +`one_time_pub`, `tx_pub_key`, `amount_commit`, and `enc_amount`. + +The route requires finalized canonical state and an index ready for the actual +funding block. It returns explicit errors when that index requires canonical +replay, and a conflict when the tip changes during a read. +An empty page alone does not establish an empty wallet. + +`POST /api/v1/wallet/key-images` accepts bounded public `key_images` +and matching `refs` containing `tx_hash` and `out_idx`. +It returns spent-status information and a checkpoint hash. +Local scanning and ownership verification are still required; neither endpoint +needs a mnemonic, wallet private key, or session-created monetary balance. + +## 13. Limits, failure cases, and frequently asked questions + +**What happens when the APR reserve reaches zero?** +Available residual income can still fund rewards. +The unpaid remainder stays as arrears; displayed APR is not guaranteed cash. +Principal refunds remain separately backed and are not blocked by reserve depletion. + +**Can an inactive validator trap my Guardian deposit?** +The Guardian exit rule has no validator-active requirement. +New accrual stops while the validator is inactive; earned arrears remain due. +New deposits require an eligible active validator. + +**Does every retained position count toward capacity?** +There is a 4,096-position bound. +Closed records with unpaid liabilities remain necessary; fully returned, +fully paid records are released by subsequent processing. +Capacity and open-position count are therefore different concepts. + +**Can a validator self-stake change raise a vault above 100M?** +New Guardian deposits may not push it above the cap. +If subsequent validator self-stake growth raises an existing vault above it, +accrual uses the top tier rather than halting the ledger. +Clients must not infer new-deposit eligibility from that accrual treatment. + +**Does a topup require the validator's key?** +No. It requires an eligible selected vault and the Guardian's authenticated +owned output. It is a new source-backed position. + +**Can I withdraw rewards that are still arrears?** +Arrears cannot be spent before an actual funded output exists. +An exit preserves them; later settlements retry funding them. + +**Why can an active API temporarily become pending?** +Its publication rule concerns the exact current tip. +A new tip may not yet have the required finality evidence. +Do not substitute stale values and label them current finality. + +**Does this prevent every possible fork or uncoordinated deployment?** +No. Consensus validation, governance trust, local security, operational +coordination, and software licensing are distinct boundaries. +Apache 2.0 permits forks under its terms, and the implementation does not make +hostile or incompatible forks logically impossible. + +## 14. Developer map and verification + +All paths and commands in this guide are relative to the **public Go repository +root**, where `go.mod` resides. +The source inventory below also uses public-root paths. + +| Area | Starting point | +| --- | --- | +| Actions, ownership, signing, payout keys | [core/lpod_position.go](core/lpod_position.go) | +| Checkpoint transaction | [core/lpod.go](core/lpod.go) | +| Tiers, rounding, reward conservation | [lpod/accounting.go](lpod/accounting.go) | +| Position transitions, partial returns, arrears | [store/lpod_positions.go](store/lpod_positions.go) | +| Effective-vault totals, top-21 ranking, deterministic destinations | [store/lpod_vault_rank.go](store/lpod_vault_rank.go) | +| Checkpoint persistence and payout validation | [store/lpod.go](store/lpod.go) | +| Historical allocation proof and attestations | [store/lpod_migration.go](store/lpod_migration.go) | +| Canonical preparation and stateful filtering | [consensus/lpod.go](consensus/lpod.go) | +| Local native wallet signing | [cmd/wallet-wasm/lpod.go](cmd/wallet-wasm/lpod.go) | +| Wallet index and rollback | [store/lpod_wallet_index.go](store/lpod_wallet_index.go) | +| Pool/owner projections | [api/lpod_pool.go](api/lpod_pool.go), [api/lpod_positions.go](api/lpod_positions.go) | +| Discovery route | [api/lpod_wallet_outputs.go](api/lpod_wallet_outputs.go) | +| Startup witness loading | [cmd/node/lpod.go](cmd/node/lpod.go) | + +`LPoDPositionAction`, `LPoDPosition`, `LPoDCheckpoint`, and +`LPoDMigration` are distinct data structures, not interchangeable balances. +Use the signing/validation helpers rather than copying an API balance into +a transaction and expecting ownership to follow. + +For a development environment, relevant checks are: + +```sh +go test ./lpod ./store ./core ./config ./consensus ./api ./cmd/node ./avm ./node -count=1 +go test ./cmd/wallet-wasm -run LPoD -count=1 +go test -race ./lpod ./store ./consensus -run 'LPoD|Arrears' -count=1 +``` + +The complete WASM/discovery integration can be reproduced from the Go root, +within a full development workspace that includes the integration harness, with: + +```sh +LPOD_WASM_E2E=1 go test ./consensus -run TestLPoDCanonicalWalletPayoutDiscovery -count=1 -v +``` + +That opt-in path invokes the JavaScript integration harness and its package +manager. The harness is not included in the standalone public Go repository; +do not treat the opt-in command as a self-contained public-repository script. +Without the opt-in environment variable, the Go discovery test still exercises +its native index/API and canonical rollback portion. + +See [position integration tests](consensus/lpod_positions_test.go), +[arrears/exit tests](store/lpod_positions_test.go), +[migration regressions](store/lpod_migration_test.go), +[finality regressions](consensus/lpod_finality_test.go), +[local signer tests](cmd/wallet-wasm/lpod_test.go), and +[wallet discovery tests](consensus/lpod_wallet_discovery_test.go). +Lifecycle regressions are in +[ranking and routing tests](store/lpod_vault_rank_test.go) and +[validator-exit integration tests](consensus/lpod_lifecycle_test.go): +deterministic reassignment, preserved owner authorization, and a real +spendable principal refund when no destination qualifies. +Tests cover real deposits/refunds and ordinary spending, partial exits/topups, +APR boundaries, replay/overdraw rejection, restart/rollback, and preserved +validator unbonding. Depleted-reserve and API projection fixtures explicitly +isolate arithmetic/projection cases; they are not authentic production witnesses. +Passing tests do not replace independent multi-node validation or chain +governance review. + +## 15. Software licensing is not protocol activation + +The LPoD source code and tests are licensed under +[Apache License 2.0](LICENSE). That license permits use, modification, and +distribution under its terms without separate prior approval from the Aperod +APRO team. It does not activate LPoD on a chain, supply a reconciliation +witness, satisfy validator attestations, or replace deployment and governance +review. + +This guide and other original LPoD documentation are licensed under +[Creative Commons Attribution 4.0 International](LICENSE-DOCS). When reusing +licensed documentation, provide the attribution and other notices required by +CC BY 4.0. The official project attribution is published at +[aperod.com/vaults#lpod-authorship](https://aperod.com/vaults#lpod-authorship). + +Neither license claims ownership of LPoD as an abstract idea, method, algorithm, +or protocol concept. Independently developed clean-room implementations are not +restricted by these notices, and no attribution is required merely for using an +idea rather than licensed Aperod code or documentation. + +## 16. LPoD source map and authorship + +The web3 Aperod APRO team authored the original LPoD implementation represented +by the following **31 public-repository paths**. Each dedicated file is marked +with `SPDX-License-Identifier: Apache-2.0`: + +- `api/lpod_pool.go` +- `api/lpod_pool_test.go` +- `api/lpod_positions.go` +- `api/lpod_positions_test.go` +- `api/lpod_wallet_outputs.go` +- `cmd/node/lpod.go` +- `cmd/wallet-wasm/lpod.go` +- `cmd/wallet-wasm/lpod_js_wasm.go` +- `cmd/wallet-wasm/lpod_test.go` +- `consensus/lpod.go` +- `consensus/lpod_finality_test.go` +- `consensus/lpod_lifecycle_test.go` +- `consensus/lpod_positions_test.go` +- `consensus/lpod_test.go` +- `consensus/lpod_wallet_discovery_test.go` +- `core/lpod.go` +- `core/lpod_position.go` +- `core/stake_withdrawal_v2_test.go` +- `lpod/accounting.go` +- `lpod/accounting_test.go` +- `lpod/arrears_test.go` +- `store/lpod.go` +- `store/lpod_indices.go` +- `store/lpod_migration.go` +- `store/lpod_migration_test.go` +- `store/lpod_positions.go` +- `store/lpod_positions_test.go` +- `store/lpod_test.go` +- `store/lpod_vault_rank.go` +- `store/lpod_vault_rank_test.go` +- `store/lpod_wallet_index.go` + +This is an attribution and navigation map, not a boundary that removes Apache +2.0 permissions from shared repository files. Dependencies, generated code, +and third-party material remain subject to their own terms. Shared staking, +registry REST, and CLI files remain under the repository's applicable licensing; +the v2 authorization and operator-flow changes do not make them original +dedicated LPoD files. + +## 17. Attribution and compatibility notice + +**Code and tests:** Apache License 2.0, with the web3 Aperod APRO team copyright +notice retained as required by the license. See [LICENSE](LICENSE). + +**This guide and original LPoD documentation:** Creative Commons Attribution +4.0 International. See [LICENSE-DOCS](LICENSE-DOCS). A suggested attribution is: +"LPoD documentation by the web3 Aperod APRO team, licensed under CC BY 4.0," +with a link to the source and license and an indication of changes when required. + +The former restrictive `LICENSE-LPOD` notice is superseded for the current +release by the Apache 2.0 code license and CC BY 4.0 documentation license. +These current licenses are permissive grants governed by their own terms; they +do not require prior written Aperod approval. See [NOTICE](NOTICE) and the +official [LPoD authorship section](https://aperod.com/vaults#lpod-authorship). \ No newline at end of file diff --git a/deploy/SECURITY.md b/deploy/SECURITY.md index 9e963e8d..54ebc262 100644 --- a/deploy/SECURITY.md +++ b/deploy/SECURITY.md @@ -28,7 +28,7 @@ Include: Researchers repeating APD-2026 findings should first follow the [`APD Remediation Verification Guide`](../SECURITY-RESEARCHER-GUIDE.md). It -identifies the current remediation baseline, activation-gated behavior, and +identifies the current remediation baseline, coordinated activation behavior, and the exact regression suites for each finding. Response within **48 hours**. Critical issues patched within **7 days**. @@ -80,7 +80,7 @@ All reward amounts are at the sole discretion of the Aperod team and subject to - **Transaction unlinkability**: one-time stealth addresses (per-height mint derivation: `mint_pub = spend_pub + height·G`) - **Amount confidentiality**: Pedersen commitments with Bulletproof range proofs -- **Ring signatures**: CLSAG v5 proves ownership within a 16-member ring while binding every public key to its commitment; v5 is implemented but remains height-gated until coordinated activation +- **Ring signatures**: CLSAG v5 proves ownership within a 16-member ring while binding every public key to its commitment and pseudo-output; v5 is active on the live network from coordinated activation block 1,769,500 - **Historical compatibility**: legacy v1–v4 transactions remain replayable; activation does not rewrite chain history - **Spentness boundary**: public nodes cannot identify the real CLSAG ring member; wallets determine owned-output spentness from owner-derived key images - **Blind balancing**: transaction builder enforces `Σin_blinds = Σout_blinds + fee_blind`; unbalanced transactions are rejected at consensus diff --git a/deploy/publish_public_repo.py b/deploy/publish_public_repo.py index d1505e47..8e869d8d 100644 --- a/deploy/publish_public_repo.py +++ b/deploy/publish_public_repo.py @@ -40,7 +40,13 @@ def run(*args, cwd=None): - subprocess.run(list(args), cwd=cwd, check=True) + # 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} + subprocess.run(list(args), cwd=cwd, check=True, env=env) def prepare(root, candidate, gate_only=False): diff --git a/deploy/rollout_cleanup_installer_test.go b/deploy/rollout_cleanup_installer_test.go index eef0b593..08fd1dd7 100644 --- a/deploy/rollout_cleanup_installer_test.go +++ b/deploy/rollout_cleanup_installer_test.go @@ -48,7 +48,7 @@ func TestRolloutCleanupHooksStandaloneLayout(t *testing.T) { "aperod-rollout-cleanup.service", "aperod-rollout-cleanup.timer", "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/runtime.py", "rollout_cleanup/provider.py", "rollout_cleanup/retirement.py", } { content, err := os.ReadFile(name) if err != nil { diff --git a/deploy/test_publish_public_repo.py b/deploy/test_publish_public_repo.py index 34d9cd83..0b37113f 100644 --- a/deploy/test_publish_public_repo.py +++ b/deploy/test_publish_public_repo.py @@ -18,6 +18,15 @@ def fixture(root): class PublisherTests(unittest.TestCase): + def test_build_processes_do_not_receive_publication_credentials(self): + with patch.dict(publisher.os.environ, {"PUBLIC_GITHUB_TOKEN": "TEST_ONLY", + "SSH_PASSWORD": "TEST_ONLY"}), \ + 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) + def test_failed_required_check_never_merges(self): methods = [] def request(method, url, data=None): diff --git a/node.yaml b/node.yaml index d174eb34..2601dcb0 100644 --- a/node.yaml +++ b/node.yaml @@ -28,6 +28,10 @@ consensus: block_reward_napro: 300000000 # 0 = disabled. Set the same future height on every validator. reward_authorization_activation_height: 0 + # Guardian allocation hard fork remains disabled until an anchored validator + # rollout is approved. Both fields must be set together. + guardian_fund_activation_height: 0 + guardian_fund_chain_anchor: "" block_time: "3s" staking_pool_napro: 200000000000000000 tail_reward_napro: 100000000 diff --git a/store/wallet_read_snapshot.go b/store/wallet_read_snapshot.go index e0e5325d..d0a989db 100644 --- a/store/wallet_read_snapshot.go +++ b/store/wallet_read_snapshot.go @@ -114,6 +114,51 @@ func (s *WalletReadSnapshot) readCanonicalBlock(height uint64) (*core.Block, err return &block, nil } +// ReadCanonicalBlockBounded returns and validates a full canonical block from +// this pinned wallet snapshot. Header-only/pruned bodies and malformed bodies +// are errors; callers must not interpret them as empty blocks. +func (s *WalletReadSnapshot) ReadCanonicalBlockBounded(height uint64, maxBytes int) (*core.Block, error) { + block, _, err := s.ReadCanonicalBlockBoundedWithSize(height, maxBytes) + return block, err +} + +// ReadCanonicalBlockBoundedWithSize also reports the serialized body size so +// callers can enforce aggregate page decode budgets without charging the +// maximum per block. +func (s *WalletReadSnapshot) ReadCanonicalBlockBoundedWithSize(height uint64, maxBytes int) (*core.Block, int, error) { + if maxBytes < 1 { + return nil, 0, fmt.Errorf("invalid block decode limit") + } + hash, found, err := s.GetCanonicalHash(height) + if err != nil { + return nil, 0, err + } + if !found { + return nil, 0, fmt.Errorf("missing canonical block %d", height) + } + raw, err := s.rawBlock(hash) + if err != nil { + return nil, 0, fmt.Errorf("read canonical block %d: %w", height, err) + } + if len(raw) == 0 { + return nil, 0, fmt.Errorf("missing canonical block body at height %d", height) + } + if len(raw) > maxBytes { + return nil, 0, fmt.Errorf("canonical block %d exceeds decode limit", height) + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, 0, fmt.Errorf("decode canonical block %d: %w", height, err) + } + if block.Hash() != hash || block.Header.Height != height { + return nil, 0, fmt.Errorf("noncanonical block at height %d", height) + } + if block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + return nil, 0, fmt.Errorf("canonical block body is missing or malformed at height %d", height) + } + return &block, len(raw), nil +} + // LoadLPoDCheckpointAt loads and validates the checkpoint at a specific hash // using only data from this snapshot. func (s *WalletReadSnapshot) LoadLPoDCheckpointAt(hash crypto.Hash32) (*LPoDCheckpoint, error) { @@ -196,6 +241,123 @@ func (s *WalletReadSnapshot) LPoDWalletOutputs( return rows, next, iter.Error() } +// WalletReconciliationError indicates that a resumable wallet-index position +// cannot be safely interpreted against this pinned canonical view. +type WalletReconciliationError struct { + Height uint64 + Reason string +} + +func (e *WalletReconciliationError) Error() string { + return fmt.Sprintf("wallet reconciliation required at height %d: %s", e.Height, e.Reason) +} + +// WalletCursorReorgError reports that a previously issued native index cursor +// no longer points at the same canonical block. +type WalletCursorReorgError struct { + Height uint64 +} + +func (e *WalletCursorReorgError) Error() string { + return fmt.Sprintf("wallet resume cursor is noncanonical at height %d", e.Height) +} + +// LPoDWalletOutputsAfterHeight returns a bounded page through an immutable +// height ceiling. resumeCursor is the raw hex-encoded native address-index key +// from the prior page; unlike a snapshot cursor, it survives lease renewal. +func (s *WalletReadSnapshot) LPoDWalletOutputsAfterHeight( + address crypto.Address, + afterHeight uint64, + hasAfterHeight bool, + resumeCursor string, + throughHeight uint64, + limit int, +) ([]StoredUTXO, string, string, error) { + if limit < 1 || limit > 128 { + return nil, "", "", fmt.Errorf("invalid page limit") + } + prefix := lpodWalletPrefix(address) + iter := s.snapshot.NewIterator(util.BytesPrefix(prefix), nil) + defer iter.Release() + ok := iter.First() + if hasAfterHeight { + if afterHeight == ^uint64(0) { + return []StoredUTXO{}, "", "", nil + } + start := make([]byte, len(prefix)+76) + copy(start, prefix) + binary.BigEndian.PutUint64(start[len(prefix):len(prefix)+8], afterHeight+1) + ok = iter.Seek(start) + } + if resumeCursor != "" { + key, err := hex.DecodeString(resumeCursor) + if err != nil || !bytes.HasPrefix(key, prefix) || len(key) != len(prefix)+76 { + return nil, "", "", fmt.Errorf("invalid resume_cursor") + } + height := binary.BigEndian.Uint64(key[len(prefix) : len(prefix)+8]) + if height > throughHeight { + return nil, "", "", fmt.Errorf("resume_cursor is above through_height") + } + if hasAfterHeight && height <= afterHeight { + return nil, "", "", fmt.Errorf("resume_cursor is not after after_height") + } + canonical, found, err := s.GetCanonicalHash(height) + if err != nil { + return nil, "", "", err + } + if !found { + return nil, "", "", &WalletReconciliationError{Height: height, Reason: "canonical height index is missing"} + } + blockHashOffset := len(prefix) + 12 + if !bytes.Equal(key[blockHashOffset:blockHashOffset+32], canonical[:]) { + return nil, "", "", &WalletCursorReorgError{Height: height} + } + ok = iter.Seek(key) + if !ok || !bytes.Equal(iter.Key(), key) { + return nil, "", "", &WalletReconciliationError{Height: height, Reason: "resume cursor index row is missing"} + } + ok = iter.Next() + } + rows := make([]StoredUTXO, 0, limit) + lastExamined, next := "", "" + examined := 0 + for ok && examined < limit { + key := iter.Key() + if len(key) != len(prefix)+76 { + return nil, "", "", fmt.Errorf("malformed native wallet index key") + } + height := binary.BigEndian.Uint64(key[len(prefix) : len(prefix)+8]) + if height > throughHeight { + break + } + examined++ + lastExamined = hex.EncodeToString(key) + var output StoredUTXO + if err := json.Unmarshal(iter.Value(), &output); err != nil { + return nil, "", "", fmt.Errorf("decode native wallet output at height %d: %w", height, err) + } + spent, err := s.get(spentUTXOKey(output.TxHash, output.OutputIndex)) + if err != nil { + return nil, "", "", err + } + if spent == nil { + rows = append(rows, output) + } + ok = iter.Next() + } + if ok { + key := iter.Key() + if len(key) != len(prefix)+76 { + return nil, "", "", fmt.Errorf("malformed native wallet index key") + } + height := binary.BigEndian.Uint64(key[len(prefix) : len(prefix)+8]) + if height <= throughHeight { + next = lastExamined + } + } + return rows, next, lastExamined, iter.Error() +} + // GetUTXO retrieves an output from the captured active-output namespace. func (s *WalletReadSnapshot) GetUTXO(txHash crypto.Hash32, outIdx uint32) (*StoredUTXO, error) { data, err := s.get(utxoKey(txHash, outIdx)) diff --git a/store/wallet_read_snapshot_test.go b/store/wallet_read_snapshot_test.go index 835fcb75..2151d515 100644 --- a/store/wallet_read_snapshot_test.go +++ b/store/wallet_read_snapshot_test.go @@ -111,6 +111,176 @@ func TestWalletReadSnapshotPinsWalletReadsAndReleases(t *testing.T) { read.Release() } +func TestReadCanonicalBlockBoundedRejectsMissingAndMalformedBodies(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + block := &core.Block{Header: core.BlockHeader{ + Height: 0, MerkleRoot: core.MerkleRoot(nil), Timestamp: 1, + }} + hash := block.Hash() + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.put(append(append([]byte{}, prefixBlock...), hash[:]...), raw); err != nil { + t.Fatal(err) + } + if err := db.put(heightKey(0), hash[:]); err != nil { + t.Fatal(err) + } + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + got, err := read.ReadCanonicalBlockBounded(0, 1024) + if err != nil || got.Header.Height != 0 { + t.Fatalf("read valid empty canonical block: block=%+v err=%v", got, err) + } + if _, err := read.ReadCanonicalBlockBounded(0, 1); err == nil { + t.Fatal("accepted a canonical body above the decode-byte limit") + } + read.Release() + + missingHash := crypto.Hash32{1} + if err := db.put(heightKey(1), missingHash[:]); err != nil { + t.Fatal(err) + } + read, err = db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + if _, err := read.ReadCanonicalBlockBounded(1, 1024); err == nil { + t.Fatal("accepted a canonical height with no stored block body") + } + read.Release() + + bad := *block + bad.Txs = []core.Transaction{{Version: core.TxVersionBase}} + badRaw, err := json.Marshal(&bad) + if err != nil { + t.Fatal(err) + } + if err := db.put(append(append([]byte{}, prefixBlock...), hash[:]...), badRaw); err != nil { + t.Fatal(err) + } + read, err = db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + if _, err := read.ReadCanonicalBlockBounded(0, 1024); err == nil { + t.Fatal("accepted block body that does not match its committed Merkle root") + } + read.Release() +} + +func TestLPoDWalletOutputsResumeCursorSurvivesSnapshotRenewal(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + address := crypto.Address("resume-cursor-test") + var parent crypto.Hash32 + blockHashes := make([]crypto.Hash32, 3) + for height := uint64(0); height < 3; height++ { + block := &core.Block{Header: core.BlockHeader{ + Height: height, PrevHash: parent, MerkleRoot: core.MerkleRoot(nil), Timestamp: int64(height + 1), + }} + hash := block.Hash() + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.CommitRawBlockWithAVM(hash, height, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + parent, blockHashes[height] = hash, hash + } + for height, indexes := range map[uint64][]uint32{1: {0, 1}, 2: {0}} { + for _, index := range indexes { + txHash := crypto.HashBytes([]byte{byte(height), byte(index), 91}) + output := StoredUTXO{TxHash: txHash, OutputIndex: index, BlockHeight: height} + key := lpodWalletPrefix(address) + var suffix [12]byte + binary.BigEndian.PutUint64(suffix[:8], height) + binary.BigEndian.PutUint32(suffix[8:], index) + key = append(key, suffix[:]...) + key = append(key, blockHashes[height][:]...) + key = append(key, txHash[:]...) + raw, err := json.Marshal(output) + if err != nil { + t.Fatal(err) + } + if err := db.put(key, raw); err != nil { + t.Fatal(err) + } + if height == 1 && index == 0 { + if err := db.MarkUTXOSpent(txHash, index); err != nil { + t.Fatal(err) + } + } + } + } + + read, err := db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + rows, next, resume, err := read.LPoDWalletOutputsAfterHeight(address, 0, false, "", 2, 1) + read.Release() + if err != nil || len(rows) != 0 || next == "" || resume != next { + t.Fatalf("first page rows=%d next=%q resume=%q err=%v", len(rows), next, resume, err) + } + + // A new read snapshot models a renewed short lease. The native address key + // remains a valid continuation position independently of its old session. + read, err = db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + rows, next, resume, err = read.LPoDWalletOutputsAfterHeight(address, 0, false, resume, 2, 1) + if err != nil || len(rows) != 1 || rows[0].BlockHeight != 1 || rows[0].OutputIndex != 1 || next == "" { + read.Release() + t.Fatalf("renewed page rows=%+v next=%q resume=%q err=%v", rows, next, resume, err) + } + rows, _, _, err = read.LPoDWalletOutputsAfterHeight(address, 0, false, resume, 1, 10) + read.Release() + if err != nil || len(rows) != 0 { + t.Fatalf("through-height bound leaked later rows: rows=%+v err=%v", rows, err) + } + + otherHash := crypto.Hash32{99} + if err := db.db.Put(heightKey(1), otherHash[:], nil); err != nil { + t.Fatal(err) + } + read, err = db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + _, _, _, err = read.LPoDWalletOutputsAfterHeight(address, 0, false, resume, 2, 1) + read.Release() + if _, ok := err.(*WalletCursorReorgError); !ok { + t.Fatalf("noncanonical resume cursor error=%v, want WalletCursorReorgError", err) + } + + if err := db.db.Delete(heightKey(1), nil); err != nil { + t.Fatal(err) + } + read, err = db.NewWalletReadSnapshot() + if err != nil { + t.Fatal(err) + } + _, _, _, err = read.LPoDWalletOutputsAfterHeight(address, 0, false, resume, 2, 1) + read.Release() + if _, ok := err.(*WalletReconciliationError); !ok { + t.Fatalf("missing canonical cursor height error=%v, want WalletReconciliationError", err) + } +} + func TestWalletReadSnapshotCheckpointBudgetBeforeDecode(t *testing.T) { db, err := Open(t.TempDir()) if err != nil {