Skip to content

Commit 12b6502

Browse files
committed
gRPC: add timestamp to chain tip in rpc query responses
1 parent dcc6988 commit 12b6502

10 files changed

Lines changed: 204 additions & 8 deletions

File tree

.claude/skills/build-fix/SKILL.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
name: build-fix
3+
description: Build a cabal component with nix, parse GHC errors and warnings, fix them, and rebuild until clean.
4+
disable-model-invocation: true
5+
argument-hint: [nix-flake-target]
6+
---
7+
8+
Build and iteratively fix GHC errors/warnings for the given nix flake target.
9+
10+
If no argument is given, default to `'.#cardano-rpc:lib:cardano-rpc'`.
11+
12+
Target: $ARGUMENTS
13+
14+
## Procedure
15+
16+
1. Run `nix build '<target>' 2>&1` and capture the output.
17+
2. Parse the output for GHC errors and warnings.
18+
3. If there are errors, fix them one category at a time:
19+
- **Missing imports**: Look up which module exports the symbol. Remember that RIO does NOT re-export everything from Prelude/Data.List (e.g. `sortBy`, `on` need explicit imports).
20+
- **Type mismatches**: Analyze carefully. In cardano-rpc, remember that `Proto msg` is a grapesy wrapper — internal functions use plain proto-lens types, `getProto`/`fmap getProto` at handler boundaries only.
21+
- **Not in scope**: Check if it's a missing import or a typo.
22+
- **Redundant constraints**: Remove them.
23+
- **Redundant imports**: Remove them.
24+
- **Deprecated functions**: Replace with the recommended alternative (e.g. `valueToList` -> `toList` from `GHC.IsList`).
25+
4. After fixing, rebuild.
26+
5. Repeat until the build succeeds with no errors.
27+
6. If there are warnings remaining, fix them too:
28+
- Redundant imports/constraints: remove
29+
- hlint-style suggestions: apply (e.g. lambda to infix)
30+
- Deprecated usage: replace
31+
7. Rebuild one final time to confirm clean output.
32+
33+
## Important rules
34+
- NEVER manually edit files under `gen/` — those are generated by proto-lens.
35+
- If proto generated code needs updating, use `/proto-gen` instead.
36+
- RIO's `^.` works with proto-lens lenses. Do NOT add `lens-family` as a dependency.
37+
- Verify fixes carefully before rebuilding to minimize nix build round-trips (each takes minutes).

.claude/skills/proto-gen/SKILL.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
name: proto-gen
3+
description: Regenerate proto-lens Haskell code from .proto files using buf in the nix dev shell.
4+
disable-model-invocation: true
5+
argument-hint: [package-dir]
6+
---
7+
8+
Regenerate proto-lens code from .proto files.
9+
10+
If no argument is given, default to `cardano-rpc`.
11+
12+
Package directory: $ARGUMENTS
13+
14+
## Procedure
15+
16+
1. Run: `nix develop --command bash -c "cd <package-dir> && buf generate proto"`
17+
2. Verify the command succeeded.
18+
3. Show a summary of which files were regenerated (check git status for changed files under `<package-dir>/gen/`).
19+
20+
## Important rules
21+
- NEVER manually edit files under `gen/` — they are overwritten by this command.
22+
- If buf or proto-lens-protoc are not found, it means you're not in the nix dev shell — always use `nix develop --command`.
23+
- After regeneration, you may need to run `/build-fix` to ensure everything compiles.

