mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-07-02 00:31:57 +00:00
smp-server: JSON wire fixups + spec rewrite + small cleanups
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user