mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-03 00:12:55 +00:00
store: relay owner-signed record edits
This commit is contained in:
1 parent
c03bf3f80a
commit
ac4cbce36a
7 files changed
+1048
-4
No files matched your search
@@ -11,6 +11,7 @@ import StoreService.Names.Chain (ChainClient, newChainClient)
|
||||
import StoreService.Names.Contracts (Contracts, newContracts)
|
||||
import StoreService.Names.Phases (Phases, newPhases)
|
||||
import StoreService.Names.Profiles (Profiles, chatProfile, defaultProfileLimits, monotonicSeconds, newProfiles, systemResolver)
|
||||
import StoreService.Names.Records (Records, newRecords)
|
||||
import StoreService.Names.Sender (Sender, newSender)
|
||||
import StoreService.Names.Turns (Turns)
|
||||
|
||||
@@ -24,7 +25,8 @@ data NamesEnv = NamesEnv
|
||||
neChain :: ChainClient,
|
||||
neContracts :: Contracts,
|
||||
neProfiles :: Profiles,
|
||||
neSender :: Sender
|
||||
neSender :: Sender,
|
||||
neRecords :: Records
|
||||
}
|
||||
|
||||
newNamesEnv :: ChatController -> UserId -> NamesConfig -> DBStore -> Turns -> IO NamesEnv
|
||||
@@ -34,4 +36,5 @@ newNamesEnv neChat@ChatController {random} neUserId neConfig@NamesConfig {nTLD,
|
||||
neContracts <- newContracts nTLD neChain
|
||||
neProfiles <- newProfiles defaultProfileLimits monotonicSeconds (chatProfile neChat neUserId) systemResolver
|
||||
neSender <- newSender random neChain neStore getCurrentTime
|
||||
pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns, neChain, neContracts, neProfiles, neSender}
|
||||
neRecords <- newRecords neStore neTurns neContracts neChain neSender nTLD getCurrentTime
|
||||
pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns, neChain, neContracts, neProfiles, neSender, neRecords}
|
||||
@@ -0,0 +1,362 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
|
||||
-- | Record edits signed by a name's owner, relayed through the registrar key's sender and followed to their outcome.
|
||||
module StoreService.Names.Records
|
||||
( Records (rsPendingAfter),
|
||||
EditRequest (..),
|
||||
EditAnswer (..),
|
||||
newRecords,
|
||||
relayEdit,
|
||||
runRecordEdits,
|
||||
recordsRound,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Exception (bracket)
|
||||
import Control.Monad (forM, forever, unless, void, when)
|
||||
import Control.Monad.Except (ExceptT (..), runExceptT)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Set (Set)
|
||||
import qualified Data.Set as S
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime)
|
||||
import Data.Word (Word64)
|
||||
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import Simplex.Messaging.Crypto (sha256Hash)
|
||||
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature)
|
||||
import Simplex.Messaging.Eth.Address (Address, recoverAddress)
|
||||
import Simplex.Messaging.Eth.EIP712 (Eip712Domain, serializeSignature)
|
||||
import Simplex.Messaging.SimplexName (LabelHash, SimplexTLD)
|
||||
import Simplex.Messaging.Util (tshow)
|
||||
import StoreService.Config (ChainConfig (..))
|
||||
import StoreService.Log (logWarn, safely)
|
||||
import StoreService.Names.Chain
|
||||
import StoreService.Names.Contracts (Contracts, NameStatus (..), RegisteredName (..), nameStatus, resolverNonce, textRecord)
|
||||
import StoreService.Names.Registration (Node, channelRecordKey, clearRecordsDigest, clearRecordsWithSigCalldata, contactRecordKey, labelNode, resolverDomain, setTextDigest, setTextWithSigCalldata)
|
||||
import StoreService.Names.Revert (RevertName (..), revertText)
|
||||
import StoreService.Names.Sender (SendError (..), Sender, submitTransaction)
|
||||
import StoreService.Names.Store
|
||||
import StoreService.Names.Turns (Turns, withLabelTurn)
|
||||
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..))
|
||||
#else
|
||||
import Database.SQLite.Simple (Only (..))
|
||||
#endif
|
||||
|
||||
data Records = Records
|
||||
{ rsStore :: DBStore,
|
||||
rsTurns :: Turns,
|
||||
rsContracts :: Contracts,
|
||||
rsChain :: ChainClient,
|
||||
rsSender :: Sender,
|
||||
rsTLD :: SimplexTLD,
|
||||
rsNow :: IO UTCTime,
|
||||
-- | microseconds a request waits for its edit's outcome
|
||||
rsPendingAfter :: Int,
|
||||
-- | the edits a request or the lane is sending or judging, so no edit is sent twice
|
||||
rsClaimed :: TVar (Set Int64),
|
||||
-- | the pending edits whose last chain read failed
|
||||
rsStalled :: TVar (Set Int64),
|
||||
-- | grows on every settlement and stall, waking the requests that wait for an edit
|
||||
rsChanges :: TVar Int
|
||||
}
|
||||
|
||||
data EditRequest = EditRequest
|
||||
{ erLabelHash :: LabelHash,
|
||||
erChange :: RecordChange,
|
||||
erNonce :: Word64,
|
||||
erDeadline :: Word64,
|
||||
erSignature :: RecoverableSignature
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data EditAnswer
|
||||
= -- | mined, Nothing when found applied with the applying transaction unknown
|
||||
EAApplied (Maybe TxHash)
|
||||
| -- | the revert's name when known; the nonce is free again
|
||||
EAReverted (Maybe Text)
|
||||
| -- | not settled within the wait, or the chain stopped answering after the send; the edit goes on
|
||||
EAPending
|
||||
| -- | the signer, the deadline, the owner or the nonce
|
||||
EARefused
|
||||
| -- | the chain failed before anything was sent, and nothing is stored
|
||||
EAUnavailable
|
||||
| -- | the per-name cap, with the seconds until another edit fits
|
||||
EACapped Int
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | What the chain shows of an edit none of whose transactions mined.
|
||||
data Judgment
|
||||
= -- | one of its own hashes, or Nothing when the nonce passed with the record holding its value
|
||||
Applied (Maybe TxHash)
|
||||
| -- | the nonce passed with the record holding another value
|
||||
NonceTaken
|
||||
| NonceFree
|
||||
|
||||
-- | Below a proxy's idle timeout; the edit goes on after it.
|
||||
editPendingAfter :: Int
|
||||
editPendingAfter = 90000000
|
||||
|
||||
-- | Relayed edits per name in 'editCapWindow'.
|
||||
editCap :: Int
|
||||
editCap = 10
|
||||
|
||||
editCapWindow :: NominalDiffTime
|
||||
editCapWindow = 3600
|
||||
|
||||
recordsInterval :: Int
|
||||
recordsInterval = 4000000
|
||||
|
||||
newRecords :: DBStore -> Turns -> Contracts -> ChainClient -> Sender -> SimplexTLD -> IO UTCTime -> IO Records
|
||||
newRecords rsStore rsTurns rsContracts rsChain rsSender rsTLD rsNow = do
|
||||
rsClaimed <- newTVarIO S.empty
|
||||
rsStalled <- newTVarIO S.empty
|
||||
rsChanges <- newTVarIO 0
|
||||
pure Records {rsStore, rsTurns, rsContracts, rsChain, rsSender, rsTLD, rsNow, rsPendingAfter = editPendingAfter, rsClaimed, rsStalled, rsChanges}
|
||||
|
||||
-- | An identical repeat of a sent edit is answered from it, before any chain read.
|
||||
relayEdit :: Records -> EditRequest -> IO EditAnswer
|
||||
relayEdit rs req@EditRequest {erSignature} =
|
||||
recoverAddress erSignature digest >>= \case
|
||||
Left _ -> pure EARefused
|
||||
Right signer ->
|
||||
withStore rs (`getRecordEditByBody` bodyHash) >>= \case
|
||||
Just RecordEdit {reId} -> awaitEdit rs reId
|
||||
Nothing -> newEdit rs req signer bodyHash
|
||||
where
|
||||
digest = editDigest rs req
|
||||
bodyHash = sha256Hash $ digest <> serializeSignature erSignature
|
||||
|
||||
newEdit :: Records -> EditRequest -> Address -> ByteString -> IO EditAnswer
|
||||
newEdit rs req signer bodyHash =
|
||||
chainAccepts rs req signer >>= \case
|
||||
Left _ -> pure EAUnavailable
|
||||
Right False -> pure EARefused
|
||||
Right True ->
|
||||
insertEdit rs req signer bodyHash >>= \case
|
||||
Left wait -> pure $ EACapped wait
|
||||
Right (Just editId) -> sendNew rs editId
|
||||
-- a pending or mined edit holds the nonce: the identical twin is waited for, any other body refused
|
||||
Right Nothing -> withStore rs (`getRecordEditByBody` bodyHash) >>= maybe (pure EARefused) (awaitEdit rs . reId)
|
||||
|
||||
-- | The deadline after the latest block's time, the signer the registry's owner on the default resolver, the nonce the owner's next.
|
||||
chainAccepts :: Records -> EditRequest -> Address -> IO (Either ChainError Bool)
|
||||
chainAccepts Records {rsChain, rsContracts} EditRequest {erLabelHash, erNonce, erDeadline} signer = runExceptT $ do
|
||||
Block {blockTimestamp} <- ExceptT $ latestBlock rsChain
|
||||
if erDeadline <= blockTimestamp
|
||||
then pure False
|
||||
else
|
||||
ExceptT (nameStatus rsContracts erLabelHash) >>= \case
|
||||
NSRegistered RegisteredName {rnOwner, rnResolver}
|
||||
| rnOwner == signer && rnResolver == cResolver (ccConfig rsChain) -> (== erNonce) <$> ExceptT (resolverNonce rsContracts signer)
|
||||
_ -> pure False
|
||||
|
||||
-- | Left with the seconds to wait when the name's cap is used up; Right Nothing when a pending or mined edit holds the nonce.
|
||||
insertEdit :: Records -> EditRequest -> Address -> ByteString -> IO (Either Int (Maybe Int64))
|
||||
insertEdit Records {rsStore, rsTurns, rsNow} EditRequest {erLabelHash, erChange, erNonce, erDeadline, erSignature} signer bodyHash =
|
||||
withLabelTurn rsTurns erLabelHash $ do
|
||||
now <- rsNow
|
||||
withTransaction rsStore $ \db -> do
|
||||
times <- relayedEditTimes db erLabelHash (addUTCTime (negate editCapWindow) now)
|
||||
let relayed = length times
|
||||
if relayed >= editCap
|
||||
then pure $ Left $ max 1 $ ceiling $ diffUTCTime (addUTCTime editCapWindow $ times !! (relayed - editCap)) now
|
||||
else Right <$> insertRecordEdit db edit now
|
||||
where
|
||||
edit = NewRecordEdit {nreLabelHash = erLabelHash, nreOwner = signer, nreNonce = erNonce, nreChange = erChange, nreDeadline = erDeadline, nreSignature = erSignature, nreBodyHash = bodyHash}
|
||||
|
||||
-- | An edit the chain failed to take is deleted while still claimed, so the lane cannot send it after.
|
||||
sendNew :: Records -> Int64 -> IO EditAnswer
|
||||
sendNew rs editId = do
|
||||
sent <- withClaim rs editId True $ withStore rs (`getRecordEdit` editId) >>= maybe (pure True) send
|
||||
if sent then awaitEdit rs editId else EAUnavailable <$ settled rs editId
|
||||
where
|
||||
send edit =
|
||||
sendEdit rs edit >>= \case
|
||||
Right () -> pure True
|
||||
Left e -> do
|
||||
logWarn $ "names records: edit " <> tshow editId <> " not sent: " <> tshow e
|
||||
False <$ withStore rs (`deleteUnsentEdit` editId)
|
||||
|
||||
-- | A dry run's revert is judged at once; Left when nothing was sent and the chain did not refuse the edit.
|
||||
sendEdit :: Records -> RecordEdit -> IO (Either SendError ())
|
||||
sendEdit rs@Records {rsSender} edit@RecordEdit {reId} =
|
||||
submitTransaction rsSender (TxEdit reId) (resolver rs) (editCalldata rs edit) >>= \case
|
||||
Right _ -> Right () <$ unstall rs reId
|
||||
Left SEInFlight -> pure $ Right ()
|
||||
Left (SEChain (ChainReverted name)) -> Right () <$ judged rs edit (settle rs reId $ ESReverted $ revertText =<< name)
|
||||
Left e -> pure $ Left e
|
||||
|
||||
-- | Waits until the edit settles, the chain stops answering for it, or 'rsPendingAfter' passes.
|
||||
awaitEdit :: Records -> Int64 -> IO EditAnswer
|
||||
awaitEdit rs@Records {rsChanges, rsStalled, rsPendingAfter} editId = registerDelay rsPendingAfter >>= loop
|
||||
where
|
||||
loop timer = do
|
||||
-- read before the row, so a change committed after the read still wakes this request
|
||||
seen <- readTVarIO rsChanges
|
||||
withStore rs (`getRecordEdit` editId) >>= \case
|
||||
Nothing -> pure EAUnavailable
|
||||
Just RecordEdit {reState = ESMined tx} -> pure $ EAApplied tx
|
||||
Just RecordEdit {reState = ESReverted revert} -> pure $ EAReverted revert
|
||||
Just RecordEdit {reState = ESPending} -> do
|
||||
stalled <- S.member editId <$> readTVarIO rsStalled
|
||||
if stalled
|
||||
then pure EAPending
|
||||
else do
|
||||
changed <- atomically $ (True <$ (readTVar rsChanges >>= check . (/= seen))) `orElse` (False <$ (readTVar timer >>= check))
|
||||
if changed then loop timer else pure EAPending
|
||||
|
||||
runRecordEdits :: Records -> IO ()
|
||||
runRecordEdits rs = forever $ recordsRound rs >> threadDelay recordsInterval
|
||||
|
||||
-- | One pass over the pending edits that no request is sending. A pass that fails is logged, and the lane goes on.
|
||||
recordsRound :: Records -> IO ()
|
||||
recordsRound rs@Records {rsChain} = void $ safely "names records: the round" $ do
|
||||
editIds <- withStore rs pendingEditIds
|
||||
unless (null editIds) $
|
||||
runExceptT (ExceptT (latestBlock rsChain) >>= \tip -> (tip,) <$> ExceptT (deepBlock rsChain tip)) >>= \case
|
||||
Left e -> mapM_ (\editId -> stall rs editId e) editIds
|
||||
Right (tip, deep) -> mapM_ (\editId -> withClaim rs editId () $ withStore rs (`getRecordEdit` editId) >>= mapM_ (follow rs tip deep)) editIds
|
||||
|
||||
-- | The newest block at the configured confirmation depth, as the sender counts it.
|
||||
deepBlock :: ChainClient -> Block -> IO (Either ChainError Block)
|
||||
deepBlock c@ChainClient {ccConfig = ChainConfig {cConfirmations}} tip@Block {blockNumber}
|
||||
| depth == 0 = pure $ Right tip
|
||||
| otherwise = blockAt c $ if blockNumber >= depth then blockNumber - depth else 0
|
||||
where
|
||||
depth = fromIntegral (max 1 cConfirmations) - 1
|
||||
|
||||
-- | One step for a pending edit, from its transactions' statuses as the sender settled them.
|
||||
follow :: Records -> Block -> Block -> RecordEdit -> IO ()
|
||||
follow rs@Records {rsChain} Block {blockTimestamp} Block {blockTimestamp = deepTime} edit@RecordEdit {reId, reDeadline} = do
|
||||
txs <- withStore rs (`editTransactions` reId)
|
||||
case txs of
|
||||
[] -> if ahead then resend else expire
|
||||
_
|
||||
| mined : _ <- [ntHash | NameTransaction {ntHash, ntStatus = TSMined} <- txs] -> settle rs reId $ ESMined $ Just mined
|
||||
| any ((== TSPending) . ntStatus) txs -> if deepTime > reDeadline then expire else unstall rs reId
|
||||
-- a mined revert carries no data: past the deadline it is SignatureExpired, else unnamed
|
||||
| block : _ <- [b | NameTransaction {ntStatus = TSReverted, ntMinedBlock = Just b} <- txs] ->
|
||||
blockAt rsChain block >>= \case
|
||||
Left e -> stall rs reId e
|
||||
Right Block {blockTimestamp = minedTime} -> judged rs edit $ settle rs reId $ ESReverted $ if minedTime > reDeadline then signatureExpired else Nothing
|
||||
-- every transaction dropped: its registrar nonce went to another one
|
||||
| otherwise -> judged rs edit $ if ahead then resend else expire
|
||||
where
|
||||
ahead = reDeadline > blockTimestamp
|
||||
expire = judged rs edit $ settle rs reId $ ESReverted signatureExpired
|
||||
resend = sendEdit rs edit >>= either (stall rs reId) pure
|
||||
|
||||
-- | Settles the edit as the chain shows it, or runs the action when its nonce is still free.
|
||||
judged :: Records -> RecordEdit -> IO () -> IO ()
|
||||
judged rs edit@RecordEdit {reId} whenFree =
|
||||
judge rs edit >>= \case
|
||||
Left e -> stall rs reId e
|
||||
Right (Applied tx) -> settle rs reId $ ESMined tx
|
||||
Right NonceTaken -> settle rs reId $ ESReverted $ revertText RNInvalidNonce
|
||||
Right NonceFree -> whenFree
|
||||
|
||||
-- | Its own transactions first: a status-1 receipt answers its hash. Without one, the owner's nonce and the record.
|
||||
judge :: Records -> RecordEdit -> IO (Either ChainError Judgment)
|
||||
judge rs@Records {rsChain, rsContracts} RecordEdit {reId, reLabelHash, reOwner, reNonce, reChange} = runExceptT $ do
|
||||
txs <- liftIO $ withStore rs (`editTransactions` reId)
|
||||
receipts <- forM txs $ \NameTransaction {ntHash} -> (ntHash,) <$> ExceptT (transactionReceipt rsChain ntHash)
|
||||
case [h | (h, Just Receipt {receiptSuccess = True}) <- receipts] of
|
||||
h : _ -> pure $ Applied $ Just h
|
||||
[] -> do
|
||||
nonce <- ExceptT $ resolverNonce rsContracts reOwner
|
||||
if nonce <= reNonce
|
||||
then pure NonceFree
|
||||
else do
|
||||
holds <- case reChange of
|
||||
RCSetText key value -> (== value) <$> record (encodeUtf8 key)
|
||||
RCClear -> (&&) <$> (unset <$> record contactRecordKey) <*> (unset <$> record channelRecordKey)
|
||||
pure $ if holds then Applied Nothing else NonceTaken
|
||||
where
|
||||
record = ExceptT . textRecord rsContracts reLabelHash
|
||||
unset = (== "")
|
||||
|
||||
settle :: Records -> Int64 -> EditState -> IO ()
|
||||
settle rs editId state = do
|
||||
void $ withStore rs $ \db -> settleRecordEdit db editId state
|
||||
settled rs editId
|
||||
|
||||
-- | Wakes the requests waiting for the edit, which read it again.
|
||||
settled :: Records -> Int64 -> IO ()
|
||||
settled Records {rsStalled, rsChanges} editId = atomically $ modifyTVar' rsStalled (S.delete editId) >> modifyTVar' rsChanges (+ 1)
|
||||
|
||||
-- | Logged once until the edit's next successful step.
|
||||
stall :: Show e => Records -> Int64 -> e -> IO ()
|
||||
stall Records {rsStalled, rsChanges} editId e = do
|
||||
new <- atomically $ do
|
||||
stalled <- readTVar rsStalled
|
||||
let new = not $ S.member editId stalled
|
||||
when new $ writeTVar rsStalled (S.insert editId stalled) >> modifyTVar' rsChanges (+ 1)
|
||||
pure new
|
||||
when new $ logWarn $ "names records: edit " <> tshow editId <> " waits for the chain: " <> tshow e
|
||||
|
||||
unstall :: Records -> Int64 -> IO ()
|
||||
unstall Records {rsStalled} editId = atomically $ modifyTVar' rsStalled $ S.delete editId
|
||||
|
||||
-- | Runs the action only when nobody else holds the edit, else answers the fallback.
|
||||
withClaim :: Records -> Int64 -> a -> IO a -> IO a
|
||||
withClaim Records {rsClaimed} editId fallback action =
|
||||
bracket claim (\claimed -> when claimed release) $ \claimed -> if claimed then action else pure fallback
|
||||
where
|
||||
claim = atomically $ do
|
||||
held <- readTVar rsClaimed
|
||||
if S.member editId held then pure False else True <$ writeTVar rsClaimed (S.insert editId held)
|
||||
release = atomically $ modifyTVar' rsClaimed $ S.delete editId
|
||||
|
||||
editDigest :: Records -> EditRequest -> ByteString
|
||||
editDigest rs EditRequest {erLabelHash, erChange, erNonce, erDeadline} = case erChange of
|
||||
RCSetText key value -> setTextDigest (domain rs) node key value erNonce erDeadline
|
||||
RCClear -> clearRecordsDigest (domain rs) node erNonce erDeadline
|
||||
where
|
||||
node = nodeOf rs erLabelHash
|
||||
|
||||
editCalldata :: Records -> RecordEdit -> ByteString
|
||||
editCalldata rs RecordEdit {reLabelHash, reChange, reNonce, reDeadline, reSignature} = case reChange of
|
||||
RCSetText key value -> setTextWithSigCalldata node key value reNonce reDeadline reSignature
|
||||
RCClear -> clearRecordsWithSigCalldata node reNonce reDeadline reSignature
|
||||
where
|
||||
node = nodeOf rs reLabelHash
|
||||
|
||||
nodeOf :: Records -> LabelHash -> Node
|
||||
nodeOf Records {rsTLD} = labelNode rsTLD
|
||||
|
||||
domain :: Records -> Eip712Domain
|
||||
domain rs@Records {rsChain} = resolverDomain (cChainId $ ccConfig rsChain) (resolver rs)
|
||||
|
||||
resolver :: Records -> Address
|
||||
resolver = cResolver . ccConfig . rsChain
|
||||
|
||||
signatureExpired :: Maybe Text
|
||||
signatureExpired = revertText RNSignatureExpired
|
||||
|
||||
withStore :: Records -> (DB.Connection -> IO a) -> IO a
|
||||
withStore Records {rsStore} = withTransaction rsStore
|
||||
|
||||
pendingEditIds :: DB.Connection -> IO [Int64]
|
||||
pendingEditIds db = map fromOnly <$> DB.query db "SELECT name_record_edit_id FROM sx_badge_service_name_record_edits WHERE state = ? ORDER BY name_record_edit_id" (Only pendingState)
|
||||
|
||||
deleteUnsentEdit :: DB.Connection -> Int64 -> IO ()
|
||||
deleteUnsentEdit db editId =
|
||||
DB.execute
|
||||
db
|
||||
"DELETE FROM sx_badge_service_name_record_edits WHERE name_record_edit_id = ? AND state = ? AND NOT EXISTS (SELECT 1 FROM sx_badge_service_name_transactions WHERE name_record_edit_id = ?)"
|
||||
(editId, pendingState, editId)
|
||||
|
||||
pendingState :: Text
|
||||
pendingState = "pending"
|
||||
@@ -8,8 +8,9 @@ import Data.Text (Text)
|
||||
import Network.HTTP.Types (Method)
|
||||
import Network.Wai (Request, ResponseReceived, pathInfo)
|
||||
import StoreService.Names.Env (NamesEnv (..))
|
||||
import StoreService.Web.Http (HttpEnv, Limit, Respond, codeLimit, limited, only)
|
||||
import StoreService.Web.Http (HttpEnv, Limit, Respond, codeLimit, limited, only, recordsLimit)
|
||||
import StoreService.Web.Names.Codes (codeCheckHandler)
|
||||
import StoreService.Web.Names.Records (recordsHandler)
|
||||
|
||||
data Route = Route
|
||||
{ rMethod :: Method,
|
||||
@@ -18,7 +19,8 @@ data Route = Route
|
||||
}
|
||||
|
||||
namesRoutes :: NamesEnv -> [Text] -> Maybe Route
|
||||
namesRoutes NamesEnv {neStore} = \case
|
||||
namesRoutes NamesEnv {neStore, neRecords} = \case
|
||||
["api", "name", "records"] -> Just Route {rMethod = "POST", rLimit = recordsLimit, rHandler = recordsHandler neRecords}
|
||||
["api", "code", "check"] -> Just Route {rMethod = "POST", rLimit = codeLimit, rHandler = codeCheckHandler neStore}
|
||||
_ -> Nothing
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module StoreService.Web.Names.Records (EditInvalid (..), recordsHandler, editRequest) where
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Data.Aeson ((.:), (.:?), (.=))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.Types as JT
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (decodeLatin1)
|
||||
import Network.HTTP.Types (status200, status400, status409, status504)
|
||||
import Network.Wai (Request, Response, ResponseReceived)
|
||||
import Simplex.Messaging.SimplexName (LabelHash (..))
|
||||
import StoreService.Names.Abi (bytes32FromHex, hex)
|
||||
import StoreService.Names.Chain (TxHash (..))
|
||||
import StoreService.Names.Links (LinkKind (..), linkKindOf)
|
||||
import StoreService.Names.Records (EditAnswer (..), EditRequest (..), Records, relayEdit)
|
||||
import StoreService.Names.Registration (channelRecordKey, contactRecordKey)
|
||||
import StoreService.Names.Signatures (parseSignature)
|
||||
import StoreService.Names.Store (RecordChange (..))
|
||||
import StoreService.Web.Http (Respond, badRequest, jsonError, jsonResponse, maxBodyBytes, providerUnavailable, rateLimited, readBoundedBody)
|
||||
|
||||
data EditInvalid = EIBadRequest | EIInvalidLink
|
||||
deriving (Eq, Show)
|
||||
|
||||
recordsHandler :: Records -> Request -> Respond -> IO ResponseReceived
|
||||
recordsHandler rs req respond =
|
||||
readBoundedBody maxBodyBytes req >>= \case
|
||||
Just body | Just o <- J.decode body -> case editRequest o of
|
||||
Right edit -> relayEdit rs edit >>= respond . editResponse
|
||||
Left EIInvalidLink -> respond invalidLink
|
||||
Left EIBadRequest -> respond badRequest
|
||||
_ -> respond badRequest
|
||||
|
||||
-- | The formats and the key, then the link, then the signature's format and low s, as the page's checks run.
|
||||
editRequest :: J.Object -> Either EditInvalid EditRequest
|
||||
editRequest o = do
|
||||
(labelHash, change, erNonce, erDeadline, signature) <- maybe (Left EIBadRequest) Right $ JT.parseMaybe fields o
|
||||
erLabelHash <- maybe (Left EIBadRequest) (Right . LabelHash) $ bytes32FromHex labelHash
|
||||
erChange <- case change of
|
||||
Nothing -> Right RCClear
|
||||
Just (key, value) -> do
|
||||
kind <- maybe (Left EIBadRequest) Right $ lookup key recordKinds
|
||||
unless (value == "" || linkKindOf value == Just kind) $ Left EIInvalidLink
|
||||
Right $ RCSetText key value
|
||||
erSignature <- either (const $ Left EIBadRequest) Right $ parseSignature signature
|
||||
pure EditRequest {erLabelHash, erChange, erNonce, erDeadline, erSignature}
|
||||
where
|
||||
fields v = do
|
||||
clear <- v .:? "clear"
|
||||
change <- if clear == Just True then pure Nothing else Just <$> ((,) <$> v .: "key" <*> v .: "value")
|
||||
(,,,,) <$> v .: "labelHash" <*> pure change <*> v .: "nonce" <*> v .: "deadline" <*> v .: "signature"
|
||||
|
||||
recordKinds :: [(Text, LinkKind)]
|
||||
recordKinds = [(decodeLatin1 contactRecordKey, LKContact), (decodeLatin1 channelRecordKey, LKChannel)]
|
||||
|
||||
editResponse :: EditAnswer -> Response
|
||||
editResponse = \case
|
||||
EAApplied tx -> jsonResponse status200 $ J.object ["tx" .= ((\(TxHash h) -> hex h) <$> tx)]
|
||||
EAReverted revert -> jsonResponse status409 $ J.object ["error" .= ("record_reverted" :: Text), "revert" .= revert]
|
||||
EAPending -> jsonError status504 "edit_pending"
|
||||
EARefused -> badRequest
|
||||
EAUnavailable -> providerUnavailable
|
||||
EACapped seconds -> rateLimited seconds
|
||||
|
||||
invalidLink :: Response
|
||||
invalidLink = jsonError status400 "invalid_link"
|
||||
@@ -451,6 +451,7 @@ executable simplex-store-service
|
||||
StoreService.Names.Links
|
||||
StoreService.Names.Phases
|
||||
StoreService.Names.Profiles
|
||||
StoreService.Names.Records
|
||||
StoreService.Names.Registration
|
||||
StoreService.Names.Revert
|
||||
StoreService.Names.Sender
|
||||
@@ -471,6 +472,7 @@ executable simplex-store-service
|
||||
StoreService.Web.Http
|
||||
StoreService.Web.Names
|
||||
StoreService.Web.Names.Codes
|
||||
StoreService.Web.Names.Records
|
||||
StoreService.Web.Server
|
||||
Paths_simplex_chat
|
||||
ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts
|
||||
@@ -763,6 +765,7 @@ test-suite simplex-chat-test
|
||||
Bots.StoreService.NamesLabelTests
|
||||
Bots.StoreService.NamesLinksTests
|
||||
Bots.StoreService.NamesProfilesTests
|
||||
Bots.StoreService.NamesRecordsTests
|
||||
Bots.StoreService.NamesSenderTests
|
||||
Bots.StoreService.NamesSignatureTests
|
||||
Bots.StoreService.NamesStoreTests
|
||||
@@ -797,6 +800,7 @@ test-suite simplex-chat-test
|
||||
StoreService.Names.Links
|
||||
StoreService.Names.Phases
|
||||
StoreService.Names.Profiles
|
||||
StoreService.Names.Records
|
||||
StoreService.Names.Registration
|
||||
StoreService.Names.Revert
|
||||
StoreService.Names.Sender
|
||||
@@ -817,6 +821,7 @@ test-suite simplex-chat-test
|
||||
StoreService.Web.Http
|
||||
StoreService.Web.Names
|
||||
StoreService.Web.Names.Codes
|
||||
StoreService.Web.Names.Records
|
||||
StoreService.Web.Server
|
||||
Paths_simplex_chat
|
||||
if flag(client_postgres)
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Bots.StoreService.NamesRecordsTests (namesRecordsTests) where
|
||||
|
||||
import Bots.StoreService.FakeEthNode
|
||||
import Bots.StoreService.NamesStoreTests (t0)
|
||||
import Bots.StoreService.WebTests (withServiceStore)
|
||||
import Bots.StoreService.WebUIMock (EphemeralWallet (..), newEphemeralWallet, walletFromCode)
|
||||
import Control.Concurrent.Async (Async, async, concurrently, wait)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad (forM_)
|
||||
import Crypto.Number.Serialize (i2ospOf_, os2ip)
|
||||
import qualified Crypto.PubKey.ECC.Types as ECC
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Data.Aeson ((.=))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime)
|
||||
import Data.Word (Word64)
|
||||
import Network.HTTP.Client (RequestBody (..), defaultManagerSettings, httpLbs, method, newManager, parseRequest, requestBody, requestHeaders, responseBody, responseStatus)
|
||||
import Network.HTTP.Types (hContentType, statusCode)
|
||||
import qualified Network.Wai.Handler.Warp as Warp
|
||||
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature (..), signRecoverable)
|
||||
import Simplex.Messaging.Encoding.String (strDecode)
|
||||
import Simplex.Messaging.Eth.Address (Address, recoverAddress)
|
||||
import Simplex.Messaging.Eth.EIP712 (Eip712Domain, serializeSignature)
|
||||
import Simplex.Messaging.SimplexName (LabelHash (..), SimplexTLD (..), labelHash)
|
||||
import StoreService.Config (ChainConfig (..))
|
||||
import StoreService.Names.Abi
|
||||
import StoreService.Names.Chain
|
||||
import StoreService.Names.Contracts (newContracts)
|
||||
import StoreService.Names.Records
|
||||
import StoreService.Names.Registration (Node, clearRecordsDigest, clearRecordsWithSigCalldata, labelNode, nodeBytes, resolverDomain, setTextDigest, setTextWithSigCalldata)
|
||||
import StoreService.Names.Sender (Sender, followTransactions, newSender)
|
||||
import StoreService.Names.Signatures (parseSignature)
|
||||
import StoreService.Names.Store (EditState (..), NewRecordEdit (..), RecordChange (..), RecordEdit (..), getRecordEditByBody, insertRecordEdit)
|
||||
import StoreService.Names.Turns (newTurns)
|
||||
import StoreService.Web.Names.Records (EditInvalid (..), editRequest, recordsHandler)
|
||||
import System.Timeout (timeout)
|
||||
import Test.Hspec
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..))
|
||||
#else
|
||||
import Database.SQLite.Simple (Only (..))
|
||||
#endif
|
||||
|
||||
namesRecordsTests :: Spec
|
||||
namesRecordsTests = describe "StoreService names: records" $ do
|
||||
-- the signatures and calldata marked viem are viem 2.57.5's, by the wallet of "abandon ... about" (account 0),
|
||||
-- with the SimplexResolver ABI of ens-contracts PR #36 at 23892cc
|
||||
g <- runIO C.newRandom
|
||||
describe "requests" $ do
|
||||
it "signs and encodes SetText and ClearRecords edits as viem does" $ testViemVectors g
|
||||
it "reads a request's formats, then its link, then its signature" testRequestOrder
|
||||
it "bounds a link at 4096 bytes and takes only a link of the key's kind" testLinkBound
|
||||
it "refuses a high s, though recovery accepts it" testHighS
|
||||
describe "relay" $ do
|
||||
it "relays an edit with viem's calldata and answers its hash once mined" $ testRelayMined g
|
||||
it "refuses a wrong signer or a passed deadline before any row or gas" $ testRefusedBeforeRow g
|
||||
it "leaves the owner's cap untouched by ten edits a non-owner signs" $ testNonOwnerCap g
|
||||
it "checks a refused edit's repeat anew" $ testRefusedRepeat g
|
||||
it "refuses the eleventh relayed edit of a name within an hour, with the real wait" $ testCap g
|
||||
it "answers provider_unavailable and stores nothing when the chain fails before the send" $ testUnavailableBeforeSend g
|
||||
it "lets a concurrent twin lose the unique insert before any signing, and answers both" $ testConcurrentTwin g
|
||||
it "answers edit_pending after the wait, and its repeat with the hash once mined" $ testPendingThenMined g
|
||||
it "answers edit_pending at once when the chain stops answering after the send" $ testChainStopsAfterSend g
|
||||
describe "outcomes" $ do
|
||||
it "answers an InvalidNonce revert of an edit another relayer applied as mined with no hash, and its repeat too" $ testAppliedByAnother g
|
||||
it "answers a SignatureExpired revert of an edit another relayer applied as mined with no hash" $ testExpiredAppliedByAnother g
|
||||
it "answers record_reverted InvalidNonce when another body used the nonce with another value" $ testNonceTakenByAnother g
|
||||
it "frees a reverted edit's nonce for a new body, and answers the reverted edit's repeat while the new one is pending" $ testRevertFreesNonce g
|
||||
it "keeps an edit pending when the re-read after a dry run's revert fails, and settles it in a later round" $ testFailedReRead g
|
||||
describe "restarts" $ do
|
||||
it "sends a saved edit that was never sent, and follows a sent one to its receipt" $ testResume g
|
||||
it "answers an identical repeat after a restart from the stored edit, with no chain read" $ testRepeatAfterRestart g
|
||||
it "finds an edit that expired across a restart applied, and makes it mined" $ testRestartExpiredApplied g
|
||||
it "frees an edit past its deadline while its transaction is still rebroadcast" $ testExpiryFreesNonce g
|
||||
describe "http" $
|
||||
it "answers the contract's statuses and bodies" $ testHttp g
|
||||
|
||||
data Setup = Setup
|
||||
{ node :: FakeEthNode,
|
||||
store :: DBStore,
|
||||
clock :: TVar UTCTime,
|
||||
chain :: ChainClient,
|
||||
random :: TVar ChaChaDRG,
|
||||
sender :: Sender,
|
||||
records :: Records,
|
||||
owner :: EphemeralWallet
|
||||
}
|
||||
|
||||
-- | A registered alicebakery on the default resolver, held by the viem wallet, its nonce 0 and no records; two confirmations.
|
||||
withSetup :: TVar ChaChaDRG -> (Setup -> IO a) -> IO a
|
||||
withSetup random action = withFakeEthNode $ \node -> withServiceStore $ \store -> do
|
||||
clock <- newTVarIO t0
|
||||
chain <- newChainClient random (fakeChainConfig node) {cConfirmations = 2}
|
||||
owner <- walletFromCode random viemCode
|
||||
(sender, records) <- start store clock chain random
|
||||
let s = Setup {node, store, clock, chain, random, sender, records, owner}
|
||||
answerName s
|
||||
action s
|
||||
|
||||
start :: DBStore -> TVar UTCTime -> ChainClient -> TVar ChaChaDRG -> IO (Sender, Records)
|
||||
start store clock chain random = do
|
||||
sender <- newSender random chain store (readTVarIO clock)
|
||||
contracts <- newContracts TLDSimplex chain
|
||||
turns <- newTurns
|
||||
rs <- newRecords store turns contracts chain sender TLDSimplex (readTVarIO clock)
|
||||
pure (sender, rs {rsPendingAfter = longWait})
|
||||
|
||||
-- | A new sender and lane over the same store and node, as after the service restarts.
|
||||
restart :: Setup -> IO Setup
|
||||
restart s@Setup {store, clock, chain, random} = do
|
||||
(sender, records) <- start store clock chain random
|
||||
pure s {sender, records}
|
||||
|
||||
answerName :: Setup -> IO ()
|
||||
answerName s@Setup {node, owner = EphemeralWallet {ewAddress}} = do
|
||||
answerCall node fakeBaseRegistrar (selector "ens()") $ Returns $ addressWord registry
|
||||
answerCall node fakeBaseRegistrar (selector "GRACE_PERIOD()") $ Returns $ uint256 gracePeriod
|
||||
answerCall node fakeBaseRegistrar (selector "nameExpires(uint256)" <> unLabel alice) $ Returns $ uint256 nameExpiry
|
||||
answerCall node registry (selector "owner(bytes32)" <> nodeBytes aliceNode) $ Returns $ addressWord ewAddress
|
||||
answerCall node registry (selector "resolver(bytes32)" <> nodeBytes aliceNode) $ Returns $ addressWord fakeResolver
|
||||
setNonce s 0
|
||||
setRecord s contactKey ""
|
||||
setRecord s channelKey ""
|
||||
|
||||
setNonce :: Setup -> Word64 -> IO ()
|
||||
setNonce Setup {node, owner = EphemeralWallet {ewAddress}} n =
|
||||
answerCall node fakeResolver (selector "nonces(address)" <> addressWord ewAddress) $ Returns $ uint256 n
|
||||
|
||||
setRecord :: Setup -> Text -> Text -> IO ()
|
||||
setRecord Setup {node} key value =
|
||||
answerCall node fakeResolver (selector "text(bytes32,string)" <> abiTuple [Static $ nodeBytes aliceNode, Dynamic $ abiBytes $ encodeUtf8 key]) $
|
||||
Returns $ abiTuple [Dynamic $ abiBytes $ encodeUtf8 value]
|
||||
|
||||
signEdit :: Setup -> EphemeralWallet -> RecordChange -> Word64 -> Word64 -> IO EditRequest
|
||||
signEdit Setup {random} EphemeralWallet {ewKey} change nonce erDeadline = do
|
||||
sig <- signRecoverable random ewKey (digestOf change nonce erDeadline) >>= either fail pure
|
||||
pure EditRequest {erLabelHash = alice, erChange = change, erNonce = nonce, erDeadline, erSignature = sig}
|
||||
|
||||
channel :: Text -> RecordChange
|
||||
channel = RCSetText channelKey
|
||||
|
||||
-- | The owner's channel edit with nonce 0 and the default deadline.
|
||||
ownerEdit :: Setup -> Text -> IO EditRequest
|
||||
ownerEdit s@Setup {owner} value = signEdit s owner (channel value) 0 deadline
|
||||
|
||||
relay :: Setup -> EditRequest -> IO (Async EditAnswer)
|
||||
relay Setup {records} = async . relayEdit records
|
||||
|
||||
-- | Mines the n-th transaction sent with this status, one block deeper, then runs the sender's round and the lane's.
|
||||
mineSent :: Setup -> Int -> Bool -> IO TxHash
|
||||
mineSent Setup {node, sender, records} n ok = do
|
||||
raws <- within $ awaitSent node n
|
||||
let h = C.keccak256 $ raws !! (n - 1)
|
||||
_ <- mine node [(h, ok)]
|
||||
_ <- mine node []
|
||||
followTransactions sender
|
||||
recordsRound records
|
||||
pure $ TxHash h
|
||||
|
||||
answerOf :: Async EditAnswer -> IO EditAnswer
|
||||
answerOf = within . wait
|
||||
|
||||
within :: IO a -> IO a
|
||||
within action = timeout 5000000 action >>= maybe (fail "timed out") pure
|
||||
|
||||
stateOf :: Setup -> EditRequest -> IO (Maybe EditState)
|
||||
stateOf Setup {store} req = withTransaction store $ \db -> fmap reState <$> getRecordEditByBody db (bodyHashOf req)
|
||||
|
||||
editRows :: Setup -> IO Int
|
||||
editRows Setup {store} = withTransaction store $ \db -> do
|
||||
[Only n] <- DB.query_ db "SELECT count(*) FROM sx_badge_service_name_record_edits"
|
||||
pure n
|
||||
|
||||
-- | As the relay keys a sent edit: the signed digest and the signature.
|
||||
bodyHashOf :: EditRequest -> ByteString
|
||||
bodyHashOf EditRequest {erChange, erNonce, erDeadline, erSignature} = C.sha256Hash $ digestOf erChange erNonce erDeadline <> serializeSignature erSignature
|
||||
|
||||
-- | Saves an edit as a request does before it joins the sender, as if the service stopped right after.
|
||||
saveUnsent :: Setup -> EditRequest -> IO ()
|
||||
saveUnsent Setup {store, clock, owner = EphemeralWallet {ewAddress}} req@EditRequest {erChange, erNonce, erDeadline, erSignature} = do
|
||||
now <- readTVarIO clock
|
||||
Just _ <- withTransaction store $ \db ->
|
||||
insertRecordEdit db NewRecordEdit {nreLabelHash = alice, nreOwner = ewAddress, nreNonce = erNonce, nreChange = erChange, nreDeadline = erDeadline, nreSignature = erSignature, nreBodyHash = bodyHashOf req} now
|
||||
pure ()
|
||||
|
||||
passTime :: Setup -> NominalDiffTime -> IO ()
|
||||
passTime Setup {clock} t = atomically $ modifyTVar' clock (addUTCTime t)
|
||||
|
||||
-- requests
|
||||
|
||||
testViemVectors :: TVar ChaChaDRG -> IO ()
|
||||
testViemVectors g = withSetup g $ \s@Setup {owner = EphemeralWallet {ewAddress}} -> do
|
||||
ewAddress `shouldBe` viemAddress
|
||||
EditRequest {erSignature = setSig} <- ownerEdit s viemChannelLink
|
||||
setSig `shouldBe` viemSetSignature
|
||||
recoverAddress setSig (digestOf (channel viemChannelLink) 0 deadline) `shouldReturn` Right viemAddress
|
||||
setTextWithSigCalldata aliceNode channelKey viemChannelLink 0 deadline setSig `shouldBe` viemSetCall
|
||||
EditRequest {erSignature = clearSig} <- signEdit s (owner s) RCClear 1 deadline
|
||||
clearSig `shouldBe` viemClearSignature
|
||||
clearRecordsWithSigCalldata aliceNode 1 deadline clearSig `shouldBe` viemClearCall
|
||||
|
||||
testRequestOrder :: IO ()
|
||||
testRequestOrder = do
|
||||
let valid = requestJson [("key", J.String channelKey), ("value", J.String viemChannelLink)]
|
||||
fmap erChange (editRequest valid) `shouldBe` Right (channel viemChannelLink)
|
||||
fmap erChange (editRequest $ requestJson [("clear", J.Bool True)]) `shouldBe` Right RCClear
|
||||
fmap erChange (editRequest $ requestJson [("key", J.String contactKey), ("value", "")]) `shouldBe` Right (RCSetText contactKey "")
|
||||
-- formats first: a bad label hash or key is bad_request even with a bad link
|
||||
editRequest (KM.insert "labelHash" (J.String $ "0x" <> T.toUpper (T.drop 2 aliceHashText)) $ withLink "https://x/a") `shouldBe` Left EIBadRequest
|
||||
editRequest (requestJson [("key", "simplex.other"), ("value", J.String viemChannelLink)]) `shouldBe` Left EIBadRequest
|
||||
editRequest (KM.insert "nonce" (J.Number (-1)) valid) `shouldBe` Left EIBadRequest
|
||||
editRequest (KM.insert "deadline" (J.String "1767229200") valid) `shouldBe` Left EIBadRequest
|
||||
editRequest (KM.delete "value" valid) `shouldBe` Left EIBadRequest
|
||||
-- the link before the signature
|
||||
editRequest (KM.insert "signature" "0x00" $ withLink "https://x/a") `shouldBe` Left EIInvalidLink
|
||||
editRequest (KM.insert "signature" "0x00" valid) `shouldBe` Left EIBadRequest
|
||||
where
|
||||
withLink link = requestJson [("key", J.String channelKey), ("value", J.String link)]
|
||||
|
||||
testLinkBound :: IO ()
|
||||
testLinkBound = do
|
||||
let prefix = "https://smp8.simplex.im/c#"
|
||||
linkOf n = prefix <> T.replicate (n - T.length prefix) "a"
|
||||
edit key value = editRequest $ requestJson [("key", J.String key), ("value", J.String value)]
|
||||
fmap erChange (edit channelKey $ linkOf 4096) `shouldBe` Right (channel $ linkOf 4096)
|
||||
edit channelKey (linkOf 4097) `shouldBe` Left EIInvalidLink
|
||||
edit contactKey viemChannelLink `shouldBe` Left EIInvalidLink
|
||||
edit channelKey "https://smp8.simplex.im/c#Y8m3;https://smp9.simplex.im/c#Z1" `shouldBe` Left EIInvalidLink
|
||||
|
||||
testHighS :: IO ()
|
||||
testHighS = do
|
||||
let RecoverableSignature {rsR, rsS, rsRecId} = viemSetSignature
|
||||
highS = i2ospOf_ 32 $ curveOrder - os2ip rsS
|
||||
flipped = RecoverableSignature {rsR, rsS = highS, rsRecId = 1 - rsRecId}
|
||||
recoverAddress flipped (digestOf (channel viemChannelLink) 0 deadline) `shouldReturn` Right viemAddress
|
||||
editRequest (KM.insert "signature" (J.String $ hex $ serializeSignature flipped) $ requestJson [("key", J.String channelKey), ("value", J.String viemChannelLink)])
|
||||
`shouldBe` Left EIBadRequest
|
||||
where
|
||||
curveOrder = ECC.ecc_n $ ECC.common_curve $ ECC.getCurveByName ECC.SEC_p256k1
|
||||
|
||||
-- relay
|
||||
|
||||
testRelayMined :: TVar ChaChaDRG -> IO ()
|
||||
testRelayMined g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
r <- relay s req
|
||||
[raw] <- within $ awaitSent node 1
|
||||
fmap (\TxRequest {trTo, trData} -> (trTo, trData)) (decodeSignedTx raw) `shouldBe` Right (fakeResolver, viemSetCall)
|
||||
h <- mineSent s 1 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
stateOf s req `shouldReturn` Just (ESMined $ Just h)
|
||||
|
||||
testRefusedBeforeRow :: TVar ChaChaDRG -> IO ()
|
||||
testRefusedBeforeRow g = withSetup g $ \s@Setup {node, records} -> do
|
||||
other <- newEphemeralWallet g
|
||||
(relayEdit records =<< signEdit s other (channel viemChannelLink) 0 deadline) `shouldReturn` EARefused
|
||||
setBlock node 2 deadline
|
||||
(relayEdit records =<< ownerEdit s viemChannelLink) `shouldReturn` EARefused
|
||||
editRows s `shouldReturn` 0
|
||||
sentTransactions node `shouldReturn` []
|
||||
|
||||
testNonOwnerCap :: TVar ChaChaDRG -> IO ()
|
||||
testNonOwnerCap g = withSetup g $ \s@Setup {node, records} -> do
|
||||
other <- newEphemeralWallet g
|
||||
forM_ [0 .. 9] $ \n -> (relayEdit records =<< signEdit s other (channel viemChannelLink) n deadline) `shouldReturn` EARefused
|
||||
editRows s `shouldReturn` 0
|
||||
r <- relay s =<< ownerEdit s viemChannelLink
|
||||
_ <- within $ awaitSent node 1
|
||||
h <- mineSent s 1 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
|
||||
testRefusedRepeat :: TVar ChaChaDRG -> IO ()
|
||||
testRefusedRepeat g = withSetup g $ \s@Setup {owner} -> do
|
||||
req <- signEdit s owner (channel viemChannelLink) 1 deadline
|
||||
relayEdit (records s) req `shouldReturn` EARefused
|
||||
stateOf s req `shouldReturn` Nothing
|
||||
setNonce s 1
|
||||
r <- relay s req
|
||||
h <- mineSent s 1 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
|
||||
testCap :: TVar ChaChaDRG -> IO ()
|
||||
testCap g = withSetup g $ \s@Setup {owner} -> do
|
||||
let quick = s {records = (records s) {rsPendingAfter = shortWait}}
|
||||
forM_ [0 .. 9] $ \n -> do
|
||||
setNonce s n
|
||||
(relayEdit (records quick) =<< signEdit s owner (channel viemChannelLink) n deadline) `shouldReturn` EAPending
|
||||
setNonce s 10
|
||||
eleventh <- signEdit s owner (channel viemChannelLink) 10 deadline
|
||||
passTime s 1000
|
||||
relayEdit (records s) eleventh `shouldReturn` EACapped 2600
|
||||
editRows s `shouldReturn` 10
|
||||
passTime s 2601
|
||||
relayEdit (records quick) eleventh `shouldReturn` EAPending
|
||||
editRows s `shouldReturn` 11
|
||||
|
||||
testUnavailableBeforeSend :: TVar ChaChaDRG -> IO ()
|
||||
testUnavailableBeforeSend g = withSetup g $ \s@Setup {node, records} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
setFault node "eth_call" HttpFailure
|
||||
relayEdit records req `shouldReturn` EAUnavailable
|
||||
clearFault node "eth_call"
|
||||
setFault node "eth_maxPriorityFeePerGas" HttpFailure
|
||||
relayEdit records req `shouldReturn` EAUnavailable
|
||||
editRows s `shouldReturn` 0
|
||||
sentTransactions node `shouldReturn` []
|
||||
clearFault node "eth_maxPriorityFeePerGas"
|
||||
r <- relay s req
|
||||
h <- mineSent s 1 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
|
||||
testConcurrentTwin :: TVar ChaChaDRG -> IO ()
|
||||
testConcurrentTwin g = withSetup g $ \s@Setup {node, records} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
-- both pass the repeat lookup before either inserts
|
||||
setFault node "eth_getBlockByNumber" $ Slow 300000
|
||||
answers <- async $ concurrently (relayEdit records req) (relayEdit records req)
|
||||
_ <- within $ awaitSent node 1
|
||||
clearFault node "eth_getBlockByNumber"
|
||||
h <- mineSent s 1 True
|
||||
within (wait answers) `shouldReturn` (EAApplied (Just h), EAApplied (Just h))
|
||||
length <$> sentTransactions node `shouldReturn` 1
|
||||
editRows s `shouldReturn` 1
|
||||
|
||||
testPendingThenMined :: TVar ChaChaDRG -> IO ()
|
||||
testPendingThenMined g = withSetup g $ \s@Setup {records} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
relayEdit records {rsPendingAfter = shortWait} req `shouldReturn` EAPending
|
||||
h <- mineSent s 1 True
|
||||
relayEdit records req `shouldReturn` EAApplied (Just h)
|
||||
|
||||
testChainStopsAfterSend :: TVar ChaChaDRG -> IO ()
|
||||
testChainStopsAfterSend g = withSetup g $ \s@Setup {node, records} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
r <- relay s req
|
||||
_ <- within $ awaitSent node 1
|
||||
setFault node "eth_getBlockByNumber" HttpFailure
|
||||
recordsRound records
|
||||
answerOf r `shouldReturn` EAPending
|
||||
relayEdit records req `shouldReturn` EAPending
|
||||
clearFault node "eth_getBlockByNumber"
|
||||
h <- mineSent s 1 True
|
||||
relayEdit records req `shouldReturn` EAApplied (Just h)
|
||||
|
||||
-- outcomes
|
||||
|
||||
testAppliedByAnother :: TVar ChaChaDRG -> IO ()
|
||||
testAppliedByAnother g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
saveUnsent s req
|
||||
-- another relayer applied it while the service was down
|
||||
setNonce s 1
|
||||
setRecord s channelKey viemChannelLink
|
||||
revertDryRun node viemSetCall $ Just $ selector "InvalidNonce()"
|
||||
s' <- restart s
|
||||
recordsRound (records s')
|
||||
stateOf s req `shouldReturn` Just (ESMined Nothing)
|
||||
sentTransactions node `shouldReturn` []
|
||||
relayEdit (records s') req `shouldReturn` EAApplied Nothing
|
||||
|
||||
testExpiredAppliedByAnother :: TVar ChaChaDRG -> IO ()
|
||||
testExpiredAppliedByAnother g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
r <- relay s req
|
||||
_ <- within $ awaitSent node 1
|
||||
setBlock node 300 deadline
|
||||
setNonce s 1
|
||||
setRecord s channelKey viemChannelLink
|
||||
-- mined in the block after the deadline, so it reverted SignatureExpired
|
||||
_ <- mineSent s 1 False
|
||||
answerOf r `shouldReturn` EAApplied Nothing
|
||||
stateOf s req `shouldReturn` Just (ESMined Nothing)
|
||||
|
||||
testNonceTakenByAnother :: TVar ChaChaDRG -> IO ()
|
||||
testNonceTakenByAnother g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
r <- relay s req
|
||||
_ <- within $ awaitSent node 1
|
||||
setNonce s 1
|
||||
setRecord s channelKey "https://smp9.simplex.im/c#other"
|
||||
_ <- mineSent s 1 False
|
||||
answerOf r `shouldReturn` EAReverted (Just "InvalidNonce")
|
||||
stateOf s req `shouldReturn` Just (ESReverted $ Just "InvalidNonce")
|
||||
|
||||
testRevertFreesNonce :: TVar ChaChaDRG -> IO ()
|
||||
testRevertFreesNonce g = withSetup g $ \s@Setup {node, records} -> do
|
||||
first <- ownerEdit s viemChannelLink
|
||||
r <- relay s first
|
||||
-- reverted before the deadline with the nonce unused, so the revert has no name
|
||||
_ <- mineSent s 1 False
|
||||
answerOf r `shouldReturn` EAReverted Nothing
|
||||
second <- ownerEdit s "https://smp9.simplex.im/c#second"
|
||||
r' <- relay s second
|
||||
_ <- within $ awaitSent node 2
|
||||
relayEdit records first `shouldReturn` EAReverted Nothing
|
||||
stateOf s second `shouldReturn` Just ESPending
|
||||
h <- mineSent s 2 True
|
||||
answerOf r' `shouldReturn` EAApplied (Just h)
|
||||
|
||||
testFailedReRead :: TVar ChaChaDRG -> IO ()
|
||||
testFailedReRead g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
saveUnsent s req
|
||||
revertDryRun node viemSetCall $ Just $ selector "InvalidSignature()"
|
||||
s' <- restart s
|
||||
setFault node "eth_call" HttpFailure
|
||||
recordsRound (records s')
|
||||
stateOf s req `shouldReturn` Just ESPending
|
||||
relayEdit (records s') req `shouldReturn` EAPending
|
||||
clearFault node "eth_call"
|
||||
recordsRound (records s')
|
||||
stateOf s req `shouldReturn` Just (ESReverted $ Just "InvalidSignature")
|
||||
relayEdit (records s') req `shouldReturn` EAReverted (Just "InvalidSignature")
|
||||
sentTransactions node `shouldReturn` []
|
||||
|
||||
-- restarts
|
||||
|
||||
testResume :: TVar ChaChaDRG -> IO ()
|
||||
testResume g = withSetup g $ \s@Setup {node, owner} -> do
|
||||
unsent <- ownerEdit s viemChannelLink
|
||||
saveUnsent s unsent
|
||||
s' <- restart s
|
||||
recordsRound (records s')
|
||||
h <- mineSent s' 1 True
|
||||
stateOf s unsent `shouldReturn` Just (ESMined $ Just h)
|
||||
-- a sent edit whose transaction the node forgot while the service was down
|
||||
setNonce s 1
|
||||
sent <- signEdit s owner (channel "https://smp9.simplex.im/c#next") 1 deadline
|
||||
_ <- relayEdit (records s') {rsPendingAfter = shortWait} sent
|
||||
[_, raw] <- within $ awaitSent node 2
|
||||
dropTransaction node $ C.keccak256 raw
|
||||
s'' <- restart s'
|
||||
followTransactions (sender s'')
|
||||
(!! 2) <$> sentTransactions node `shouldReturn` raw
|
||||
h' <- mineSent s'' 3 True
|
||||
stateOf s sent `shouldReturn` Just (ESMined $ Just h')
|
||||
TxHash (C.keccak256 raw) `shouldBe` h'
|
||||
|
||||
testRepeatAfterRestart :: TVar ChaChaDRG -> IO ()
|
||||
testRepeatAfterRestart g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
r <- relay s req
|
||||
h <- mineSent s 1 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
s' <- restart s
|
||||
setFault node "eth_call" HttpFailure
|
||||
setFault node "eth_getBlockByNumber" HttpFailure
|
||||
relayEdit (records s') req `shouldReturn` EAApplied (Just h)
|
||||
length <$> sentTransactions node `shouldReturn` 1
|
||||
|
||||
testRestartExpiredApplied :: TVar ChaChaDRG -> IO ()
|
||||
testRestartExpiredApplied g = withSetup g $ \s@Setup {node} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
saveUnsent s req
|
||||
setBlock node 300 $ deadline + 1
|
||||
setNonce s 1
|
||||
setRecord s channelKey viemChannelLink
|
||||
s' <- restart s
|
||||
recordsRound (records s')
|
||||
stateOf s req `shouldReturn` Just (ESMined Nothing)
|
||||
sentTransactions node `shouldReturn` []
|
||||
|
||||
testExpiryFreesNonce :: TVar ChaChaDRG -> IO ()
|
||||
testExpiryFreesNonce g = withSetup g $ \s@Setup {node, records, sender} -> do
|
||||
req <- ownerEdit s viemChannelLink
|
||||
relayEdit records {rsPendingAfter = shortWait} req `shouldReturn` EAPending
|
||||
[raw] <- within $ awaitSent node 1
|
||||
-- the latest block is past the deadline, but not yet the block two confirmations deep
|
||||
setBlock node 300 deadline
|
||||
setBlock node 301 $ deadline + 1
|
||||
recordsRound records
|
||||
stateOf s req `shouldReturn` Just ESPending
|
||||
setBlock node 302 $ deadline + 2
|
||||
recordsRound records
|
||||
stateOf s req `shouldReturn` Just (ESReverted $ Just "SignatureExpired")
|
||||
relayEdit records req `shouldReturn` EAReverted (Just "SignatureExpired")
|
||||
dropTransaction node $ C.keccak256 raw
|
||||
followTransactions sender
|
||||
sentTransactions node `shouldReturn` [raw, raw]
|
||||
|
||||
-- http
|
||||
|
||||
testHttp :: TVar ChaChaDRG -> IO ()
|
||||
testHttp g = withSetup g $ \s@Setup {records} -> Warp.testWithApplication (pure $ recordsHandler records) $ \port -> do
|
||||
manager <- newManager defaultManagerSettings
|
||||
let post body = do
|
||||
req <- parseRequest $ "http://127.0.0.1:" <> show port <> "/api/name/records"
|
||||
resp <- httpLbs req {method = "POST", requestHeaders = [(hContentType, "application/json")], requestBody = RequestBodyLBS body} manager
|
||||
pure (statusCode $ responseStatus resp, J.decode $ responseBody resp :: Maybe J.Value)
|
||||
editBody value = J.encode $ KM.insert "value" (J.String value) $ requestJson [("key", J.String channelKey)]
|
||||
post "[]" `shouldReturn` (400, Just $ J.object ["error" .= ("bad_request" :: Text)])
|
||||
post (editBody "https://x/a") `shouldReturn` (400, Just $ J.object ["error" .= ("invalid_link" :: Text)])
|
||||
answer <- async $ post $ editBody viemChannelLink
|
||||
TxHash h <- mineSent s 1 True
|
||||
within (wait answer) `shouldReturn` (200, Just $ J.object ["tx" .= hex h])
|
||||
setNonce s 1
|
||||
next <- async . post . J.encode . editJson =<< signEdit s (owner s) (channel "https://smp9.simplex.im/c#next") 1 deadline
|
||||
_ <- mineSent s 2 False
|
||||
within (wait next) `shouldReturn` (409, Just $ J.object ["error" .= ("record_reverted" :: Text), "revert" .= J.Null])
|
||||
|
||||
-- fixtures
|
||||
|
||||
editJson :: EditRequest -> J.Object
|
||||
editJson EditRequest {erChange, erNonce, erDeadline, erSignature} =
|
||||
KM.fromList $
|
||||
[ ("labelHash", J.String aliceHashText),
|
||||
("nonce", J.toJSON erNonce),
|
||||
("deadline", J.toJSON erDeadline),
|
||||
("signature", J.String $ hex $ serializeSignature erSignature)
|
||||
]
|
||||
<> case erChange of
|
||||
RCSetText key value -> [("key", J.String key), ("value", J.String value)]
|
||||
RCClear -> [("clear", J.Bool True)]
|
||||
|
||||
longWait, shortWait :: Int
|
||||
longWait = 4000000
|
||||
shortWait = 200000
|
||||
|
||||
-- | The fields of viem's SetText edit but its key and value, with these added or replaced.
|
||||
requestJson :: [(J.Key, J.Value)] -> J.Object
|
||||
requestJson extra = KM.union (KM.fromList extra) $ KM.delete "key" $ KM.delete "value" $ editJson viemEdit
|
||||
|
||||
viemEdit :: EditRequest
|
||||
viemEdit = EditRequest {erLabelHash = alice, erChange = channel viemChannelLink, erNonce = 0, erDeadline = deadline, erSignature = viemSetSignature}
|
||||
|
||||
digestOf :: RecordChange -> Word64 -> Word64 -> ByteString
|
||||
digestOf change nonce dl = case change of
|
||||
RCSetText key value -> setTextDigest resolverDomain' aliceNode key value nonce dl
|
||||
RCClear -> clearRecordsDigest resolverDomain' aliceNode nonce dl
|
||||
|
||||
resolverDomain' :: Eip712Domain
|
||||
resolverDomain' = resolverDomain fakeChainId fakeResolver
|
||||
|
||||
alice :: LabelHash
|
||||
alice = labelHash "alicebakery"
|
||||
|
||||
aliceNode :: Node
|
||||
aliceNode = labelNode TLDSimplex alice
|
||||
|
||||
aliceHashText :: Text
|
||||
aliceHashText = hex $ unLabel alice
|
||||
|
||||
unLabel :: LabelHash -> ByteString
|
||||
unLabel (LabelHash h) = h
|
||||
|
||||
contactKey, channelKey :: Text
|
||||
contactKey = "simplex.contact"
|
||||
channelKey = "simplex.channel"
|
||||
|
||||
-- | An hour after the fake node's first block.
|
||||
deadline :: Word64
|
||||
deadline = 1767229200
|
||||
|
||||
nameExpiry, gracePeriod :: Word64
|
||||
nameExpiry = 1861833600
|
||||
gracePeriod = 7776000
|
||||
|
||||
registry :: Address
|
||||
registry = address "0x5fbdb2315678afecb367f032d93f642f64180aa3"
|
||||
|
||||
-- | The name code of BIP-39 entropy zero, the wallet of "abandon ... about".
|
||||
viemCode :: Text
|
||||
viemCode = "SN-00000-00000-00000-00000-00000-0VME2"
|
||||
|
||||
viemAddress :: Address
|
||||
viemAddress = address "0x9858effd232b4033e47d90003d41ec34ecaeda94"
|
||||
|
||||
viemChannelLink :: Text
|
||||
viemChannelLink = "https://smp8.simplex.im/c#Y8m3"
|
||||
|
||||
-- | viem: SetText(alicebakery.simplex, simplex.channel, viemChannelLink, 0, deadline)
|
||||
viemSetSignature :: RecoverableSignature
|
||||
viemSetSignature = either (error . show) id $ parseSignature "0xfce4c6865591609c07ef3d94f376187d8a20cd9d94b4affcb60b55e999038fbd4e45c42b5fafe66a8d4f756e99398602eacef777b4dce4afd0a767cddadf3ca91c"
|
||||
|
||||
-- | viem: ClearRecords(alicebakery.simplex, 1, deadline)
|
||||
viemClearSignature :: RecoverableSignature
|
||||
viemClearSignature = either (error . show) id $ parseSignature "0x0565f7e5a591c200fcc358c1d9e9293721d0b99f0285b589211203e311e8e6167ee822d664048870bdd8552263d25bbf45e7f4985fd3b8c3a19cfced44ef9d561c"
|
||||
|
||||
viemSetCall, viemClearCall :: ByteString
|
||||
viemSetCall = unhex "71a218a70f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a00000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006955c7100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000f73696d706c65782e6368616e6e656c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e68747470733a2f2f736d70382e73696d706c65782e696d2f632359386d3300000000000000000000000000000000000000000000000000000000000000000041fce4c6865591609c07ef3d94f376187d8a20cd9d94b4affcb60b55e999038fbd4e45c42b5fafe66a8d4f756e99398602eacef777b4dce4afd0a767cddadf3ca91c00000000000000000000000000000000000000000000000000000000000000"
|
||||
viemClearCall = unhex "6463e6140f4104919a21c22f566d9c7947c7c7d8addbf546c973851a7e449276ea54ec1a0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000006955c710000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000410565f7e5a591c200fcc358c1d9e9293721d0b99f0285b589211203e311e8e6167ee822d664048870bdd8552263d25bbf45e7f4985fd3b8c3a19cfced44ef9d561c00000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
unhex :: ByteString -> ByteString
|
||||
unhex = either error id . convertFromBase Base16
|
||||
|
||||
address :: ByteString -> Address
|
||||
address = either error id . strDecode
|
||||
@@ -16,6 +16,7 @@ import Bots.StoreService.NamesContractsTests
|
||||
import Bots.StoreService.NamesLabelTests
|
||||
import Bots.StoreService.NamesLinksTests
|
||||
import Bots.StoreService.NamesProfilesTests
|
||||
import Bots.StoreService.NamesRecordsTests
|
||||
import Bots.StoreService.NamesSenderTests
|
||||
import Bots.StoreService.NamesSignatureTests
|
||||
import Bots.StoreService.NamesStoreTests
|
||||
@@ -103,6 +104,7 @@ main = do
|
||||
namesLabelTests
|
||||
namesLinksTests
|
||||
namesProfilesTests
|
||||
namesRecordsTests
|
||||
namesSenderTests
|
||||
namesSignatureTests
|
||||
namesStoreTests
|
||||
|
||||
Reference in new issue
Block a user