diff --git a/db.go b/db.go index 9dd8f39..ef41b53 100644 --- a/db.go +++ b/db.go @@ -506,6 +506,25 @@ func (s *PgStore) GetNonceValuesForEpoch(ctx context.Context, epoch int) ([][]by return values, rows.Err() } +func (s *PgStore) GetVrfOutputsForEpoch(ctx context.Context, epoch int) ([]VrfBlock, error) { + rows, err := s.pool.Query(ctx, + `SELECT epoch, vrf_output FROM blocks WHERE epoch = $1 ORDER BY slot`, epoch) + if err != nil { + return nil, err + } + defer rows.Close() + + var blocks []VrfBlock + for rows.Next() { + var b VrfBlock + if err := rows.Scan(&b.Epoch, &b.VrfOutput); err != nil { + return nil, err + } + blocks = append(blocks, b) + } + return blocks, rows.Err() +} + func (s *PgStore) GetCandidateNonce(ctx context.Context, epoch int) ([]byte, error) { var nonce []byte err := s.pool.QueryRow(ctx, @@ -526,6 +545,19 @@ func (s *PgStore) GetLastBlockHashForEpoch(ctx context.Context, epoch int) (stri return hash, nil } +// GetPrevHashOfLastBlock returns the block hash of the second-to-last block +// in the given epoch. This is the prevHash of the last block, which is what +// the Cardano node uses for praosStateLabNonce (η_ph in the TICKN rule). +func (s *PgStore) GetPrevHashOfLastBlock(ctx context.Context, epoch int) (string, error) { + var hash string + err := s.pool.QueryRow(ctx, + `SELECT block_hash FROM blocks WHERE epoch = $1 ORDER BY slot DESC LIMIT 1 OFFSET 1`, epoch).Scan(&hash) + if err != nil { + return "", err + } + return hash, nil +} + func (s *PgStore) TruncateAll(ctx context.Context) error { _, err := s.pool.Exec(ctx, `TRUNCATE blocks, epoch_nonces, leader_schedules`) return err diff --git a/main.go b/main.go index 9d0efed..7b03c03 100644 --- a/main.go +++ b/main.go @@ -1271,10 +1271,8 @@ func (i *Indexer) checkLeaderlogTrigger(slot uint64) { stabilitySlot := epochStartSlot + StabilityWindowSlots(i.networkMagic) pastStability := slot >= stabilitySlot - // Freeze candidate nonce at stability window - if pastStability { - i.nonceTracker.FreezeCandidate(i.epoch) - } + // Candidate nonce is now frozen inside ProcessBlock (before nonce evolution) + // to match ComputeEpochNonce's behavior. No separate FreezeCandidate call needed. // Calculate leader schedule for next epoch after stability window // Skip during historical sync — nonces aren't available yet diff --git a/nonce.go b/nonce.go index 1eaeca3..8b65b47 100644 --- a/nonce.go +++ b/nonce.go @@ -172,6 +172,22 @@ func (nt *NonceTracker) ProcessBlock(slot uint64, epoch int, blockHash string, v return } + // Freeze candidate nonce at the stability window BEFORE evolving, + // matching ComputeEpochNonce and the Cardano node's behavior where + // η_c is the evolving nonce just before the first post-stability block. + if !nt.candidateFroze { + epochStart := GetEpochStartSlot(epoch, nt.networkMagic) + stabilitySlot := epochStart + StabilityWindowSlotsForEpoch(epoch, nt.networkMagic) + if slot >= stabilitySlot { + nt.candidateFroze = true + if storeErr := nt.store.SetCandidateNonce(ctx, epoch, nt.evolvingNonce); storeErr != nil { + log.Printf("Failed to freeze candidate nonce for epoch %d: %v", epoch, storeErr) + } else { + log.Printf("Froze candidate nonce for epoch %d (block count: %d)", epoch, nt.blockCount) + } + } + } + // Update evolving nonce nt.evolvingNonce = evolveNonce(nt.evolvingNonce, nonceValue) nt.blockCount++ @@ -270,52 +286,33 @@ func (nt *NonceTracker) RecomputeCurrentEpochNonce(ctx context.Context, epoch in log.Printf("RecomputeCurrentEpochNonce: no previous epoch nonce, using initial seed") } - // Stream nonce values for this epoch's blocks and re-evolve - nonceValues, err := nt.store.GetNonceValuesForEpoch(ctx, epoch) + // Stream raw VRF outputs for this epoch's blocks and recompute nonce values. + // Don't trust stored nonce_value — recompute from vrf_output for correctness. + vrfBlocks, err := nt.store.GetVrfOutputsForEpoch(ctx, epoch) if err != nil { return fmt.Errorf("querying blocks for epoch %d: %w", epoch, err) } - for _, nv := range nonceValues { - etaV = evolveNonce(etaV, nv) + for _, b := range vrfBlocks { + nonceValue := vrfNonceValueForEpoch(b.VrfOutput, b.Epoch, nt.networkMagic) + etaV = evolveNonce(etaV, nonceValue) } // Persist corrected nonce - if err := nt.store.UpsertEvolvingNonce(ctx, epoch, etaV, len(nonceValues)); err != nil { + if err := nt.store.UpsertEvolvingNonce(ctx, epoch, etaV, len(vrfBlocks)); err != nil { return fmt.Errorf("persisting recomputed nonce for epoch %d: %w", epoch, err) } // Update in-memory state nt.evolvingNonce = etaV nt.currentEpoch = epoch - nt.blockCount = len(nonceValues) + nt.blockCount = len(vrfBlocks) nt.candidateFroze = false - log.Printf("RecomputeCurrentEpochNonce: epoch %d recomputed from %d blocks", epoch, len(nonceValues)) + log.Printf("RecomputeCurrentEpochNonce: epoch %d recomputed from %d blocks (raw VRF)", epoch, len(vrfBlocks)) return nil } -// FreezeCandidate freezes the candidate nonce at the stability window. -func (nt *NonceTracker) FreezeCandidate(epoch int) { - nt.mu.Lock() - defer nt.mu.Unlock() - - if nt.candidateFroze || epoch != nt.currentEpoch { - return - } - - nt.candidateFroze = true - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := nt.store.SetCandidateNonce(ctx, epoch, nt.evolvingNonce); err != nil { - log.Printf("Failed to freeze candidate nonce for epoch %d: %v", epoch, err) - } else { - log.Printf("Froze candidate nonce for epoch %d (block count: %d)", epoch, nt.blockCount) - } -} - // GetNonceForEpoch returns the epoch nonce. Priority: // 1. Local DB final_nonce cache // 2. Compute from chain data (full mode only) @@ -359,11 +356,14 @@ func (nt *NonceTracker) GetNonceForEpoch(epoch int) ([]byte, error) { // Full mode: compute next epoch nonce from frozen candidate + η_ph (TICKN rule). // At 60% of epoch N, we need epoch N+1's nonce (not yet on Koios). - // epochNonce = BLAKE2b-256(candidateNonce_N || lastBlockHash_of_epoch_N-1) + // epochNonce(N+1) = BLAKE2b-256(η_c(N) || η_ph) + // where η_ph = praosStateLastEpochBlockNonce = prevHash of the last block + // of epoch N-1 = hash of the second-to-last block of epoch N-1. if nt.fullMode { candidateEpoch := epoch - 1 + etaPhEpoch := candidateEpoch - 1 log.Printf("TICKN: attempting to compute epoch %d nonce from candidate(%d) + η_ph(%d)", - epoch, candidateEpoch, candidateEpoch-1) + epoch, candidateEpoch, etaPhEpoch) candidate, candErr := nt.store.GetCandidateNonce(ctx, candidateEpoch) if candErr != nil { log.Printf("TICKN: GetCandidateNonce(%d) failed: %v", candidateEpoch, candErr) @@ -371,19 +371,20 @@ func (nt *NonceTracker) GetNonceForEpoch(epoch int) ([]byte, error) { log.Printf("TICKN: GetCandidateNonce(%d) returned nil", candidateEpoch) } else { log.Printf("TICKN: got candidate for epoch %d: %s", candidateEpoch, hex.EncodeToString(candidate)) - // Try DB first, fall back to Koios blocks API for η_ph - prevEpochHash, hashErr := nt.store.GetLastBlockHashForEpoch(ctx, candidateEpoch-1) - if hashErr != nil || prevEpochHash == "" { - log.Printf("TICKN: DB has no blocks for epoch %d, trying Koios", candidateEpoch-1) - prevEpochHash, hashErr = nt.fetchLastBlockHashFromKoios(ctx, candidateEpoch-1) + // η_ph = prevHash of the last block of etaPhEpoch = hash of second-to-last block. + // This matches how ComputeEpochNonce tracks labNonce via prevBlockHash. + etaPh, hashErr := nt.store.GetPrevHashOfLastBlock(ctx, etaPhEpoch) + if hashErr != nil || etaPh == "" { + // Fallback: try GetLastBlockHashForEpoch from the epoch BEFORE etaPhEpoch. + // If the epoch only had 1 block, second-to-last doesn't exist. + // In that edge case the labNonce at the transition would be from an earlier epoch. + log.Printf("TICKN: no second-to-last block for epoch %d, trying Koios for full nonce", etaPhEpoch) } - if hashErr != nil { - log.Printf("TICKN: failed to get η_ph for epoch %d: %v", candidateEpoch-1, hashErr) - } else if prevEpochHash != "" { - hashBytes, _ := hex.DecodeString(prevEpochHash) + if hashErr == nil && etaPh != "" { + hashBytes, _ := hex.DecodeString(etaPh) nonce = hashConcat(candidate, hashBytes) log.Printf("Computed epoch %d nonce from candidate(%d) + η_ph(%d): %s", - epoch, candidateEpoch, candidateEpoch-1, hex.EncodeToString(nonce)) + epoch, candidateEpoch, etaPhEpoch, hex.EncodeToString(nonce)) if storeErr := nt.store.SetFinalNonce(ctx, epoch, nonce, "computed"); storeErr != nil { log.Printf("Failed to cache computed nonce for epoch %d: %v", epoch, storeErr) } diff --git a/store.go b/store.go index 276dc83..f815b08 100644 --- a/store.go +++ b/store.go @@ -59,8 +59,10 @@ type Store interface { GetLastNBlocks(ctx context.Context, n int) ([]BlockRecord, error) GetBlockCountForEpoch(ctx context.Context, epoch int) (int, error) GetNonceValuesForEpoch(ctx context.Context, epoch int) ([][]byte, error) + GetVrfOutputsForEpoch(ctx context.Context, epoch int) ([]VrfBlock, error) GetCandidateNonce(ctx context.Context, epoch int) ([]byte, error) GetLastBlockHashForEpoch(ctx context.Context, epoch int) (string, error) + GetPrevHashOfLastBlock(ctx context.Context, epoch int) (string, error) TruncateAll(ctx context.Context) error Close() error } @@ -72,6 +74,12 @@ type BlockRecord struct { BlockHash string } +// VrfBlock holds the raw VRF output and epoch for a block, used for nonce recomputation. +type VrfBlock struct { + Epoch int + VrfOutput []byte +} + // SqliteStore implements Store using SQLite via modernc.org/sqlite (pure Go, no CGO). type SqliteStore struct { db *sql.DB @@ -563,6 +571,25 @@ func (s *SqliteStore) GetNonceValuesForEpoch(ctx context.Context, epoch int) ([] return values, rows.Err() } +func (s *SqliteStore) GetVrfOutputsForEpoch(ctx context.Context, epoch int) ([]VrfBlock, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT epoch, vrf_output FROM blocks WHERE epoch = ? ORDER BY slot`, epoch) + if err != nil { + return nil, err + } + defer rows.Close() + + var blocks []VrfBlock + for rows.Next() { + var b VrfBlock + if err := rows.Scan(&b.Epoch, &b.VrfOutput); err != nil { + return nil, err + } + blocks = append(blocks, b) + } + return blocks, rows.Err() +} + func (s *SqliteStore) GetCandidateNonce(ctx context.Context, epoch int) ([]byte, error) { var nonce []byte err := s.db.QueryRowContext(ctx, @@ -583,6 +610,19 @@ func (s *SqliteStore) GetLastBlockHashForEpoch(ctx context.Context, epoch int) ( return hash, nil } +// GetPrevHashOfLastBlock returns the block hash of the second-to-last block +// in the given epoch. This is the prevHash of the last block, which is what +// the Cardano node uses for praosStateLabNonce (η_ph in the TICKN rule). +func (s *SqliteStore) GetPrevHashOfLastBlock(ctx context.Context, epoch int) (string, error) { + var hash string + err := s.db.QueryRowContext(ctx, + `SELECT block_hash FROM blocks WHERE epoch = ? ORDER BY slot DESC LIMIT 1 OFFSET 1`, epoch).Scan(&hash) + if err != nil { + return "", err + } + return hash, nil +} + func (s *SqliteStore) TruncateAll(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil {