store: limit a name's link edits per year

This commit is contained in:
shum committed 2026-10-29 13:13:54 +00:00
1 parent 197961c18c
commit 57335c7a5b
15 files changed
+312 -41

No files matched your search

+22 -1
View File
@@ -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
+16
View File
@@ -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
+125 -6
View File
@@ -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
+28 -4
View File
@@ -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"
+13 -2
View File
@@ -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]