mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 17:17:39 +00:00
store: limit a name's link edits per year
This commit is contained in:
1 parent
197961c18c
commit
57335c7a5b
15 files changed
+312
-41
No files matched your search
@@ -908,6 +908,26 @@ ini and a restart, with the same build. Codes for investors come from the group
|
||||
[From the group](#from-the-group)) or from `//issue name` in `--run-cli` (see
|
||||
[From the command line](#from-the-command-line)).
|
||||
|
||||
### Link edits
|
||||
|
||||
Each "Edit links" save is relayed by the service as `setTextWithSig` (or `clearRecordsWithSig`), a
|
||||
transaction whose gas it pays (see [The gas key](#the-gas-key)). `[names] record_edits_per_year`
|
||||
(default 3, from 1 to 100, read at startup) bounds them per name in any 365 days:
|
||||
|
||||
- each accepted request counts once, from when it is stored to be sent: one changed link, or a clear
|
||||
of both. The page sends one request per changed link, so saving both links takes two;
|
||||
- links set at registration ride on the register transaction and count nothing;
|
||||
- a request refused before any transaction, a dry run that reverts, a send the chain fails, and an
|
||||
edit that reverts on chain (or expires) count nothing, the last once it settles. A pending edit
|
||||
counts until then;
|
||||
- an exact resend of an accepted edit is answered as before and counts nothing more.
|
||||
|
||||
When a name has none left, `POST /api/name/records` answers
|
||||
`429 {"error": "edit_limit", "nextAt": <unix seconds>}` before any chain read, and the search answer
|
||||
for the name carries `recordEdits: {"left": 0, "nextAt": …}`, from which the page shows when the
|
||||
owner can edit again. Lowering the setting keeps the edits already counted. The hourly cap of 10
|
||||
relayed edits per name still applies, and still counts reverted edits.
|
||||
|
||||
### The gas key
|
||||
|
||||
`[chain] service_key` is the registrar key: an Ethereum account (EOA) that sends every names
|
||||
@@ -1053,7 +1073,8 @@ The gas below was measured on a local anvil deployment of the contracts (the nam
|
||||
used × the block's base fee plus tip, usually far less.
|
||||
- An order is one commit and one register: 0.6850 ETH at the cap with two links, 0.0627 ETH with
|
||||
none. The expected orders × 0.6850 ETH, plus 0.3026 ETH per expected link edit, pays for all of
|
||||
them even at the cap. The figures scale with `max_fee_per_gas`.
|
||||
them even at the cap. A name's link edits are at most `record_edits_per_year` (3) a year, see
|
||||
[Link edits](#link-edits). The figures scale with `max_fee_per_gas`.
|
||||
- `gas_limit_cap` (default 8,000,000) must stay above 6,791,417: a transaction whose estimate is
|
||||
above it is not sent.
|
||||
|
||||
|
||||
@@ -176,6 +176,8 @@ data NamesConfig = NamesConfig
|
||||
nMaxOrderMoves :: Int,
|
||||
-- | Only prepaid codes pay for names: every order that would take money for one is refused.
|
||||
nCodesOnly :: Bool,
|
||||
-- | The owner record edits relayed for one name in any 365 days, each a transaction whose gas the service pays.
|
||||
nRecordEditsPerYear :: Int,
|
||||
nTerms :: TermsConfig,
|
||||
-- | The operator's community names, keyed by the label hash's bytes, as search receives a name.
|
||||
nCommunity :: Map ByteString Text,
|
||||
@@ -278,6 +280,11 @@ maxTermsVersionLength = 32
|
||||
maxCommunityTextLength :: Int
|
||||
maxCommunityTextLength = 200
|
||||
|
||||
defaultRecordEditsPerYear, minRecordEditsPerYear, maxRecordEditsPerYear :: Int
|
||||
defaultRecordEditsPerYear = 3
|
||||
minRecordEditsPerYear = 1
|
||||
maxRecordEditsPerYear = 100
|
||||
|
||||
-- | The controller refuses a registration under 730 days.
|
||||
minNameYears :: Int
|
||||
minNameYears = 2
|
||||
@@ -378,7 +385,7 @@ knownSettings =
|
||||
("group", ["display_name", "description"]),
|
||||
("dev", ["accept_unverified_store_receipts", "check_store_during_coverage", "credit_test_purchases"]),
|
||||
("issuer", ["index", "private_key", "dev_keys"]),
|
||||
("names", ["tld", "terms_version", "terms_url", "years", "term_start", "term_slack_seconds", "reveal_margin_seconds", "max_order_moves", "codes_only"]),
|
||||
("names", ["tld", "terms_version", "terms_url", "years", "term_start", "term_slack_seconds", "reveal_margin_seconds", "max_order_moves", "codes_only", "record_edits_per_year"]),
|
||||
("chain", ["rpc_url", "chain_id", "controller", "sponsor", "base_registrar", "resolver", "service_key", "confirmations", "gas_limit_cap", "max_fee_per_gas"])
|
||||
]
|
||||
|
||||
@@ -605,10 +612,11 @@ parseConfig ini = do
|
||||
nRevealMarginSeconds <- atLeast 0 "names" "reveal_margin_seconds" 900
|
||||
nMaxOrderMoves <- atLeast 0 "names" "max_order_moves" 3
|
||||
nCodesOnly <- bool "names" "codes_only" False
|
||||
nRecordEditsPerYear <- recordEditsPerYear
|
||||
nTerms <- termsSetting
|
||||
nCommunity <- communityList nTLD
|
||||
nChain <- chainSection
|
||||
pure (Just NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nCodesOnly, nTerms, nCommunity, nChain})
|
||||
pure (Just NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nCodesOnly, nRecordEditsPerYear, nTerms, nCommunity, nChain})
|
||||
tldSetting =
|
||||
required "names" "tld" >>= \case
|
||||
"simplex" -> Right TLDSimplex
|
||||
@@ -676,6 +684,11 @@ parseConfig ini = do
|
||||
case T.stripPrefix "0x" v of
|
||||
Just h | T.length h == 2 * serviceKeyBytes, Right bytes <- convertFromBase Base16 (encodeUtf8 h) -> either (const bad) Right (mkPrivateKey (BA.convert (bytes :: B.ByteString)))
|
||||
_ -> bad
|
||||
recordEditsPerYear = do
|
||||
v <- num "names" "record_edits_per_year" defaultRecordEditsPerYear
|
||||
if v >= minRecordEditsPerYear && v <= maxRecordEditsPerYear
|
||||
then Right v
|
||||
else Left ("names.record_edits_per_year must be between " <> show minRecordEditsPerYear <> " and " <> show maxRecordEditsPerYear)
|
||||
sessionMinutes = do
|
||||
v <- num "stripe" "session_minutes" defaultSessionMinutes
|
||||
if v >= minSessionMinutes && v <= maxSessionMinutes
|
||||
|
||||
@@ -34,11 +34,11 @@ data NamesEnv = NamesEnv
|
||||
}
|
||||
|
||||
newNamesEnv :: ChatController -> UserId -> NamesConfig -> DBStore -> Turns -> Waiters -> [Provider] -> IO NamesEnv
|
||||
newNamesEnv neChat@ChatController {random} neUserId neConfig@NamesConfig {nTLD, nChain} neStore neTurns neWaiters neProviders = do
|
||||
newNamesEnv neChat@ChatController {random} neUserId neConfig@NamesConfig {nTLD, nChain, nRecordEditsPerYear} neStore neTurns neWaiters neProviders = do
|
||||
nePhases <- newPhases
|
||||
neChain <- newChainClient random nChain
|
||||
neContracts <- newContracts nTLD neChain
|
||||
neProfiles <- newProfiles defaultProfileLimits monotonicSeconds (chatProfile neChat neUserId) systemResolver
|
||||
neSender <- newSender random neChain neStore getCurrentTime
|
||||
neRecords <- newRecords neStore neTurns neContracts neChain neSender nTLD getCurrentTime
|
||||
neRecords <- newRecords neStore neTurns neContracts neChain neSender nTLD nRecordEditsPerYear getCurrentTime
|
||||
pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns, neChain, neContracts, neProfiles, neSender, neRecords, neWaiters, neProviders}
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
-- | 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),
|
||||
( Records (rsPendingAfter, rsEditsPerYear),
|
||||
EditRequest (..),
|
||||
EditAnswer (..),
|
||||
EditsLeft (..),
|
||||
newRecords,
|
||||
nameEditsLeft,
|
||||
relayEdit,
|
||||
runRecordEdits,
|
||||
recordsRound,
|
||||
@@ -23,6 +25,7 @@ import Control.Monad.Except (ExceptT (..), runExceptT)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Maybe (listToMaybe)
|
||||
import Data.Set (Set)
|
||||
import qualified Data.Set as S
|
||||
import Data.Text (Text)
|
||||
@@ -54,6 +57,7 @@ data Records = Records
|
||||
rsChain :: ChainClient,
|
||||
rsSender :: Sender,
|
||||
rsTLD :: SimplexTLD,
|
||||
rsEditsPerYear :: Int,
|
||||
rsNow :: IO UTCTime,
|
||||
-- | microseconds a request waits for its edit's outcome
|
||||
rsPendingAfter :: Int,
|
||||
@@ -87,6 +91,12 @@ data EditAnswer
|
||||
EAUnavailable
|
||||
| -- | the per-name cap, with the seconds until another edit fits
|
||||
EACapped Int
|
||||
| -- | the name's yearly limit is used up until this time
|
||||
EALimited UTCTime
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The edits a name has left in its window, and when the next one returns; Nothing while none counts.
|
||||
data EditsLeft = EditsLeft {elLeft :: Int, elNextAt :: Maybe UTCTime}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | What the chain shows of an edit none of whose transactions mined.
|
||||
@@ -108,27 +118,46 @@ editCap = 10
|
||||
editCapWindow :: NominalDiffTime
|
||||
editCapWindow = 3600
|
||||
|
||||
-- | The window of the yearly limit, in 365-day years as the names' terms count them.
|
||||
editLimitWindow :: NominalDiffTime
|
||||
editLimitWindow = 365 * 86400
|
||||
|
||||
recordsInterval :: Int
|
||||
recordsInterval = 4000000
|
||||
|
||||
newRecords :: DBStore -> Turns -> Contracts -> ChainClient -> Sender -> SimplexTLD -> IO UTCTime -> IO Records
|
||||
newRecords rsStore rsTurns rsContracts rsChain rsSender rsTLD rsNow = do
|
||||
newRecords :: DBStore -> Turns -> Contracts -> ChainClient -> Sender -> SimplexTLD -> Int -> IO UTCTime -> IO Records
|
||||
newRecords rsStore rsTurns rsContracts rsChain rsSender rsTLD rsEditsPerYear 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}
|
||||
pure Records {rsStore, rsTurns, rsContracts, rsChain, rsSender, rsTLD, rsEditsPerYear, rsNow, rsPendingAfter = editPendingAfter, rsClaimed, rsStalled, rsChanges}
|
||||
|
||||
-- | An identical repeat of a sent edit is answered from it, before any chain read.
|
||||
-- | A pending or mined edit counts. One refused, never sent, or reverted on chain does not.
|
||||
nameEditsLeft :: DB.Connection -> Int -> LabelHash -> UTCTime -> IO EditsLeft
|
||||
nameEditsLeft db perYear lh now = do
|
||||
times <- countedEditTimes db lh $ addUTCTime (negate editLimitWindow) now
|
||||
let counted = length times
|
||||
-- the oldest edit returns one, unless a lowered limit is overrun and later ones must go too
|
||||
pure EditsLeft {elLeft = max 0 $ perYear - counted, elNextAt = addUTCTime editLimitWindow <$> listToMaybe (drop (counted - perYear) times)}
|
||||
|
||||
-- | When the name's yearly limit is used up, the time the next edit fits.
|
||||
usedUpUntil :: EditsLeft -> Maybe UTCTime
|
||||
usedUpUntil EditsLeft {elLeft, elNextAt} = if elLeft == 0 then elNextAt else Nothing
|
||||
|
||||
-- | An identical repeat of a sent edit is answered from it, and the yearly limit is checked, before any chain read.
|
||||
-- The wait for an outcome is bounded from the request's arrival, so the checks and the send count toward it.
|
||||
relayEdit :: Records -> EditRequest -> IO EditAnswer
|
||||
relayEdit rs@Records {rsPendingAfter} req@EditRequest {erSignature} = do
|
||||
relayEdit rs@Records {rsPendingAfter, rsEditsPerYear, rsNow} req@EditRequest {erLabelHash, erSignature} = do
|
||||
timer <- registerDelay rsPendingAfter
|
||||
recoverAddress erSignature digest >>= \case
|
||||
Left _ -> pure EARefused
|
||||
Right signer ->
|
||||
withStore rs (`getRecordEditByBody` bodyHash) >>= \case
|
||||
Just RecordEdit {reId} -> awaitEdit rs timer reId
|
||||
Nothing -> newEdit rs timer req signer bodyHash
|
||||
Nothing -> do
|
||||
now <- rsNow
|
||||
withStore rs (\db -> usedUpUntil <$> nameEditsLeft db rsEditsPerYear erLabelHash now)
|
||||
>>= maybe (newEdit rs timer req signer bodyHash) (pure . EALimited)
|
||||
where
|
||||
digest = editDigest rs req
|
||||
bodyHash = sha256Hash $ digest <> serializeSignature erSignature
|
||||
@@ -140,7 +169,7 @@ newEdit rs timer req signer bodyHash =
|
||||
Right False -> pure EARefused
|
||||
Right True ->
|
||||
insertEdit rs req signer bodyHash >>= \case
|
||||
Left wait -> pure $ EACapped wait
|
||||
Left answer -> pure answer
|
||||
Right (Just editId) -> sendNew rs timer 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 timer . reId)
|
||||
@@ -157,17 +186,21 @@ chainAccepts Records {rsChain, rsContracts} EditRequest {erLabelHash, erNonce, e
|
||||
| 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 =
|
||||
-- | Left when the name's yearly limit or hourly cap is used up, checked again under the name's turn against a concurrent edit.
|
||||
-- Right Nothing when a pending or mined edit holds the nonce.
|
||||
insertEdit :: Records -> EditRequest -> Address -> ByteString -> IO (Either EditAnswer (Maybe Int64))
|
||||
insertEdit Records {rsStore, rsTurns, rsEditsPerYear, rsNow} EditRequest {erLabelHash, erChange, erNonce, erDeadline, erSignature} signer bodyHash =
|
||||
withLabelTurn rsTurns erLabelHash $ do
|
||||
now <- rsNow
|
||||
withTransaction rsStore $ \db -> do
|
||||
limited <- usedUpUntil <$> nameEditsLeft db rsEditsPerYear erLabelHash now
|
||||
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
|
||||
case limited of
|
||||
Just nextAt -> pure $ Left $ EALimited nextAt
|
||||
Nothing
|
||||
| relayed >= editCap -> pure $ Left $ EACapped $ max 1 $ ceiling $ diffUTCTime (addUTCTime editCapWindow $ times !! (relayed - editCap)) now
|
||||
| otherwise -> Right <$> insertRecordEdit db edit now
|
||||
where
|
||||
edit = NewRecordEdit {nreLabelHash = erLabelHash, nreOwner = signer, nreNonce = erNonce, nreChange = erChange, nreDeadline = erDeadline, nreSignature = erSignature, nreBodyHash = bodyHash}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ module StoreService.Names.Store
|
||||
pendingEditIds,
|
||||
deleteUnsentEdit,
|
||||
relayedEditTimes,
|
||||
countedEditTimes,
|
||||
)
|
||||
where
|
||||
|
||||
@@ -1511,6 +1512,15 @@ relayedEditTimes db (LabelHash lh) since =
|
||||
"SELECT created_at FROM sx_badge_service_name_record_edits WHERE label_hash = ? AND created_at > ? ORDER BY created_at"
|
||||
(Binary lh, truncateToSecond since)
|
||||
|
||||
-- | When each pending or mined edit of the name was made since the given time, oldest first: the edits its yearly limit counts.
|
||||
countedEditTimes :: DB.Connection -> LabelHash -> UTCTime -> IO [UTCTime]
|
||||
countedEditTimes db (LabelHash lh) since =
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
"SELECT created_at FROM sx_badge_service_name_record_edits WHERE label_hash = ? AND created_at > ? AND state IN (?,?) ORDER BY created_at"
|
||||
(Binary lh, truncateToSecond since, pendingEdit, minedEdit)
|
||||
|
||||
int64 :: Word64 -> Int64
|
||||
int64 = fromIntegral
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
module StoreService.Web.Names (Route (..), namesRoutes, namesApp) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
import Network.HTTP.Types (Method)
|
||||
import Network.Wai (Request, ResponseReceived, pathInfo)
|
||||
import StoreService.Config (ServiceConfig)
|
||||
@@ -31,7 +32,7 @@ namesRoutes cfg env@NamesEnv {neConfig, neStore, neContracts, neProfiles, neReco
|
||||
["api", "code", "check"] -> Just Route {rMethod = "POST", rLimit = codeLimit, rHandler = codeCheckHandler neStore}
|
||||
["api", "link", "preview"] -> Just Route {rMethod = "POST", rLimit = previewLimit, rHandler = previewHandler neProfiles}
|
||||
-- below every fixed /api/name/<word> route, which it would otherwise match
|
||||
["api", "name", hash] -> Just Route {rMethod = "GET", rLimit = searchLimit, rHandler = searchHandler neConfig neStore neContracts neProfiles hash}
|
||||
["api", "name", hash] -> Just Route {rMethod = "GET", rLimit = searchLimit, rHandler = searchHandler neConfig neStore neContracts neProfiles getCurrentTime hash}
|
||||
-- above /api/registration/:id, which it would otherwise match
|
||||
["api", "registration", "find"] -> Just Route {rMethod = "POST", rLimit = findLimit, rHandler = findHandler (ordersEnv env)}
|
||||
["api", "registration", rid] -> Just Route {rMethod = "GET", rLimit = readLimit, rHandler = registrationHandler (ordersEnv env) holdMicros rid}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module StoreService.Web.Names.Records (EditInvalid (..), recordsHandler, editRequest) where
|
||||
module StoreService.Web.Names.Records (EditInvalid (..), recordsHandler, editRequest, editsLeftJSON) where
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Data.Aeson ((.:), (.:?), (.=))
|
||||
@@ -10,13 +10,15 @@ 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, status409, status504)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
||||
import Network.HTTP.Types (status200, status409, status429, 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.Records (EditAnswer (..), EditRequest (..), EditsLeft (..), Records, relayEdit)
|
||||
import StoreService.Names.Registration (channelRecordKey, contactRecordKey)
|
||||
import StoreService.Names.Signatures (parseSignature)
|
||||
import StoreService.Names.Store (RecordChange (..))
|
||||
@@ -64,3 +66,11 @@ editResponse = \case
|
||||
EARefused -> badRequest
|
||||
EAUnavailable -> providerUnavailable
|
||||
EACapped seconds -> rateLimited seconds
|
||||
EALimited nextAt -> jsonResponse status429 $ J.object ["error" .= ("edit_limit" :: Text), "nextAt" .= unixSeconds nextAt]
|
||||
|
||||
-- | The search answer's recordEdits.
|
||||
editsLeftJSON :: EditsLeft -> J.Value
|
||||
editsLeftJSON EditsLeft {elLeft, elNextAt} = J.object ["left" .= elLeft, "nextAt" .= (unixSeconds <$> elNextAt)]
|
||||
|
||||
unixSeconds :: UTCTime -> Integer
|
||||
unixSeconds = floor . utcTimeToPOSIXSeconds
|
||||
@@ -15,6 +15,7 @@ import qualified Data.Aeson.Types as JT
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (isDigit)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
||||
import Network.HTTP.Types (status200)
|
||||
@@ -30,9 +31,11 @@ import StoreService.Names.Community (communityJSON, termsJSON)
|
||||
import StoreService.Names.Contracts
|
||||
import StoreService.Names.Links (LinkKind (..), linkKindOf)
|
||||
import StoreService.Names.Profiles (Entity, Profiles, linkEntity, linkPreview)
|
||||
import StoreService.Names.Records (nameEditsLeft)
|
||||
import StoreService.Names.Store (isLabelRegistering)
|
||||
import StoreService.Names.View (addressText, tldText)
|
||||
import StoreService.Web.Http (Respond, badRequest, invalidLink, invalidName, jsonResponse, maxBodyBytes, providerUnavailable, readBoundedBody)
|
||||
import StoreService.Web.Names.Records (editsLeftJSON)
|
||||
import System.Timeout (timeout)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
@@ -44,15 +47,15 @@ data Found
|
||||
| FRegistered RegisteredName (Maybe NameRecords)
|
||||
|
||||
-- | The chain reads of one search share one deadline, so a node that answers slowly fails the search once.
|
||||
searchHandler :: NamesConfig -> DBStore -> Contracts -> Profiles -> Text -> Request -> Respond -> IO ResponseReceived
|
||||
searchHandler cfg@NamesConfig {nYears} st ct ps hashText req respond = case LabelHash <$> bytes32FromHex hashText of
|
||||
searchHandler :: NamesConfig -> DBStore -> Contracts -> Profiles -> IO UTCTime -> Text -> Request -> Respond -> IO ResponseReceived
|
||||
searchHandler cfg@NamesConfig {nYears} st ct ps clock hashText req respond = case LabelHash <$> bytes32FromHex hashText of
|
||||
Nothing -> respond badRequest
|
||||
Just lh -> case queryLength req of
|
||||
Nothing -> respond invalidName
|
||||
Just len -> do
|
||||
registering <- withTransaction st (`isLabelRegistering` lh)
|
||||
timeout (ctDeadline ct) (runExceptT $ found lh len registering) >>= \case
|
||||
Just (Right f) -> respond =<< searchAnswer cfg ps lh f
|
||||
Just (Right f) -> respond =<< searchAnswer cfg st ps clock lh f
|
||||
Just (Left e) -> unavailable $ tshow e
|
||||
Nothing -> unavailable "timed out"
|
||||
where
|
||||
@@ -77,8 +80,8 @@ queryLength req = case join $ lookup "length" $ queryString req of
|
||||
| n >= toInteger minNameLength && n <= toInteger maxNameLength = Just $ fromInteger n
|
||||
| otherwise = Nothing
|
||||
|
||||
searchAnswer :: NamesConfig -> Profiles -> LabelHash -> Found -> IO Response
|
||||
searchAnswer cfg@NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cChainId, cResolver, cSponsor}} ps lh = \case
|
||||
searchAnswer :: NamesConfig -> DBStore -> Profiles -> IO UTCTime -> LabelHash -> Found -> IO Response
|
||||
searchAnswer cfg@NamesConfig {nTLD, nYears, nTermStart, nRecordEditsPerYear, nChain = ChainConfig {cChainId, cResolver, cSponsor}} st ps clock lh = \case
|
||||
FAvailable price ->
|
||||
ok $
|
||||
["status" .= ("available" :: Text), "price" .= price, "currency" .= ("usd" :: Text), "years" .= nYears, "termStart" .= termStart]
|
||||
@@ -89,9 +92,11 @@ searchAnswer cfg@NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cC
|
||||
FReserved -> ok ["status" .= ("reserved" :: Text)]
|
||||
FRegistered RegisteredName {rnOwner, rnResolver, rnExpiresAt, rnGracePeriod} records_ -> do
|
||||
entities <- maybe (pure []) (recordEntities ps) records_
|
||||
-- only a name on the default resolver is edited here
|
||||
edits <- mapM (const editsLeft) records_
|
||||
ok $
|
||||
["status" .= ("registered" :: Text), "entities" .= entities, "records" .= (recordsJSON <$> records_)]
|
||||
<> ["owner" .= addressText rnOwner, "resolver" .= addressText rnResolver, "recordsNonce" .= (rdNonce <$> records_)]
|
||||
<> ["owner" .= addressText rnOwner, "resolver" .= addressText rnResolver, "recordsNonce" .= (rdNonce <$> records_), "recordEdits" .= (editsLeftJSON <$> edits)]
|
||||
<> ["expiresAt" .= rnExpiresAt, "gracePeriod" .= rnGracePeriod]
|
||||
<> chain
|
||||
where
|
||||
@@ -100,6 +105,7 @@ searchAnswer cfg@NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cC
|
||||
chain = ["chainId" .= cChainId, "tld" .= tldText nTLD]
|
||||
termStart = floor (utcTimeToPOSIXSeconds nTermStart) :: Integer
|
||||
recordsJSON NameRecords {rdContact, rdChannel} = J.object ["contact" .= rdContact, "channel" .= rdChannel]
|
||||
editsLeft = clock >>= \now -> withTransaction st $ \db -> nameEditsLeft db nRecordEditsPerYear lh now
|
||||
|
||||
-- | Contact entities first. A record may hold several links joined as the app joins them; a value that is not
|
||||
-- a short link of its record's kind makes no entity, and neither does a link past the first few of its kind.
|
||||
|
||||
@@ -118,6 +118,9 @@ idle_seconds = 60
|
||||
;max_order_moves = 3
|
||||
; on: only prepaid codes register names, and every paid name order answers 403 codes_only
|
||||
;codes_only = off
|
||||
; the owner's link edits relayed for one name in any 365 days, each a transaction whose gas the
|
||||
; service pays; 1 to 100. A request refused, never sent or reverted on chain does not count
|
||||
;record_edits_per_year = 3
|
||||
;
|
||||
; community names, with [names] only: one line per name, `<label> = <what it is for>`, the label in
|
||||
; lower case and listed once, the text 1 to 200 characters without a full stop at the end, which
|
||||
|
||||
@@ -103,6 +103,9 @@ private_key = replace-me
|
||||
;max_order_moves = 3
|
||||
; on: only prepaid codes register names, and every paid name order answers 403 codes_only
|
||||
;codes_only = off
|
||||
; the owner's link edits relayed for one name in any 365 days, each a transaction whose gas the
|
||||
; service pays; 1 to 100. A request refused, never sent or reverted on chain does not count
|
||||
;record_edits_per_year = 3
|
||||
;
|
||||
; community names, with [names] only: one line per name, `<label> = <what it is for>`, the label in
|
||||
; lower case and listed once, the text 1 to 200 characters without a full stop at the end, which
|
||||
|
||||
@@ -659,6 +659,7 @@ namesConfigTests = describe "StoreService names: config" $ do
|
||||
it "keeps the RPC URL and the service key out of errors and the shown config" testNamesSecretsHidden
|
||||
it "names a [names] or [chain] setting nothing reads, and still boots" testNamesUnknownKeys
|
||||
it "reads codes_only on and off as a known setting, off when absent, and refuses any other value" testNamesCodesOnly
|
||||
it "reads record_edits_per_year as a known setting, 3 when absent, and refuses a value outside 1-100" testNamesRecordEdits
|
||||
it "accepts [issuer] dev_keys on chain 31337 only, with [names] on" testDevKeysLocalChain
|
||||
it "refuses a malformed dev_keys entry or an index named twice, quoting no secret" testDevKeysMalformed
|
||||
it "accepts [issuer] dev_keys beside Stripe test keys and a BTCPay on loopback" testDevKeysTestPayments
|
||||
@@ -874,6 +875,21 @@ testNamesCodesOnly = do
|
||||
(value, nCodesOnly) `shouldBe` (value, expected)
|
||||
namesRefusal (namesSection <> ["codes_only = yes"]) chainSection `shouldReturn` "codes_only must be on or off, not yes"
|
||||
|
||||
testNamesRecordEdits :: IO ()
|
||||
testNamesRecordEdits = do
|
||||
NamesConfig {nRecordEditsPerYear = absent} <- parsedNames namesSection chainSection
|
||||
absent `shouldBe` 3
|
||||
forM_ [1, 100] $ \n -> do
|
||||
let ns = namesSection <> ["record_edits_per_year = " <> T.pack (show n)]
|
||||
withIni (namesIni ns chainSection) $ \p -> do
|
||||
Right ini <- readIniFile p
|
||||
unknownKeys ini `shouldBe` []
|
||||
NamesConfig {nRecordEditsPerYear} <- parsedNames ns chainSection
|
||||
(n, nRecordEditsPerYear) `shouldBe` (n, n)
|
||||
forM_ ["0", "101", "-3"] $ \v ->
|
||||
namesRefusal (namesSection <> ["record_edits_per_year = " <> v]) chainSection `shouldReturn` "names.record_edits_per_year must be between 1 and 100"
|
||||
namesRefusal (namesSection <> ["record_edits_per_year = three"]) chainSection `shouldReturn` "record_edits_per_year must be a whole number"
|
||||
|
||||
-- | Names on the local chain with no payment provider, as a run with dev keys has.
|
||||
localNamesIni :: [T.Text] -> T.Text
|
||||
localNamesIni cs = T.unlines (["[listener]"] <> namesSection <> cs)
|
||||
|
||||
@@ -329,6 +329,7 @@ testNamesConfig node =
|
||||
nRevealMarginSeconds = 900,
|
||||
nMaxOrderMoves = 3,
|
||||
nCodesOnly = False,
|
||||
nRecordEditsPerYear = 3,
|
||||
nTerms = TermsConfig {tVersion = termsVersion, tUrl = termsUrl},
|
||||
nCommunity = M.empty,
|
||||
nChain = fakeChainConfig node
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 Control.Monad (forM, forM_, when)
|
||||
import Crypto.Number.Serialize (i2ospOf_, os2ip)
|
||||
import qualified Crypto.PubKey.ECC.Types as ECC
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
@@ -26,6 +26,7 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
|
||||
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
||||
import Data.Word (Word64)
|
||||
import Network.HTTP.Client (RequestBody (..), defaultManagerSettings, httpLbs, method, newManager, parseRequest, requestBody, requestHeaders, responseBody, responseHeaders, responseStatus)
|
||||
import Network.HTTP.Types (hContentType, statusCode)
|
||||
@@ -99,9 +100,15 @@ namesRecordsTests = describe "StoreService names: records" $ do
|
||||
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 "yearly limit" $ do
|
||||
it "refuses a name's fourth edit within 365 days with when the first leaves, before any chain read, row or gas" $ testYearLimit g
|
||||
it "takes an edit again once the oldest counted one is 365 days old" $ testYearWindow g
|
||||
it "answers an accepted edit's resend as before once the limit is used up, counting it once" $ testYearResend g
|
||||
it "counts a pending edit, and no edit refused, never sent, or reverted on chain" $ testYearFailedRelays g
|
||||
describe "http" $ do
|
||||
it "answers the contract's statuses and bodies" $ testHttp g
|
||||
it "answers 503, 504, and 429 with the real wait in Retry-After" $ testHttpUnsettled g
|
||||
it "answers 429 edit_limit with nextAt in unix seconds and no Retry-After" $ testHttpEditLimit g
|
||||
|
||||
data Setup = Setup
|
||||
{ node :: FakeEthNode,
|
||||
@@ -130,7 +137,7 @@ 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)
|
||||
rs <- newRecords store turns contracts chain sender TLDSimplex editsPerYear (readTVarIO clock)
|
||||
pure (sender, rs {rsPendingAfter = longWait})
|
||||
|
||||
-- | A new sender and lane over the same store and node, as after the service restarts.
|
||||
@@ -323,8 +330,9 @@ testRefusedRepeat g = withSetup g $ \s@Setup {owner} -> do
|
||||
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}}
|
||||
testCap g = withSetup g $ \s0@Setup {owner} -> do
|
||||
let s = roomy s0
|
||||
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
|
||||
@@ -642,6 +650,105 @@ testExpiryFreesNonce g = withSetup g $ \s@Setup {node, records, sender} -> do
|
||||
followTransactions sender
|
||||
sentTransactions node `shouldReturn` [raw, raw]
|
||||
|
||||
-- yearly limit
|
||||
|
||||
testYearLimit :: TVar ChaChaDRG -> IO ()
|
||||
testYearLimit g = withSetup g $ \s@Setup {node, records, owner} -> do
|
||||
_ <- appliedEdits s 3
|
||||
setNonce s 3
|
||||
fourth <- signEdit s owner (channel viemChannelLink) 3 deadline
|
||||
-- any chain read would answer provider_unavailable
|
||||
setFault node "eth_call" HttpFailure
|
||||
setFault node "eth_getBlockByNumber" HttpFailure
|
||||
relayEdit records fourth `shouldReturn` EALimited (addUTCTime year t0)
|
||||
editRows s `shouldReturn` 3
|
||||
length <$> sentTransactions node `shouldReturn` 3
|
||||
|
||||
testYearWindow :: TVar ChaChaDRG -> IO ()
|
||||
testYearWindow g = withSetup g $ \s@Setup {records, owner} -> do
|
||||
-- at t0, a day later and two days later
|
||||
_ <- appliedEdits s 3
|
||||
setNonce s 3
|
||||
fourth <- signEdit s owner (channel viemChannelLink) 3 deadline
|
||||
passTime s $ year - 2 * day - 1
|
||||
relayEdit records fourth `shouldReturn` EALimited (addUTCTime year t0)
|
||||
passTime s 1
|
||||
r <- relay s fourth
|
||||
h <- mineSent s 4 True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
setNonce s 4
|
||||
fifth <- signEdit s owner (channel "https://smp9.simplex.im/c#fifth") 4 deadline
|
||||
relayEdit records fifth `shouldReturn` EALimited (addUTCTime (year + day) t0)
|
||||
editRows s `shouldReturn` 4
|
||||
|
||||
testYearResend :: TVar ChaChaDRG -> IO ()
|
||||
testYearResend g = withSetup g $ \s@Setup {node, records} -> do
|
||||
sent <- appliedEdits s 3
|
||||
forM_ sent $ \(req, h) -> relayEdit records req `shouldReturn` EAApplied (Just h)
|
||||
editRows s `shouldReturn` 3
|
||||
length <$> sentTransactions node `shouldReturn` 3
|
||||
|
||||
testYearFailedRelays :: TVar ChaChaDRG -> IO ()
|
||||
testYearFailedRelays g = withSetup g $ \s@Setup {node, records, owner} -> do
|
||||
-- reverted on chain, its nonce unused
|
||||
r <- relay s =<< ownerEdit s viemChannelLink
|
||||
_ <- mineSent s 1 False
|
||||
answerOf r `shouldReturn` EAReverted Nothing
|
||||
-- refused: another signer, then a passed deadline
|
||||
other <- newEphemeralWallet g
|
||||
(relayEdit records =<< signEdit s other (channel viemChannelLink) 0 deadline) `shouldReturn` EARefused
|
||||
(relayEdit records =<< signEdit s owner (channel viemChannelLink) 0 (genesis - 1)) `shouldReturn` EARefused
|
||||
-- never sent: the dry run reverted, then the fee read failed
|
||||
setFault node "eth_estimateGas" $ revertFault $ selector "InvalidSignature()"
|
||||
(relayEdit records =<< signEdit s owner (channel "https://smp9.simplex.im/c#dry") 0 deadline) `shouldReturn` EAReverted (Just "InvalidSignature")
|
||||
clearFault node "eth_estimateGas"
|
||||
setFault node "eth_maxPriorityFeePerGas" HttpFailure
|
||||
(relayEdit records =<< signEdit s owner (channel "https://smp9.simplex.im/c#fee") 0 deadline) `shouldReturn` EAUnavailable
|
||||
clearFault node "eth_maxPriorityFeePerGas"
|
||||
-- two mined and one pending use the limit up
|
||||
_ <- appliedEditsFrom s 0 1 2
|
||||
setNonce s 2
|
||||
unsettled <- signEdit s owner (channel "https://smp9.simplex.im/c#pending") 2 deadline
|
||||
relayEdit records {rsPendingAfter = shortWait} unsettled `shouldReturn` EAPending
|
||||
setNonce s 3
|
||||
fourth <- signEdit s owner (channel "https://smp9.simplex.im/c#fourth") 3 deadline
|
||||
relayEdit records fourth `shouldReturn` EALimited (addUTCTime year t0)
|
||||
-- the pending edit reverts, which gives its edit back
|
||||
_ <- mineSent s 4 False
|
||||
stateOf s unsettled >>= (`shouldSatisfy` \case Just (ESReverted _) -> True; _ -> False)
|
||||
r' <- relay s fourth
|
||||
h <- mineSent s 5 True
|
||||
answerOf r' `shouldReturn` EAApplied (Just h)
|
||||
|
||||
-- | The owner's channel edits from nonce 0, each relayed, mined and answered, a day apart from t0; their requests and hashes.
|
||||
appliedEdits :: Setup -> Int -> IO [(EditRequest, TxHash)]
|
||||
appliedEdits s = appliedEditsFrom s 0 0
|
||||
|
||||
-- | As 'appliedEdits', from a nonce, after this many transactions were sent.
|
||||
appliedEditsFrom :: Setup -> Word64 -> Int -> Int -> IO [(EditRequest, TxHash)]
|
||||
appliedEditsFrom s@Setup {owner} from sentBefore n =
|
||||
forM [0 .. n - 1] $ \i -> do
|
||||
let nonce = from + fromIntegral i
|
||||
when (i > 0) $ passTime s day
|
||||
setNonce s nonce
|
||||
req <- signEdit s owner (channel $ "https://smp9.simplex.im/c#e" <> T.pack (show nonce)) nonce deadline
|
||||
r <- relay s req
|
||||
h <- mineSent s (sentBefore + i + 1) True
|
||||
answerOf r `shouldReturn` EAApplied (Just h)
|
||||
pure (req, h)
|
||||
|
||||
-- | Room in the yearly limit for the tests of the hourly cap.
|
||||
roomy :: Setup -> Setup
|
||||
roomy s = s {records = (records s) {rsEditsPerYear = 100}}
|
||||
|
||||
day, year :: NominalDiffTime
|
||||
day = 86400
|
||||
year = 365 * day
|
||||
|
||||
yearSeconds, t0Seconds :: Int
|
||||
yearSeconds = 365 * 86400
|
||||
t0Seconds = floor $ utcTimeToPOSIXSeconds t0
|
||||
|
||||
-- http
|
||||
|
||||
testHttp :: TVar ChaChaDRG -> IO ()
|
||||
@@ -658,8 +765,8 @@ testHttp g = withSetup g $ \s@Setup {records} -> withRecordsApp records $ \post
|
||||
within (wait next) `shouldReturn` (409, Just $ J.object ["error" .= ("record_reverted" :: Text), "revert" .= J.Null], Nothing)
|
||||
|
||||
testHttpUnsettled :: TVar ChaChaDRG -> IO ()
|
||||
testHttpUnsettled g = withSetup g $ \s@Setup {node, records, owner} -> do
|
||||
let quick = records {rsPendingAfter = shortWait}
|
||||
testHttpUnsettled g = withSetup g $ \s@Setup {node, owner} -> do
|
||||
let quick = (records $ roomy s) {rsPendingAfter = shortWait}
|
||||
edit n = signEdit s owner (channel viemChannelLink) n deadline
|
||||
withRecordsApp quick $ \post -> do
|
||||
setFault node "eth_call" HttpFailure
|
||||
@@ -672,6 +779,14 @@ testHttpUnsettled g = withSetup g $ \s@Setup {node, records, owner} -> do
|
||||
setNonce s 10
|
||||
(post . J.encode . editJson =<< edit 10) `shouldReturn` (429, Just $ errorJson "rate_limited", Just "3600")
|
||||
|
||||
testHttpEditLimit :: TVar ChaChaDRG -> IO ()
|
||||
testHttpEditLimit g = withSetup g $ \s@Setup {records, owner} -> do
|
||||
_ <- appliedEdits s 3
|
||||
setNonce s 3
|
||||
withRecordsApp records $ \post ->
|
||||
(post . J.encode . editJson =<< signEdit s owner (channel viemChannelLink) 3 deadline)
|
||||
`shouldReturn` (429, Just $ J.object ["error" .= ("edit_limit" :: Text), "nextAt" .= (t0Seconds + yearSeconds)], Nothing)
|
||||
|
||||
-- | Posts a body to the records handler, answering the status, the JSON body and Retry-After.
|
||||
withRecordsApp :: Records -> ((LB.ByteString -> IO (Int, Maybe J.Value, Maybe ByteString)) -> IO a) -> IO a
|
||||
withRecordsApp rs action = Warp.testWithApplication (pure $ recordsHandler rs) $ \port -> do
|
||||
@@ -698,6 +813,10 @@ editJson EditRequest {erChange, erNonce, erDeadline, erSignature} =
|
||||
RCSetText key value -> [("key", J.String key), ("value", J.String value)]
|
||||
RCClear -> [("clear", J.Bool True)]
|
||||
|
||||
-- | The service's default.
|
||||
editsPerYear :: Int
|
||||
editsPerYear = 3
|
||||
|
||||
longWait, shortWait :: Int
|
||||
longWait = 4000000
|
||||
shortWait = 200000
|
||||
|
||||
@@ -27,7 +27,7 @@ import Bots.StoreService.NamesContractsTests
|
||||
year,
|
||||
)
|
||||
import Bots.StoreService.NamesProfilesTests (bakeryLink, jpeg, newsLink)
|
||||
import Bots.StoreService.NamesStoreTests (issue, order, ownerA, placeCode, t0, usable)
|
||||
import Bots.StoreService.NamesStoreTests (issue, newEdit, order, ownerA, placeCode, setChannel, t0, usable)
|
||||
import Bots.StoreService.WebTests (withServiceStore)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad (forM_, replicateM_)
|
||||
@@ -44,7 +44,8 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Time.Calendar (fromGregorian)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime (..), diffUTCTime, getCurrentTime)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime)
|
||||
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
||||
import Network.HTTP.Client (Manager, Request, RequestBody (..), defaultManagerSettings, httpLbs, method, newManager, parseRequest, requestBody, responseBody, responseHeaders, responseStatus)
|
||||
import qualified Network.HTTP.Client as HTTP
|
||||
import Network.HTTP.Types.Status (statusCode)
|
||||
@@ -59,7 +60,7 @@ import StoreService.Names.Abi (AbiValue (..), abiBytes, abiTuple, bytes32FromHex
|
||||
import StoreService.Names.Contracts (Contracts (..))
|
||||
import StoreService.Names.Phases (RegistrationId (..))
|
||||
import StoreService.Names.Profiles (ChatKind (..), HostResolver, LinkProfile (..), ProfileFetcher, ProfileLimits (..), defaultProfileLimits, monotonicSeconds, newProfiles)
|
||||
import StoreService.Names.Store (NamePhase (..), NameRegistration (..), VersionWrite (..), getRegistration, updateRegistration)
|
||||
import StoreService.Names.Store (EditState (..), NamePhase (..), NameRegistration (..), NewRecordEdit (..), VersionWrite (..), getRegistration, insertRecordEdit, settleRecordEdit, updateRegistration)
|
||||
import StoreService.Web.Http (newHttpEnv, notFound, previewLimit, searchLimit)
|
||||
import StoreService.Web.Names (Route (..), namesApp)
|
||||
import StoreService.Web.Names.Search (previewHandler, searchHandler)
|
||||
@@ -77,6 +78,7 @@ namesSearchTests = describe "StoreService names: search" $ do
|
||||
it "makes one entity per short link of its record's kind, splitting on ';', and none from other values" $ testRecordLinks g
|
||||
it "makes entities of the first 5 short links of a record only, fetching no other" $ testRecordLinksCap g
|
||||
it "reads no records from another resolver" $ testOtherResolver g
|
||||
it "answers a registered name's record edits left and when the next one returns, counting pending and mined edits of 365 days" $ testRecordEdits g
|
||||
it "answers a link whose profile was not read unread, and a private host's unavailable without fetching it" $ testEntityProfiles g
|
||||
it "answers provider_unavailable to any failed chain read, records and price included" $ testFailedReads g
|
||||
it "fails a search whose reads together pass one deadline, though each read meets it" $ testOneDeadline g
|
||||
@@ -104,7 +106,7 @@ withSearch g adjustConfig adjust action = withServiceStore $ \store -> withContr
|
||||
let cfg = adjustConfig $ namesConfig node
|
||||
routes = \case
|
||||
["api", "link", "preview"] -> Just Route {rMethod = "POST", rLimit = previewLimit, rHandler = previewHandler ps}
|
||||
["api", "name", hash] -> Just Route {rMethod = "GET", rLimit = searchLimit, rHandler = searchHandler cfg store (adjust ct) ps hash}
|
||||
["api", "name", hash] -> Just Route {rMethod = "GET", rLimit = searchLimit, rHandler = searchHandler cfg store (adjust ct) ps (pure t0) hash}
|
||||
_ -> Nothing
|
||||
app req respond = fromMaybe (respond notFound) (namesApp http routes req respond)
|
||||
mgr <- newManager defaultManagerSettings
|
||||
@@ -120,6 +122,7 @@ namesConfig node =
|
||||
nRevealMarginSeconds = 900,
|
||||
nMaxOrderMoves = 3,
|
||||
nCodesOnly = False,
|
||||
nRecordEditsPerYear = 3,
|
||||
nTerms = TermsConfig {tVersion = termsVersion, tUrl = termsUrl},
|
||||
nCommunity = M.empty,
|
||||
nChain = fakeChainConfig node
|
||||
@@ -270,6 +273,7 @@ registeredAnswer entities records resolver nonce =
|
||||
"owner" .= ownerText,
|
||||
"resolver" .= resolver,
|
||||
"recordsNonce" .= nonce,
|
||||
"recordEdits" .= if records == J.Null then J.Null else editsLeft 3 J.Null,
|
||||
"expiresAt" .= (now + year),
|
||||
"gracePeriod" .= gracePeriod,
|
||||
"chainId" .= fakeChainId,
|
||||
@@ -277,6 +281,9 @@ registeredAnswer entities records resolver nonce =
|
||||
"community" .= J.Null
|
||||
]
|
||||
|
||||
editsLeft :: Int -> J.Value -> J.Value
|
||||
editsLeft left nextAt = J.object ["left" .= left, "nextAt" .= nextAt]
|
||||
|
||||
reservedAnswer, registeringAnswer :: Answer
|
||||
reservedAnswer = ok ["status" .= str "reserved", "community" .= J.Null]
|
||||
registeringAnswer = ok ["status" .= str "registered", "entities" .= ([] :: [J.Value]), "tld" .= str "simplex", "community" .= J.Null]
|
||||
@@ -408,6 +415,23 @@ testOtherResolver g = withSearch g id id $ \s@Search {node} -> do
|
||||
answerRegistered node otherResolver
|
||||
searchAlice s 12 `shouldReturn` registeredAnswer [] J.Null otherResolverText J.Null
|
||||
|
||||
testRecordEdits :: TVar ChaChaDRG -> IO ()
|
||||
testRecordEdits g = forM_ [(3, 1), (2, 0)] $ \(perYear, left) -> withSearch g (\cfg -> cfg {nRecordEditsPerYear = perYear}) id $ \s@Search {node, store} -> do
|
||||
registeredWith node fullLink ""
|
||||
let daysAgo :: Int -> UTCTime
|
||||
daysAgo d = addUTCTime (fromIntegral $ negate d * 86400) t0
|
||||
inserted n d = withTransaction store $ \db -> insertRecordEdit db (newEdit ownerA n setChannel) {nreNonce = fromIntegral n} (daysAgo d)
|
||||
Just _ <- inserted 1 366
|
||||
Just minedId <- inserted 2 10
|
||||
Just revertedId <- inserted 3 5
|
||||
Just _ <- inserted 4 1
|
||||
withTransaction store $ \db -> do
|
||||
settleRecordEdit db minedId (ESMined Nothing) `shouldReturn` True
|
||||
settleRecordEdit db revertedId (ESReverted Nothing) `shouldReturn` True
|
||||
answer <- searchAlice s 12
|
||||
(snd answer >>= JT.parseMaybe (J.withObject "answer" (J..: "recordEdits")))
|
||||
`shouldBe` Just (editsLeft left $ J.toJSON (floor (utcTimeToPOSIXSeconds $ daysAgo 10) + 365 * 86400 :: Int))
|
||||
|
||||
testEntityProfiles :: TVar ChaChaDRG -> IO ()
|
||||
testEntityProfiles g = withSearch g id id $ \s@Search {node} -> do
|
||||
let gone = taggedLink "smp8.simplex.im" 'a' "Gone"
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Bots.StoreService.NamesStoreTests (namesStoreTests, at, btc, insertInvoiceRow, invoiceStatus, issue, newReg, order, ownerA, ownerB, paidOrder, placeCode, placePaid, price, reg, reveal, signature, t0, usable, write) where
|
||||
module Bots.StoreService.NamesStoreTests (namesStoreTests, at, btc, insertInvoiceRow, invoiceStatus, issue, newEdit, newReg, order, ownerA, ownerB, paidOrder, placeCode, placePaid, price, reg, reveal, setChannel, signature, t0, usable, write) where
|
||||
|
||||
import Bots.StoreService.ConfigTests (termsVersion)
|
||||
import Bots.StoreService.WebTests (columnsOf, digestFixture, runMigrations, withServiceStore)
|
||||
import Control.Arrow ((&&&))
|
||||
import Control.Monad (forM_, void)
|
||||
import Control.Monad (forM, forM_, void)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (sort)
|
||||
import Data.Maybe (fromJust, mapMaybe)
|
||||
@@ -90,6 +90,7 @@ namesStoreTests = describe "StoreService names: store" $ do
|
||||
it "settles a pending edit once, with its mined hash" testSettleEdit
|
||||
it "round-trips a clear edit" testClearEdit
|
||||
it "gives the times of a name's relayed edits in the window" testRelayedEditTimes
|
||||
it "gives the times of a name's pending and mined edits in the window, leaving out reverted ones" testCountedEditTimes
|
||||
|
||||
t0 :: UTCTime
|
||||
t0 = UTCTime (fromGregorian 2026 10 21) 0
|
||||
@@ -949,3 +950,13 @@ testRelayedEditTimes = withServiceStore $ \st -> do
|
||||
withTransaction st $ \db -> void $ insertRecordEdit db (newEdit ownerA n setChannel) {nreNonce = fromIntegral n} (at m)
|
||||
withTransaction st (\db -> void $ insertRecordEdit db (newEdit ownerA 9 setChannel) {nreLabelHash = labelHash "bobsbakery", nreNonce = 9} (at 15))
|
||||
withTransaction st (\db -> relayedEditTimes db (labelHash "alicebakery") (at 5)) `shouldReturn` [at 10, at 20]
|
||||
|
||||
testCountedEditTimes :: IO ()
|
||||
testCountedEditTimes = withServiceStore $ \st -> do
|
||||
[_, Just minedId, Just revertedId, _] <- forM (zip [1 ..] [0, 10, 20, 30]) $ \(n, m) ->
|
||||
withTransaction st $ \db -> insertRecordEdit db (newEdit ownerA n setChannel) {nreNonce = fromIntegral n} (at m)
|
||||
withTransaction st $ \db -> do
|
||||
settleRecordEdit db minedId (ESMined Nothing) `shouldReturn` True
|
||||
settleRecordEdit db revertedId (ESReverted Nothing) `shouldReturn` True
|
||||
withTransaction st (\db -> void $ insertRecordEdit db (newEdit ownerA 9 setChannel) {nreLabelHash = labelHash "bobsbakery", nreNonce = 9} (at 15))
|
||||
withTransaction st (\db -> countedEditTimes db (labelHash "alicebakery") (at 5)) `shouldReturn` [at 10, at 30]
|
||||
Reference in new issue
Block a user