smp-server: JSON wire fixups + spec rewrite + small cleanups

This commit is contained in:
sh
2026-05-29 16:02:28 +00:00
parent 6b216cad18
commit c812725461
7 changed files with 89 additions and 38 deletions
+27 -18
View File
@@ -1467,28 +1467,37 @@ out-of-band for operator observability.
#### Name record response
```abnf
name = %s"NAME" SP nameRecord
The `NAME` response carries a JSON-encoded record as the payload:
nameRecord = displayName owner channelLinks contactLinks adminAddr adminEmail expiry isTest
displayName = length *OCTET ; 1-byte length prefix, up to 255 bytes UTF-8
owner = 20OCTET ; raw 20-byte Ethereum-style address
channelLinks = count *nameLink ; count is a 1-byte unsigned integer
contactLinks = count *nameLink ; combined count of channelLinks + contactLinks ≤ 8
nameLink = length16 *OCTET ; 2-byte big-endian length, up to 1024 bytes UTF-8
adminAddr = optionalText ; "0" absent or "1" + 1-byte length + UTF-8 up to 255 bytes
adminEmail = optionalText ; same encoding as adminAddr
expiry = 8OCTET ; Int64 big-endian, Unix seconds, MUST be ≥ 0
isTest = "T" / "F"
```abnf
name = %s"NAME" SP json-bytes ; json-bytes consumes the remainder of the transmission
```
The encoding is canonical: every primitive has exactly one valid byte form, so
two names routers reading the same backing state produce byte-identical
responses.
`json-bytes` MUST be a UTF-8 JSON object with the following schema:
**Wire-size budget.** A maximal `nameRecord` (8 links × 1024 bytes + maximal
admin / display strings) fits comfortably within the SMP proxied transmission
budget of 16224 bytes.
| Field | JSON type | Constraints |
|---|---|---|
| `displayName` | string | ≤ 255 bytes UTF-8 |
| `owner` | string | `"0x"` followed by 40 lowercase hex characters (20 raw bytes) |
| `channelLinks` | array of strings | each ≤ 1024 bytes UTF-8; combined count of `channelLinks + contactLinks` ≤ 8 |
| `contactLinks` | array of strings | each ≤ 1024 bytes UTF-8; combined count cap shared with `channelLinks` |
| `adminAddress` | string or null | ≤ 255 bytes UTF-8; senders MUST emit `null` when unset; receivers MUST also accept absent keys as unset |
| `adminEmail` | string or null | ≤ 255 bytes UTF-8; senders MUST emit `null` when unset; receivers MUST also accept absent keys as unset |
| `expiry` | integer | Int64 Unix seconds, MUST be ≥ 0; `0` means "never expires" |
| `isTest` | boolean | true on testnet deployments |
Receivers MUST tolerate extra unknown fields (forward-compatibility for future
field additions). Adding a required field is a breaking change requiring an
SMP version bump.
**Canonical encoding.** Two names routers reading the same backing state and
producing the same `NameRecord` MUST emit byte-identical JSON: emit object
keys in the order listed above, integers without decimal points, no
insignificant whitespace.
**Wire-size budget.** A maximal `nameRecord` (8 × 1024-byte links plus
maximal admin / display strings) JSON-encodes to roughly 9 KB, well under the
SMP proxied transmission budget of 16224 bytes.
## Transport connection with the SMP router
+10 -7
View File
@@ -256,7 +256,7 @@ import Data.Kind
import Data.List (foldl')
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import Data.Maybe (isJust, isNothing)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
@@ -750,11 +750,11 @@ unNameOwner (NameOwner bs) = bs
instance J.ToJSON NameOwner where
toJSON (NameOwner bs) = J.String $ "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 bs)
toEncoding (NameOwner bs) = J.toEncoding $ "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 bs)
instance J.FromJSON NameOwner where
parseJSON = J.withText "NameOwner" $ \t -> do
let hex = maybe t id (T.stripPrefix "0x" t)
-- Accept "0x" and "0X" prefixes (matches Server/Main.hs:parseEthAddr via fromHex).
let hex = fromMaybe t (T.stripPrefix "0x" t <|> T.stripPrefix "0X" t)
case BAE.convertFromBase BAE.Base16 (encodeUtf8 hex) of
Left e -> fail e
Right bs -> either fail pure (mkNameOwner bs)
@@ -775,7 +775,6 @@ unNameLink (NameLink t) = t
instance J.ToJSON NameLink where
toJSON (NameLink t) = J.toJSON t
toEncoding (NameLink t) = J.toEncoding t
instance J.FromJSON NameLink where
parseJSON = J.withText "NameLink" (either fail pure . mkNameLink)
@@ -809,18 +808,22 @@ instance J.ToJSON NameRecord where
instance J.FromJSON NameRecord where
parseJSON = J.withObject "NameRecord" $ \o -> do
nrDisplayName <- o J..: "displayName"
nrDisplayName <- o J..: "displayName" >>= capUtf8 "displayName" 255
nrOwner <- o J..: "owner"
nrChannelLinks <- o J..: "channelLinks"
nrContactLinks <- o J..: "contactLinks"
when (length nrChannelLinks + length nrContactLinks > 8) $
fail "combined channelLinks + contactLinks > 8"
nrAdminAddress <- o J..:? "adminAddress"
nrAdminEmail <- o J..:? "adminEmail"
nrAdminAddress <- o J..:? "adminAddress" >>= traverse (capUtf8 "adminAddress" 255)
nrAdminEmail <- o J..:? "adminEmail" >>= traverse (capUtf8 "adminEmail" 255)
nrExpiry <- o J..: "expiry"
when (nrExpiry < 0) $ fail "expiry must be non-negative"
nrIsTest <- o J..: "isTest"
pure NameRecord {nrDisplayName, nrOwner, nrChannelLinks, nrContactLinks, nrAdminAddress, nrAdminEmail, nrExpiry, nrIsTest}
where
capUtf8 fld lim t
| B.length (encodeUtf8 t) <= lim = pure t
| otherwise = fail $ fld <> " exceeds " <> show lim <> " bytes UTF-8"
data BrokerMsg where
-- SMP broker messages (responses, client messages, notifications)
+6 -1
View File
@@ -841,7 +841,12 @@ validateUrl url auth_ = do
ua <- maybe (Left "missing authority (host)") Right (uriAuthority uri)
when (null (uriRegName ua)) $ Left "empty host"
unless (null (uriUserInfo ua)) $ Left "userinfo (user:pass@) not allowed; use rpc_auth instead"
when (null (uriPort ua)) $ Left "explicit port required (e.g. http://host:8545)"
case uriPort ua of
"" -> Left "explicit port required (e.g. http://host:8545)"
':' : portStr -> case readMaybe portStr of
Just n | n >= 1 && n <= 65535 -> Right ()
_ -> Left $ "port " <> portStr <> " out of range (must be 1..65535)"
other -> Left $ "unexpected port syntax: " <> other
unless (null (uriQuery uri)) $ Left "query string not allowed"
unless (null (uriFragment uri)) $ Left "fragment not allowed"
let path = uriPath uri
+3 -2
View File
@@ -158,8 +158,9 @@ iniFileContent cfgPath logPath opts host basicAuth controlPortPwds =
\[NAMES]\n\
\# Public-namespace resolution (SNRC on Ethereum).\n\
\# Requires an Ethereum JSON-RPC endpoint (Reth+Nimbus). See deployment guide.\n\
\# Co-locating with the proxy role logs a warning at startup - slow RSLV cache misses\n\
\# can serialise other forwarded commands. For high-volume deployments, run on a separate host.\n\
\# Co-locating with the proxy role logs a startup advisory: slow RSLV calls can\n\
\# serialise other forwarded commands on the same proxy-relay session.\n\
\# For high-volume deployments, run [NAMES] on a separate host.\n\
\# Restart required to change settings.\n\
\enable: off\n\
\# Same-host:\n\
+31 -5
View File
@@ -24,16 +24,18 @@ module Simplex.Messaging.Server.Names
)
where
import Control.Monad (when, unless)
import qualified Control.Exception as E
import Control.Logger.Simple (logError)
import Data.ByteString.Char8 (ByteString)
import Data.IORef (IORef, atomicModifyIORef', newIORef)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock.POSIX (getPOSIXTime)
import Simplex.Messaging.Protocol (NameOwner, NameRecord (..), unNameOwner)
import Simplex.Messaging.Server.Names.Eth.RPC (EthRpcEnv, EthRpcError (..), RpcAuth (..), closeEthRpcEnv, ethCallReal, newEthRpcEnv)
import Simplex.Messaging.Server.Names.Eth.SNRC (decodeGetRecord, encodeGetRecord, namehash)
import Simplex.Messaging.Server.Names.Eth.SNRC (decodeAddress, decodeGetRecord, encodeGetRecord, isZeroOwner, namehash)
import System.Timeout (timeout)
data NamesConfig = NamesConfig
@@ -61,7 +63,10 @@ type EthCall = ByteString -> ByteString -> IO (Either EthRpcError ByteString)
data NamesEnv = NamesEnv
{ config :: NamesConfig,
ethCall :: EthCall,
rpcEnv :: Maybe EthRpcEnv -- Nothing for test stubs
rpcEnv :: Maybe EthRpcEnv, -- Nothing for test stubs
-- One-shot guard so the placeholder-decoder warning logs once per process,
-- not once per RSLV.
placeholderWarned :: IORef Bool
}
newNamesEnv :: NamesConfig -> IO NamesEnv
@@ -71,7 +76,9 @@ newNamesEnv cfg = do
-- | Allocate resolver with an injected ethCall (test seam).
newNamesEnvWith :: NamesConfig -> EthCall -> Maybe EthRpcEnv -> IO NamesEnv
newNamesEnvWith config ethCall rpcEnv = pure NamesEnv {config, ethCall, rpcEnv}
newNamesEnvWith config ethCall rpcEnv = do
placeholderWarned <- newIORef False
pure NamesEnv {config, ethCall, rpcEnv, placeholderWarned}
closeNamesEnv :: NamesEnv -> IO ()
closeNamesEnv NamesEnv {rpcEnv} = mapM_ closeEthRpcEnv rpcEnv
@@ -101,14 +108,25 @@ resolveName env key = do
pure (Left EthHttpErr)
fetch :: NamesEnv -> ByteString -> IO (Either ResolveError NameRecord)
fetch NamesEnv {ethCall, config} key =
fetch env@NamesEnv {ethCall, config} key =
ethCall (unNameOwner (snrcAddress config)) (encodeGetRecord (namehash key)) >>= \case
Left e -> pure (Left (mapEthRpcError e))
Right ret -> case decodeGetRecord ret of
Right Nothing -> pure (Left NotFound)
Right Nothing -> notFoundWithPlaceholderWarn ret
Right (Just rec) -> checkExpiry rec
Left _ -> pure (Left EthDecodeErr)
where
-- decodeGetRecord is currently a placeholder: it returns Right Nothing
-- for BOTH "zero-owner sentinel" (real NotFound) and "non-zero owner
-- with real data but no ABI decoder yet". Inspect the owner slot
-- directly to distinguish, and surface the latter once per process so
-- an operator who enables [NAMES] against a working SNRC contract sees
-- the resolver is functionally stubbed.
notFoundWithPlaceholderWarn ret = do
case decodeAddress 32 ret of
Right owner -> unless (isZeroOwner owner) (warnPlaceholderOnce env)
Left _ -> pure ()
pure (Left NotFound)
-- Defense in depth: the SNRC contract should already return the
-- zero-owner sentinel for expired records, but a buggy / pre-upgrade
-- contract might not. nrExpiry == 0 means "never expires" (reserved
@@ -119,6 +137,14 @@ fetch NamesEnv {ethCall, config} key =
then Left NotFound
else Right rec
warnPlaceholderOnce :: NamesEnv -> IO ()
warnPlaceholderOnce NamesEnv {placeholderWarned} = do
first <- atomicModifyIORef' placeholderWarned (\w -> (True, not w))
when first $
logError
"[NAMES] decodeGetRecord placeholder hit — SNRC ABI codec not finalised; \
\every non-zero-owner record returns NotFound until the decoder ships"
-- | Collapse the JSON-RPC transport-layer error space into the resolver's
-- public error space.
mapEthRpcError :: EthRpcError -> ResolveError
@@ -35,6 +35,7 @@ module Simplex.Messaging.Server.Names.Eth.SNRC
decodeString,
decodeUtf8Text,
decodeStringArray,
isZeroOwner,
)
where
+11 -5
View File
@@ -17,6 +17,7 @@ import qualified Data.Aeson as J
import qualified Data.ByteString.Lazy as LB
import Simplex.Messaging.Protocol
( LookupKey (..),
NameOwner,
NameRecord (..),
mkNameLink,
mkNameOwner,
@@ -41,8 +42,6 @@ import Simplex.Messaging.Server.Names
newNamesEnvWith,
resolveName,
)
import Simplex.Messaging.Transport (VersionSMP)
import Simplex.Messaging.Version.Internal (Version (..))
import Test.Hspec
-- Reference vectors:
@@ -63,9 +62,6 @@ sha3_256Abc = "\x3a\x98\x5d\xa7\x4f\xe2\x25\xb2\x04\x5c\x17\x2d\x6b\xd3\x90\xbd\
namehashEth :: ByteString
namehashEth = "\x93\xcd\xeb\x70\x8b\x75\x45\xdc\x66\x8e\xb9\x28\x01\x76\x16\x9d\x1c\x33\xcf\xd8\xed\x6f\x04\x69\x0a\x0b\xcc\x88\xa9\x3f\xc4\xae"
v20 :: VersionSMP
v20 = Version 20
twentyOnes :: ByteString
twentyOnes = B.replicate 20 '\x01'
@@ -109,6 +105,16 @@ nameRecordEncodingSpec = do
bytes = LB.toStrict (J.encode overflow)
(J.eitherDecodeStrict bytes :: Either String NameRecord) `shouldSatisfy` isLeft
it "rejects nrDisplayName > 255 bytes UTF-8" $ do
let oversize = sampleRecord {nrDisplayName = T.replicate 256 "x"}
bytes = LB.toStrict (J.encode oversize)
(J.eitherDecodeStrict bytes :: Either String NameRecord) `shouldSatisfy` isLeft
it "FromJSON NameOwner accepts both 0x and 0X prefixes" $ do
let json p = "\"" <> p <> "0101010101010101010101010101010101010101\""
(J.eitherDecodeStrict (json "0x") :: Either String NameOwner) `shouldSatisfy` isRight
(J.eitherDecodeStrict (json "0X") :: Either String NameOwner) `shouldSatisfy` isRight
it "encodes within the proxied transmission budget" $ do
let huge = either error id (mkNameLink (T.replicate 1024 "x"))
wide =