.claude/skills/rio-check/SKILL.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
name: rio-check
3+
description: Check if a Haskell function or type is re-exported by the RIO module.
4+
disable-model-invocation: true
5+
argument-hint: [symbol-name]
6+
---
7+
8+
Check whether the symbol `$ARGUMENTS` is re-exported by RIO.
9+
10+
## Procedure
11+
12+
1. Find the RIO package source in the nix store by searching for the RIO module file:
13+
```
14+
find /nix/store -path '*/RIO.hs' -name 'RIO.hs' 2>/dev/null | head -5
15+
```
16+
Or search the project's dependency tree:
17+
```
18+
grep -r 'module RIO' $(nix build '.#cardano-rpc:lib:cardano-rpc' --print-out-paths 2>/dev/null)/lib/ 2>/dev/null
19+
```
20+
2. Search for the symbol in RIO's module exports and re-exports.
21+
3. Report:
22+
- Whether the symbol is available from RIO
23+
- If NOT available, which module to import it from (e.g. `Data.List`, `Data.Map`, etc.)
24+
- Whether RIO hides it (some symbols are explicitly hidden, like `toList`)
25+
26+
## Known RIO gaps (common gotchas)
27+
- `sortBy` — NOT in RIO, import from `Data.List`
28+
- `on` — NOT in RIO, import from `Data.Function`
29+
- `toList` — RIO re-exports from GHC.Exts but some modules hide it; use `GHC.IsList` or import explicitly
30+
- `sortOn`, `sort` — available in RIO

cardano-api/src/Cardano/Api/Internal/Orphans/Misc.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import Cardano.Ledger.HKD (NoUpdate (..))
3131
import Cardano.Ledger.Plutus.Language qualified as L
3232
import Cardano.Ledger.Shelley.PParams qualified as Ledger
3333
import Ouroboros.Consensus.Cardano.Block (EraMismatch (..))
34+
import Ouroboros.Consensus.HardFork.History.Qry (PastHorizonException)
3435
import PlutusLedgerApi.Common qualified as P
3536

3637
import Codec.Binary.Bech32 qualified as Bech32
@@ -299,6 +300,9 @@ instance Error Byron.GenesisDataGenerationError where
299300
instance Error P.ParseError where
300301
prettyError = pretty . show
301302

303+
instance Error PastHorizonException where
304+
prettyError = pretty . show
305+
302306
deriving via ShowOf TypeRep instance Pretty TypeRep
303307

304308
instance TestEquality L.SLanguage where

cardano-api/src/Cardano/Api/Query.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module Cardano.Api.Query
3737
, LedgerState (..)
3838
, getProgress
3939
, getSlotForRelativeTime
40+
, slotToUTCTime
4041
, decodeBigLedgerPeerSnapshot
4142

4243
-- * Convenience functions

cardano-api/src/Cardano/Api/Query/Internal/Type/QueryInMode.hs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ module Cardano.Api.Query.Internal.Type.QueryInMode
5757
, LedgerState (..)
5858
, getProgress
5959
, getSlotForRelativeTime
60+
, slotToUTCTime
6061
, decodeBigLedgerPeerSnapshot
6162

6263
-- * Internal conversion functions
@@ -100,7 +101,7 @@ import Cardano.Ledger.Shelley.API qualified as Shelley
100101
import Cardano.Ledger.Shelley.Core qualified as Core
101102
import Cardano.Slotting.EpochInfo (hoistEpochInfo)
102103
import Cardano.Slotting.Slot (WithOrigin (..))
103-
import Cardano.Slotting.Time (SystemStart (..))
104+
import Cardano.Slotting.Time (SystemStart (..), fromRelativeTime)
104105
import Ouroboros.Consensus.BlockchainTime.WallClock.Types (RelativeTime, SlotLength)
105106
import Ouroboros.Consensus.Byron.Ledger qualified as Consensus
106107
import Ouroboros.Consensus.Cardano.Block (LedgerState (..), StandardCrypto)
@@ -134,6 +135,7 @@ import Data.Set qualified as Set
134135
import Data.Singletons qualified as Singletons
135136
import Data.Text (Text)
136137
import Data.Text qualified as Text
138+
import Data.Time (UTCTime)
137139
import Data.Word (Word64)
138140
import GHC.Exts (IsList (..))
139141
import GHC.Stack
@@ -204,6 +206,17 @@ getSlotForRelativeTime relTime (EraHistory interpreter) = do
204206
(slotNo, _, _) <- Qry.interpretQuery interpreter $ Qry.wallclockToSlot relTime
205207
pure slotNo
206208

