From 57335c7a5b241bd3f97973eb9cbf2cccff0b61bd Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 29 Oct 2026 13:13:54 +0000 Subject: [PATCH] store: limit a name's link edits per year --- apps/simplex-store-service/README.md | 23 ++- .../src/StoreService/Config.hs | 17 ++- .../src/StoreService/Names/Env.hs | 4 +- .../src/StoreService/Names/Records.hs | 61 ++++++-- .../src/StoreService/Names/Store.hs | 10 ++ .../src/StoreService/Web/Names.hs | 3 +- .../src/StoreService/Web/Names/Records.hs | 16 ++- .../src/StoreService/Web/Names/Search.hs | 18 ++- .../store_service.ini.example | 3 + .../store-service/store_service.ini.example | 3 + tests/Bots/StoreService/ConfigTests.hs | 16 +++ tests/Bots/StoreService/NamesOrdersTests.hs | 1 + tests/Bots/StoreService/NamesRecordsTests.hs | 131 +++++++++++++++++- tests/Bots/StoreService/NamesSearchTests.hs | 32 ++++- tests/Bots/StoreService/NamesStoreTests.hs | 15 +- 15 files changed, 312 insertions(+), 41 deletions(-) diff --git a/apps/simplex-store-service/README.md b/apps/simplex-store-service/README.md index 8dfeec2bc5..6dd3054904 100644 --- a/apps/simplex-store-service/README.md +++ b/apps/simplex-store-service/README.md @@ -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": }` 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. diff --git a/apps/simplex-store-service/src/StoreService/Config.hs b/apps/simplex-store-service/src/StoreService/Config.hs index e1914e1ff2..1cff8e745b 100644 --- a/apps/simplex-store-service/src/StoreService/Config.hs +++ b/apps/simplex-store-service/src/StoreService/Config.hs @@ -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 diff --git a/apps/simplex-store-service/src/StoreService/Names/Env.hs b/apps/simplex-store-service/src/StoreService/Names/Env.hs index 9229e9e2ba..4cd600989d 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Env.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Env.hs @@ -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} diff --git a/apps/simplex-store-service/src/StoreService/Names/Records.hs b/apps/simplex-store-service/src/StoreService/Names/Records.hs index 24b4a27f61..a5b774651a 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Records.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Records.hs @@ -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} diff --git a/apps/simplex-store-service/src/StoreService/Names/Store.hs b/apps/simplex-store-service/src/StoreService/Names/Store.hs index e3aaf9ee98..dd3e5680bd 100644 --- a/apps/simplex-store-service/src/StoreService/Names/Store.hs +++ b/apps/simplex-store-service/src/StoreService/Names/Store.hs @@ -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 diff --git a/apps/simplex-store-service/src/StoreService/Web/Names.hs b/apps/simplex-store-service/src/StoreService/Web/Names.hs index b6f765f21d..5183602fe9 100644 --- a/apps/simplex-store-service/src/StoreService/Web/Names.hs +++ b/apps/simplex-store-service/src/StoreService/Web/Names.hs @@ -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/ 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} diff --git a/apps/simplex-store-service/src/StoreService/Web/Names/Records.hs b/apps/simplex-store-service/src/StoreService/Web/Names/Records.hs index 84dc858e94..cf4762df9a 100644 --- a/apps/simplex-store-service/src/StoreService/Web/Names/Records.hs +++ b/apps/simplex-store-service/src/StoreService/Web/Names/Records.hs @@ -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 diff --git a/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs b/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs index 88216bb695..8e7c4056e0 100644 --- a/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs +++ b/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs @@ -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. diff --git a/apps/simplex-store-service/store_service.ini.example b/apps/simplex-store-service/store_service.ini.example index 75639bf68a..e9dd7caa5f 100644 --- a/apps/simplex-store-service/store_service.ini.example +++ b/apps/simplex-store-service/store_service.ini.example @@ -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, `