209+
-- | Convert a 'SlotNo' to a 'UTCTime' given the 'SystemStart' and 'EraHistory'.
210+
slotToUTCTime
211+
:: ()
212+
=> SystemStart
213+
-> EraHistory
214+
-> SlotNo
215+
-> Either Qry.PastHorizonException UTCTime
216+
slotToUTCTime systemStart eraHistory slotNo = do
217+
(relTime, _slotLen) <- getProgress slotNo eraHistory
218+
pure $ fromRelativeTime systemStart relTime
219+
207220
newtype LedgerEpochInfo = LedgerEpochInfo {unLedgerEpochInfo :: Consensus.EpochInfo (Either Text)}
208221

209222
toLedgerEpochInfo

cardano-rpc/cardano-rpc.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ library
9191
random,
9292
rio,
9393
text,
94+
time,
9495

9596
-- this should be replaced by utxorpc package from hackage
9697
-- ideally we should upstream whatever is implemented in Proto.Cardano.Rpc.Node

cardano-rpc/src/Cardano/Rpc/Server/Internal/UtxoRpc/Query.hs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
{-# LANGUAGE DerivingVia #-}
44
{-# LANGUAGE FlexibleContexts #-}
55
{-# LANGUAGE GADTs #-}
6+
{-# LANGUAGE LambdaCase #-}
67
{-# LANGUAGE QuantifiedConstraints #-}
78
{-# LANGUAGE RankNTypes #-}
89
{-# LANGUAGE ScopedTypeVariables #-}
@@ -27,6 +28,7 @@ import RIO hiding (toList)
2728

2829
import Data.Default
2930
import Data.ProtoLens (defMessage)
31+
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
3032
import GHC.IsList
3133
import Network.GRPC.Spec
3234

@@ -44,15 +46,19 @@ readParamsMethod _req = do
4446
let sbe = convert eon
4547

4648
let target = VolatileTip
47-
(pparams, chainPoint, blockNo) <- liftIO . (throwEither =<<) $ executeLocalStateQueryExpr nodeConnInfo target $ do
49+
(pparams, chainPoint, blockNo, systemStart, eraHistory) <- liftIO . (throwEither =<<) $ executeLocalStateQueryExpr nodeConnInfo target $ do
4850
pparams <- throwEither =<< throwEither =<< queryProtocolParameters sbe
4951
chainPoint <- throwEither =<< queryChainPoint
5052
blockNo <- throwEither =<< queryChainBlockNo
51-
pure (pparams, chainPoint, blockNo)
53+
systemStart <- throwEither =<< querySystemStart
54+
eraHistory <- throwEither =<< queryEraHistory
55+
pure (pparams, chainPoint, blockNo, systemStart, eraHistory)
56+
57+
timestamp <- slotToTimestampMs systemStart eraHistory chainPoint
5258

5359
pure $
5460
def
55-
& U5c.ledgerTip .~ mkChainPointMsg chainPoint blockNo
61+
& U5c.ledgerTip .~ mkChainPointMsg chainPoint blockNo timestamp
5662
& U5c.values . U5c.cardano .~ obtainCommonConstraints eon (protocolParamsToUtxoRpcPParams eon pparams)
5763

5864
readUtxosMethod
@@ -73,15 +79,19 @@ readUtxosMethod req = do
7379
eon <- forEraInEon @Era era (error "Minimum Conway era required") pure
7480

7581
let target = VolatileTip
76-
(utxo, chainPoint, blockNo) <- liftIO . (throwEither =<<) $ executeLocalStateQueryExpr nodeConnInfo target $ do
82+
(utxo, chainPoint, blockNo, systemStart, eraHistory) <- liftIO . (throwEither =<<) $ executeLocalStateQueryExpr nodeConnInfo target $ do
7783
utxo <- throwEither =<< throwEither =<< queryUtxo (convert eon) utxoFilter
7884
chainPoint <- throwEither =<< queryChainPoint
7985
blockNo <- throwEither =<< queryChainBlockNo
80-
pure (utxo, chainPoint, blockNo)
86+
systemStart <- throwEither =<< querySystemStart
87+
eraHistory <- throwEither =<< queryEraHistory
88+
pure (utxo, chainPoint, blockNo, systemStart, eraHistory)
89+
90+
timestamp <- slotToTimestampMs systemStart eraHistory chainPoint
8191

8292
pure $
8393
defMessage
84-
& U5c.ledgerTip .~ mkChainPointMsg chainPoint blockNo
94+
& U5c.ledgerTip .~ mkChainPointMsg chainPoint blockNo timestamp
8595
& U5c.items .~ obtainCommonConstraints eon (utxoToUtxoRpcAnyUtxoData utxo)
8696
where
8797
txoRefToTxIn :: MonadRpc e m => Proto UtxoRpc.TxoRef -> m TxIn
@@ -93,3 +103,14 @@ readUtxosMethod req = do
93103
-- readAddress :: MonadRpc e m => ByteString -> m AddressAny
94104
-- readAddress =
95105
-- throwEither . first stringException . P.runParser parseAddressAny <=< throwEither . T.decodeUtf8'
106+
107+
slotToTimestampMs
108+
:: HasCallStack
109+
=> MonadIO m
110+
=> SystemStart -> EraHistory -> ChainPoint -> m Word64
111+
slotToTimestampMs systemStart eraHistory =
112+
fmap (round . (* 1000) . utcTimeToPOSIXSeconds) . \case
113+
ChainPointAtGenesis ->
114+
let SystemStart t = systemStart in pure t
115+
ChainPoint slotNo _ ->
116+
throwEither $ slotToUTCTime systemStart eraHistory slotNo

cardano-rpc/src/Cardano/Rpc/Server/Internal/UtxoRpc/Type.hs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module Cardano.Rpc.Server.Internal.UtxoRpc.Type
1919
, simpleScriptToUtxoRpcNativeScript
2020
, utxoRpcBigIntToInteger
2121
, mkChainPointMsg
22+
, utxoRpcChainPointMsgToChainPoint
2223
)
2324
where
2425

@@ -324,8 +325,10 @@ utxoRpcPParamsToProtocolParams era pp = conwayEraOnwardsConstraints (convert era
324325
mkChainPointMsg
325326
:: ChainPoint
326327
-> WithOrigin BlockNo
328+
-> Word64
329+
-- ^ timestamp in milliseconds
327330
-> Proto UtxoRpc.ChainPoint
328-
mkChainPointMsg chainPoint blockNo = do
331+
mkChainPointMsg chainPoint blockNo timestamp = do
329332
let (slotNo, blockHash) = case chainPoint of
330333
ChainPointAtGenesis -> (0, mempty)
331334
ChainPoint (SlotNo slot) (HeaderHash hash) -> (slot, SBS.fromShort hash)
@@ -336,6 +339,30 @@ mkChainPointMsg chainPoint blockNo = do
336339
& U5c.slot .~ slotNo
337340
& U5c.hash .~ blockHash
338341
& U5c.height .~ blockHeight
342+
& U5c.timestamp .~ timestamp
343+
344+
-- | Inverse of 'mkChainPointMsg'. Note: @Origin@ and @At (BlockNo 0)@ both
345+
-- encode to @height=0@, so the decode always maps @0@ back to @Origin@.
346+
utxoRpcChainPointMsgToChainPoint
347+
:: HasCallStack
348+
=> MonadThrow m
349+
=> Proto UtxoRpc.ChainPoint
350+
-> m (ChainPoint, WithOrigin BlockNo, Word64)
351+
utxoRpcChainPointMsgToChainPoint msg = do
352+
let slot = msg ^. U5c.slot
353+
blockHash = msg ^. U5c.hash
354+
blockHeight = msg ^. U5c.height
355+
timestampMs = msg ^. U5c.timestamp
356+
chainPoint <-
357+
if slot == 0 && blockHash == mempty
358+
then pure ChainPointAtGenesis
359+
else do
360+
headerHash <- liftEitherError $ deserialiseFromRawBytes (AsHash asType) blockHash
361+
pure $ ChainPoint (SlotNo slot) headerHash
362+
let blockNo
363+
| blockHeight == 0 = Origin
364+
| otherwise = At (BlockNo blockHeight)
365+
pure (chainPoint, blockNo, timestampMs)
339366

340367
simpleScriptToUtxoRpcNativeScript :: SimpleScript -> Proto UtxoRpc.NativeScript
341368
simpleScriptToUtxoRpcNativeScript = \case

cardano-rpc/test/cardano-rpc-test/Test/Cardano/Rpc/Type.hs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44

55
module Test.Cardano.Rpc.Type where
66

7+
import Cardano.Api (BlockNo (..), ChainPoint (..), SlotNo (..))
78
import Cardano.Api.Experimental.Era
89
import Cardano.Rpc.Server.Internal.UtxoRpc.Type
910

11+
import Cardano.Ledger.BaseTypes (WithOrigin (..))
12+
1013
import RIO
1114

15+
import Test.Gen.Cardano.Api.Typed (genBlockHeaderHash)
16+
1217
import Hedgehog as H
1318
import Hedgehog.Extras qualified as H
1419
import Hedgehog.Gen qualified as H
@@ -28,6 +33,40 @@ hprop_roundtrip_bigint = H.property $ do
2833
H.note_ "Check that BigInt -> Integer -> BigInt preserves the value"
2934
bigInt === bigInt'
3035

36+
-- | Test that ChainPoint protobuf message roundtrips, including the timestamp field.
37+
-- Note: @At (BlockNo 0)@ is excluded because it encodes identically to @Origin@.
38+
hprop_roundtrip_chain_point_msg :: Property
39+
hprop_roundtrip_chain_point_msg = H.property $ do
40+
chainPoint <- forAll genChainPoint
41+
blockNo <- forAll genWithOriginBlockNo
42+
timestamp <- forAll $ H.word64 (H.linearFrom 0 0 maxBound)
43+
44+
H.tripping
45+
(chainPoint, blockNo, timestamp)
46+
(uncurry3 mkChainPointMsg)
47+
(first @Either displayException . utxoRpcChainPointMsgToChainPoint)
48+
where
49+
uncurry3 f (a, b, c) = f a b c
50+
51+
-- | Generate a 'ChainPoint', avoiding @ChainPoint (SlotNo 0) hash@ which is
52+
-- ambiguous with 'ChainPointAtGenesis' after protobuf encoding (both map to
53+
-- @slot=0, hash=empty@).
54+
genChainPoint :: Gen ChainPoint
55+
genChainPoint =
56+
H.choice
57+
[ pure ChainPointAtGenesis
58+
, ChainPoint . SlotNo <$> H.word64 (H.linear 1 maxBound) <*> genBlockHeaderHash
59+
]
60+
61+
-- | Generate a @WithOrigin BlockNo@, excluding @At (BlockNo 0)@ which is
62+
-- ambiguous with 'Origin' after protobuf encoding (both map to @height=0@).
63+
genWithOriginBlockNo :: Gen (WithOrigin BlockNo)
64+
genWithOriginBlockNo =
65+
H.choice
66+
[ pure Origin
67+
, At . BlockNo <$> H.word64 (H.linear 1 maxBound)
68+
]
69+
3170
-- generate integer for each of the BigInt proto type constructors
3271
genLargeInteger :: Gen Integer
3372
genLargeInteger =

0 commit comments

Comments
 (0)