diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index 148d4c1c1..c9f699da8 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1512,64 +1512,109 @@ fact that this router cannot resolve, so iterating past it is safe. #### Name response Resolving a name and asking whether it can be registered are one question to the -registry, and one lookup answers both: a client offering a taken name to register -wants to show what took it. `RNAME` carries a tag saying which answer follows. +registry, and one lookup answers both: a client offering a taken name to +register wants to show what took it. `RNAME` carries three facts, from three +contracts - the controller, the registrar and the resolver - and any of them may +be absent. ```abnf -rname = %s"RNAME" SP answer -answer = %s"RECORD" SP optExpires json-bytes ; json-bytes consumes the remainder - / %s"TAKEN" SP optExpires - / %s"GRACE" SP grace-ends - / %s"AUCTION" SP premium auction-ends - / %s"RESERVED" SP reason - / %s"AVAILABLE" -optExpires = %s"0" / (%s"1" expires) ; absent when the router could not read the registration -expires = 8*8 OCTET ; as grace-ends -grace-ends = 8*8 OCTET ; Int64, network byte order (big-endian), seconds since the Unix epoch -auction-ends = 8*8 OCTET ; as grace-ends, and follows premium with no separator -premium = shortString ; ASCII decimal integer, in attoUSD (1e-18 USD) +rname = %s"RNAME" SP reserved SP registration SP json-bytes +reserved = %s"0" / (%s"1" reason) ; absent = not held back +registration = %s"0" / (%s"1" registered-or-not) ; absent = the router cannot say +registered-or-not = %s"REGISTERED" SP expires grace-until + / %s"UNREGISTERED" SP pricing +expires = 8*8 OCTET ; Int64, big-endian, seconds since the Unix epoch +grace-until = 8*8 OCTET ; as expires, and greater than it +pricing = %s"0" / (%s"1" rent-prices min-label-length premium-from + start-premium end-premium) +rent-prices = length *(8*8 OCTET) ; MicroUSD per year, by label length +min-label-length = 2*2 OCTET ; Word16, characters +premium-from = %s"0" / (%s"1" 8*8 OCTET) ; unix seconds the surcharge began +start-premium = 8*8 OCTET ; MicroUSD, Int64 +end-premium = 8*8 OCTET ; MicroUSD, Int64 reason = %s"UNSPECIFIED" / %s"TRADEMARK" / %s"PUBLIC_INTEREST" - / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / %s"UNKNOWN" + / %s"OFFENSIVE" / %s"INTERNAL" / %s"PREMIUM" / word +word = 1*32(%x21-7E) ; a reason this version has no word for ``` -| Answer | Condition | Client action | -|---|---|---| -| `RECORD` | resolves, and the registration runs until `expires` | use the record | -| `TAKEN` | registered until `expires`, but its records point nowhere | do not offer it | -| `GRACE` | lapsed, but renewable by its previous owner until `grace-ends` | do not offer it; it may free up then | -| `AUCTION` | registrable by anyone, at `premium` above the ordinary price, decaying to nothing by `auction-ends` | offer it only with the premium shown | -| `RESERVED` | held back by the registry for `reason` | do not offer it; explain `reason` | -| `AVAILABLE` | registrable at the ordinary price | offer it | +`json-bytes` is the record as a UTF-8 JSON object, or `null` when the name does +not resolve. It consumes the remainder of the transmission. -Below v22, `RNAME` carries the bare record with no tag and no `expires`, and -every other answer is `ERR NAME NOT_FOUND`, as it was before this version. +Money is MicroUSD, a millionth of a US dollar: the registry denominates in USD, +never in ETH, and the backing resolver converts before the value reaches the +protocol. Times are seconds since the Unix epoch. Lengths are characters. -From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" — only -`AVAILABLE` says that. `NOT_FOUND` means the router has nothing to say about the -name, which includes a backing resolver whose answer it could not read. +A client reads the three facts together: -`premium` is a decimal string because prices are 256-bit integers. It is the -surcharge only: the base price depends on the label's length, which a hashed -query does not carry. The client adds that. +| The client sees | Meaning | +|---|---| +| a record | the name resolves; use it | +| `REGISTERED` | held by someone until `expires`, renewable by its owner alone until `grace-until` | +| `UNREGISTERED` | held by nobody; registrable unless it is also reserved | +| `pricing` | what registering it costs, computed locally | +| `reserved` | the registry holds it back, whether or not it is registered | +| no registration | a pre-v22 answer, which was only ever sent for a live registration | -Times are absolute, not durations, so a client can count down without -re-querying. A deadline is not permission to register; only the registry grants -that. +Availability is the conjunction, not a state of its own: a name is registrable +when it is `UNREGISTERED` and carries no reservation, which is the registry's own +`available()`. An auction is not a state either - it is `UNREGISTERED` with a +premium that has not yet decayed to zero. -A router that cannot read the payload for `GRACE` or `AUCTION` MUST answer -`TAKEN` with no `expires`, never `AVAILABLE`. Quoting the ordinary price for a -name that carries a premium is the harmful answer. +A router MUST NOT send `pricing` for a reserved name. A name the registry holds +back is not for sale at the registry's price, and quoting one would be an offer +the registry will not honour. + +The record travels while a name is registered, through its grace period, and +stops at the moment the name becomes registrable by anyone. Keeping it that far +lets whoever opens the name tell its owner that it is about to lapse; keeping it +past that would show a record whose owner no longer holds the name. How long a +client goes on opening an expiring name is its own decision. + +**Computing the price.** All amounts MicroUSD, all times seconds: + +``` +price len duration t + = rentPrices[min (len - 1) (length rentPrices - 1)] * duration / 31536000 + + max 0 (decayed startPremium (t - premiumFrom) - endPremium) +decayed s elapsed = s * 0.5 ** (elapsed / 86400) +``` + +The surcharge is charged once whatever the duration; only the rent scales with +it. `decayed` halves each day and interpolates within the day, and is the same +function for every deployment, so it is specified here rather than sent. A +client computing it in double precision lands within 0.01 MicroUSD of the +registry across the whole curve. Rounding may leave the surcharge just above +zero at the end of its window, so a client floors it at zero, as the registry +does. The minimum registration is 28 days, a contract constant rather than a +per-deployment value, so it is not sent either. + +`rentPrices` is indexed by label length, its last entry covering every longer +label. `minLabelLength` is sent because a hashed query carries no length: the +router cannot check it, so the client must, and a price quoted for a label the +registry will refuse is worse than no quote at all. + +Below v22, `RNAME` carries the bare record with no other field, and every answer +without a record is `ERR NAME NOT_FOUND`, as it was before this version. A name +in its grace period therefore resolves for those clients too, without the expiry +they have no field to carry. + +From v22 a client MUST NOT read `ERR NAME NOT_FOUND` as "registrable" - only +`UNREGISTERED` with no reservation says that. `NOT_FOUND` means the router has +nothing to say about the name, which includes a backing resolver whose answer it +could not read. A router that reads a status it has no answer for MUST say so as `ERR NAME -RESOLVER `. Not `TAKEN`, which asserts a registration it never read, and -not `AVAILABLE`, which offers a name that may be held. An unreachable chain and -an unconfigured TLD arrive this way, as statuses of their own. When the response -carries no status the router can read at all, it answers `ERR NAME NOT_FOUND`. +RESOLVER `. Not a registration, which asserts one it never read, and not +`UNREGISTERED`, which offers a name that may be held. An unreachable chain and an +unconfigured TLD arrive this way, as statuses of their own, and so does a +registration the router could not date. When the response carries no status the +router can read at all, it answers `ERR NAME NOT_FOUND`. -A client MUST read a `reason` it does not know as `UNKNOWN` and still treat the +A client MUST read a `reason` it does not know as unknown and still treat the name as reserved: a later version may reserve names for reasons this one cannot name, and losing the reservation over that would offer a name that cannot be -registered. A router sends `UNKNOWN` for a reason its own resolver did not name. +registered. The word itself travels so that a later client can use it; a router +sends at most one bounded token, since the field ends at a space. `json-bytes` MUST be a UTF-8 JSON object with the following schema: @@ -1595,10 +1640,11 @@ an empty string, not JSON `null` and not an absent key. Link fields empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON `null` as the "unset" sentinel and MAY also be absent from the object entirely. -The backing resolver does not resolve a name whose registration has lapsed; the -router answers `GRACE` or `AUCTION` for those. The record carries no expiry -field of its own — `RECORD` carries it alongside. Testnet-vs-mainnet status is -derived from the queried TLD rather than an in-record flag. +The record carries no expiry field of its own: the registration alongside it +does. The backing resolver stops resolving a name once it is registrable by +anyone, so a record and an `UNREGISTERED` registration do not travel together. +Testnet-vs-mainnet status is derived from the queried TLD rather than an +in-record flag. Receivers MUST tolerate extra unknown fields (forward-compatibility for future field additions). Adding a required field is a breaking change requiring an diff --git a/scripts/resolver/service/snrc-resolve.py b/scripts/resolver/service/snrc-resolve.py index 7cfb01f40..66f32c40b 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -41,8 +41,8 @@ Environment: (default: empty — TLD not yet deployed) SNRC_REGISTRAR_ BaseRegistrar (ERC-721) for the TLD; expiry and status (default: mainnet for .testing, empty for .simplex) - SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; `reserved` status, - and through its `prices()` oracle the post-grace auction + SNRC_CONTROLLER_ SimplexController (proxy) for the TLD; reservations, + and through its `prices()` oracle what registering costs (default: mainnet for .testing, empty for .simplex) SNRC_PORT Listen port (default: 8000) SNRC_BIND Bind address (default: 0.0.0.0) @@ -126,6 +126,10 @@ COIN_DOT = 354 ZERO_ADDR = "0x0000000000000000000000000000000000000000" +# The registry prices in attoUSD (1e-18 USD); the protocol carries MicroUSD. +ATTO_PER_MICRO = 10**12 +SECONDS_PER_YEAR = 31536000 + # ---------- RPC + ABI helpers (mirrors ens-lookup.py shape) ---------- @@ -236,49 +240,53 @@ def reservation_reason(tld: str, token: int) -> int: return decode_uint(raw) -def auction_params(tld: str): - """(oracle, startPremium, totalDays, endValue) for the TLD's controller, or - (ZERO_ADDR, 0, 0, 0) when no controller or no oracle is configured.""" - return cached(("auction", tld), lambda: read_auction_params(tld)) +def pricing_params(tld: str): + """What it costs to register a name under this TLD, in MicroUSD, or None + when no controller or price oracle is configured.""" + return cached(("pricing", tld), lambda: read_pricing_params(tld)) -def read_auction_params(tld: str): - params = (ZERO_ADDR, 0, 0, 0) +def read_pricing_params(tld: str): controller = CONTROLLERS.get(tld) - if controller: - oracle = decode_address(eth_call(controller, selector("prices()"))) - if oracle != ZERO_ADDR: - params = ( - oracle, - decode_uint(eth_call(oracle, selector("startPremium()"))), - decode_uint(eth_call(oracle, selector("totalDays()"))), - decode_uint(eth_call(oracle, selector("endValue()"))), - ) - return params - - -def auction(tld: str, grace_ends: int, now: int): - """Past grace a name is registrable again, but at a premium decaying to zero - over the oracle's window. Returns when the premium reaches zero and what it - is now, in attoUSD, or (None, None) once prices are normal - which includes - an auction switched off with totalDays 0.""" - oracle, start, total_days, floor = auction_params(tld) + if not controller: + return None + oracle = decode_address(eth_call(controller, selector("prices()"))) if oracle == ZERO_ADDR: - return None, None - ends = grace_ends + total_days * 86400 - if now >= ends: - return None, None - # decayedPremium is `pure`, so this is the oracle's own arithmetic rather - # than a second copy of its decay curve. - decayed = decode_uint( - eth_call( - oracle, - selector("decayedPremium(uint256,uint256)") - + encode_uint(start) - + encode_uint(now - grace_ends), - ) - ) - return ends, max(decayed - floor, 0) + return None + try: + return read_oracle_prices(controller, oracle) + except RuntimeError: + # An oracle that does not expose its curve cannot be quoted from. The + # name is still registrable; the price is simply not ours to state. + return None + + +def read_oracle_prices(controller: str, oracle: str): + # The oracle prices rent in attoUSD per second and the premium in attoUSD. + # Quotes round so they are never below what the registry charges: rents and + # the surcharge up, the floor that is subtracted from the surcharge down. + # An oracle built before the six-letter tier stops at five, and the contract + # itself then charges price5Letter for anything longer - which is what the + # last entry means here too. + rents = [] + for n in range(1, 7): + try: + rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) + except RuntimeError: + if n <= 5: + raise + break + rents.append(ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_MICRO)) + return { + "rentPrices": rents, + "minLabelLength": decode_uint(eth_call(controller, selector("minCharLength()"))), + "startPremium": ceil_div(decode_uint(eth_call(oracle, selector("startPremium()"))), ATTO_PER_MICRO), + "endPremium": decode_uint(eth_call(oracle, selector("endValue()"))) // ATTO_PER_MICRO, + } + + +def ceil_div(a: int, b: int) -> int: + return -(-a // b) def name_status(name: str): @@ -290,10 +298,9 @@ def name_status(name: str): "status": "unknown", "expires": None, "graceEnds": None, - "auctionEnds": None, - "premium": None, "reasonCode": None, "reason": None, + "premiumFrom": None, } # nameExpires and reservedNames are keyed on uint256(keccak(label)). @@ -314,25 +321,24 @@ def name_status(name: str): now = chain_now() status = expiry_status(expires, grace, now) - auction_ends = premium = reason = None - if status in ("unregistered", "expired"): - code = reservation_reason(tld, token) - if code: - status, reason = "reserved", RESERVED_REASONS.get(code, UNKNOWN_REASON) - elif status == "expired": - auction_ends, premium = auction(tld, expires + grace, now) - if auction_ends: - status = "auction" + # A reservation is orthogonal to the registration: a registered name can be + # held back too, and that is why it will not free up when it expires. + code = reservation_reason(tld, token) + reason = RESERVED_REASONS.get(code, UNKNOWN_REASON) if code else None - return { + out = { "status": status, "expires": expires or None, "graceEnds": (expires + grace) if expires else None, - "auctionEnds": auction_ends, - "premium": None if premium is None else str(premium), "reasonCode": reason[0] if reason else None, "reason": reason[1] if reason else None, + # past grace the name is registrable again, at a surcharge decaying from + # the moment grace ended; the client computes it from the curve's ends + "premiumFrom": (expires + grace) if status == "expired" and expires else None, } + if status in ("unregistered", "expired"): + out.update(pricing_params(tld) or {}) + return out def selector(signature: str) -> str: @@ -642,53 +648,28 @@ def resolve(name: str): # Before the resolver lookup, so a lapsed name is not reported as noResolver. reg = name_status(name) - if reg["status"] in ("unregistered", "reserved"): + if reg["status"] in ("unregistered", "expired"): + # A name in grace is not here: its record still resolves, so that whoever + # opens it can tell the owner it is about to lapse. body = { "name": name, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], + **reg, "error": reg["status"], "message": ( - "this name is reserved and cannot be registered" - if reg["status"] == "reserved" - else "this name has never been registered" - ), - } - if reg["status"] == "reserved": - body["reasonCode"] = reg["reasonCode"] - body["reason"] = reg["reason"] - return 404, body - if reg["status"] in ("grace", "expired", "auction"): - body = { - "name": name, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], - "error": reg["status"], - "message": ( - "this registration expired and can be renewed by its owner" - if reg["status"] == "grace" + "this name has never been registered" + if reg["status"] == "unregistered" else "this registration expired and is open to anyone" ), } - if reg["status"] == "auction": - body["auctionEnds"] = reg["auctionEnds"] - body["premium"] = reg["premium"] - body["message"] = ( - "this registration expired and is open to anyone, at a premium " - "that decays to zero" - ) - return 410, body + return (404 if reg["status"] == "unregistered" else 410), body resolver_raw = eth_call(registry, selector("resolver(bytes32)") + node_hex) resolver_addr = decode_address(resolver_raw) if resolver_addr == ZERO_ADDR: return 404, { "name": name, + **reg, "status": "noResolver", - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], "error": "noResolver", "message": "no resolver set for this name", } @@ -727,9 +708,7 @@ def resolve(name: str): "dot": addr_multicoin(resolver_addr, node, COIN_DOT), "owner": owner, "resolver": resolver_addr, - "status": reg["status"], - "expires": reg["expires"], - "graceEnds": reg["graceEnds"], + **reg, } diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e0c509019..6306f4045 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -226,7 +226,7 @@ import Simplex.Messaging.Protocol ErrorType (AUTH), MsgBody, MsgFlags (..), - NameResponse, + NameResult, NtfServer, ProtoServerWithAuth (..), ProtocolServer (..), @@ -459,7 +459,7 @@ getConnShortLink c = withAgentEnv c .:. getConnShortLink' c -- | Resolve a SimpleX name (PFWD RSLV). The agent owns server selection: it -- picks a names-capable server (ServerRoles.names) from the user's nameSrvs, so -- chat clients just pass the parsed domain. -resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResponse +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameResult resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} @@ -1268,7 +1268,7 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId -resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResult resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId resolveName c nm userId resolverSrv domain diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index e2f9df328..c45a34c2d 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -269,7 +269,7 @@ import Simplex.Messaging.Protocol NetworkError (..), MsgFlags (..), MsgId, - NameResponse, + NameResult, NtfServer, NtfServerWithAuth, ProtoServer, @@ -1993,7 +1993,7 @@ getQueueLink c nm userId server lnkId = -- resolver) and falls back to a direct send when the proxy is unavailable -- (faster but exposes the client IP). Mode selection is delegated to -- `sendOrProxySMPCommand`, which honours the network config (SPMNever etc.). -resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResponse +resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameResult resolveName c nm userId server domain = snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly where diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 893525e06..94e91c0fa 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -1060,16 +1060,14 @@ queryDomain :: VersionSMP -> SimplexDomain -> SimplexDomain queryDomain v d = if v >= nameAvailSMPVersion then hashedDomain d else d -- | A hashed query's record names the hash, so put back the name that was asked. -askedName :: SimplexDomain -> NameResponse -> NameResponse -askedName name = \case - r@NRNameRecord {nameRecord} -> r {nameRecord = nameRecord {nrName = fullDomainName name}} - r -> r +askedName :: SimplexDomain -> Maybe NameRecord -> Maybe NameRecord +askedName name = fmap $ \nr -> nr {nrName = fullDomainName name} -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResponse) +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResult) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case - Right (RNAME r) -> pure $ Right (askedName name r) + Right (RNAME reserved_ reg_ rec_) -> pure $ Right (reserved_, reg_, askedName name rec_) Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1081,11 +1079,11 @@ proxyResolveName c nm proxiedRelay name -- proxy fallback in the agent. RSLV requires no entity ID or authorization -- (see `noAuthCmd` in Protocol.hs). Version-gated on the session here, not the -- encoder, so an old server never receives RSLV. -directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResponse +directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameResult directResolveName c nm name | v >= namesSMPVersion = sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME r -> pure (askedName name r) + RNAME reserved_ reg_ rec_ -> pure (reserved_, reg_, askedName name rec_) r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 35fe3afaf..fcd2f4c3e 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -80,9 +80,12 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), - NameResponse (..), - NRTag (..), + NameResult, + NameRegistration (..), + NamePricing (..), + MicroUSD (..), NameReservedReason (..), + reservedReason, NameErrorType (..), BrokerErrorType (..), NetworkError (..), @@ -251,7 +254,7 @@ import Data.Kind import Data.List (foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (isJust, isNothing) +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -743,7 +746,7 @@ data BrokerMsg where ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg -- What the router knows about a SimpleX name. - RNAME :: NameResponse -> BrokerMsg + RNAME :: Maybe NameReservedReason -> Maybe NameRegistration -> Maybe NameRecord -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -1592,104 +1595,133 @@ data ErrorType DUPLICATE_ -- not part of SMP protocol, used internally deriving (Eq, Show) -data NameResponse - = -- | resolves, and the registration runs until this time - NRNameRecord {nameRecord :: NameRecord, expires :: Maybe Int64} - | -- | registered, but its records point nowhere - NRNameTaken {expires :: Maybe Int64} - | -- | lapsed, renewable by its previous owner until this time - NRNameInGrace {graceEnds :: Int64} - | -- | registrable by anyone, at this premium in attoUSD until this time - NRNameAuction {premium :: Text, auctionEnds :: Int64} - | -- | held back by the registry - NRNameReserved {reason :: NameReservedReason} - | -- | registrable at the ordinary price - NRNameAvailable +-- | USD in millionths, converted by the resolver from the registry's attoUSD. +-- Int64 reaches ~9.2 trillion USD, and the smallest value in play - the last +-- step of a decaying premium - is a millionth of a dollar. +newtype MicroUSD = MicroUSD Int64 + deriving (Eq, Ord, Show) + deriving newtype (Encoding, ToJSON, FromJSON) + +-- | The three facts RSLV answers with, any of which the router may not have. +-- A plain tuple: nothing downstream needs a type of its own for it. +type NameResult = (Maybe NameReservedReason, Maybe NameRegistration, Maybe NameRecord) + +-- | What the registrar says about a name: held by someone, or registrable. +data NameRegistration + = -- | unix seconds; graceUntil > expires, and until it only the owner renews. + NRRegistered {expires :: Int64, graceUntil :: Int64} + | -- | Not held by anyone. Pricing is absent when the TLD has no controller or + -- price oracle configured, and when the name is reserved: a held-back name + -- is not for sale at the registry's price, only by arrangement with SimpleX. + -- An auction is not a separate state: it is this, with a premium that has + -- not decayed to zero yet. + NRUnregistered {pricing :: Maybe NamePricing} deriving (Eq, Show) -data NRTag - = NRNameRecord_ - | NRNameTaken_ - | NRNameInGrace_ - | NRNameAuction_ - | NRNameReserved_ - | NRNameAvailable_ - deriving (Show) - -instance Encoding NRTag where +instance Encoding NameRegistration where smpEncode = \case - NRNameRecord_ -> "RECORD" - NRNameTaken_ -> "TAKEN" - NRNameInGrace_ -> "GRACE" - NRNameAuction_ -> "AUCTION" - NRNameReserved_ -> "RESERVED" - NRNameAvailable_ -> "AVAILABLE" - smpP = messageTagP - -instance ProtocolMsgTag NRTag where - decodeTag = \case - "RECORD" -> Just NRNameRecord_ - "TAKEN" -> Just NRNameTaken_ - "GRACE" -> Just NRNameInGrace_ - "AUCTION" -> Just NRNameAuction_ - "RESERVED" -> Just NRNameReserved_ - "AVAILABLE" -> Just NRNameAvailable_ - _ -> Nothing - -instance Encoding NameResponse where - smpEncode = \case - NRNameRecord {nameRecord, expires} -> e (NRNameRecord_, ' ', expires, Tail $ LB.toStrict $ J.encode nameRecord) - NRNameTaken {expires} -> e (NRNameTaken_, ' ', expires) - NRNameInGrace {graceEnds} -> e (NRNameInGrace_, ' ', graceEnds) - NRNameAuction {premium, auctionEnds} -> e (NRNameAuction_, ' ', premium, auctionEnds) - NRNameReserved {reason} -> e (NRNameReserved_, ' ', reason) - NRNameAvailable -> e NRNameAvailable_ - where - e :: Encoding a => a -> ByteString - e = smpEncode + NRRegistered {expires, graceUntil} -> "REGISTERED " <> smpEncode (expires, graceUntil) + NRUnregistered {pricing} -> "UNREGISTERED " <> smpEncode pricing smpP = - smpP >>= \case - NRNameRecord_ -> do - expires <- smpP - nameRecord <- J.eitherDecodeStrict . unTail <$?> smpP - pure NRNameRecord {nameRecord, expires} - NRNameTaken_ -> NRNameTaken <$> smpP - NRNameInGrace_ -> NRNameInGrace <$> smpP - NRNameAuction_ -> NRNameAuction <$> smpP <*> smpP - NRNameReserved_ -> NRNameReserved <$> smpP - NRNameAvailable_ -> pure NRNameAvailable + A.takeTill (== ' ') >>= \case + "REGISTERED" -> NRRegistered <$> _smpP <*> smpP + "UNREGISTERED" -> NRUnregistered <$> _smpP + _ -> fail "bad NameRegistration" +-- | Enough to price the name locally, as often as the UI likes, without naming +-- it. All amounts MicroUSD, all times unix seconds: +-- +-- price len duration t +-- = rentPrices !! min (len - 1) (length rentPrices - 1) * duration `div` year +-- + max 0 (decayed startPremium (t - premiumFrom) - endPremium) +-- decayed s elapsed = s * 0.5 ** (elapsed / 86400) +-- +-- The surcharge is charged once whatever the duration - only the rent scales. +-- decayed is computed in Double and rounded: it lands within 0.01 MicroUSD of +-- the chain across the whole curve. Rounding startPremium and endPremium +-- separately means the premium may not reach exactly zero, so the client floors +-- at 0, as the chain does. The minimum registration is 28 days, a contract +-- constant rather than a per-deployment value, so it is not sent. +data NamePricing = NamePricing + { -- | MicroUSD per year by label length: first entry a one-letter label, last + -- covering every longer one. Rounded up, so a quote is never below the + -- charge; the exact figure is settled on chain at registration. + rentPrices :: [MicroUSD], + -- | characters: the registry refuses shorter, and a hash cannot be measured. + minLabelLength :: Int, + -- | unix seconds the surcharge began. Nothing when there is none. + premiumFrom :: Maybe Int64, + startPremium :: MicroUSD, -- before decay + endPremium :: MicroUSD -- floor subtracted from the decayed value + } + deriving (Eq, Show) + +instance Encoding NamePricing where + smpEncode NamePricing {rentPrices, minLabelLength, premiumFrom, startPremium, endPremium} = + smpEncodeList rentPrices <> smpEncode (w16 minLabelLength, premiumFrom, startPremium, endPremium) + where + w16 = fromIntegral :: Int -> Word16 + smpP = do + rentPrices <- smpListP + (minLen, premiumFrom, startPremium, endPremium) <- smpP + pure NamePricing {rentPrices, minLabelLength = fromIntegral (minLen :: Word16), premiumFrom, startPremium, endPremium} + +-- | Why the registry holds a name back. A reason this version has no word for +-- keeps its own word rather than losing the reservation. data NameReservedReason - = NRUnspecified - | NRTrademark - | NRPublicInterest - | NROffensive - | NRInternal - | NRPremium - | -- | a reason this version cannot name - NRUnknown + = RRUnspecified + | RRTrademark + | RRPublicInterest + | RROffensive + | RRInternal + | RRPremium + | RRUnknown Text deriving (Eq, Show) instance Encoding NameReservedReason where smpEncode = \case - NRUnspecified -> "UNSPECIFIED" - NRTrademark -> "TRADEMARK" - NRPublicInterest -> "PUBLIC_INTEREST" - NROffensive -> "OFFENSIVE" - NRInternal -> "INTERNAL" - NRPremium -> "PREMIUM" - NRUnknown t -> t + RRUnspecified -> "UNSPECIFIED" + RRTrademark -> "TRADEMARK" + RRPublicInterest -> "PUBLIC_INTEREST" + RROffensive -> "OFFENSIVE" + RRInternal -> "INTERNAL" + RRPremium -> "PREMIUM" + RRUnknown t -> encodeUtf8 t smpP = A.takeTill (== ' ') >>= \case - "UNSPECIFIED" -> pure NRUnspecified - "TRADEMARK" -> pure NRTrademark - "PUBLIC_INTEREST" -> pure NRPublicInterest - "OFFENSIVE" -> pure NROffensive - "INTERNAL" -> pure NRInternal - "PREMIUM" -> pure NRPremium - -- a later version may reserve names for reasons this one has no word for; - -- losing "reserved" over that would be worse than losing the wording - t -> pure $ NRUnknown t + "UNSPECIFIED" -> pure RRUnspecified + "TRADEMARK" -> pure RRTrademark + "PUBLIC_INTEREST" -> pure RRPublicInterest + "OFFENSIVE" -> pure RROffensive + "INTERNAL" -> pure RRInternal + "PREMIUM" -> pure RRPremium + t -> pure $ RRUnknown (safeDecodeUtf8 t) + +-- | The vocabulary the backing resolver and the JSON API share, which is not +-- the wire vocabulary above. +instance TextEncoding NameReservedReason where + textEncode = \case + RRUnspecified -> "unspecified" + RRTrademark -> "trademark" + RRPublicInterest -> "publicInterest" + RROffensive -> "offensive" + RRInternal -> "internal" + RRPremium -> "premium" + RRUnknown t -> t + textDecode = \case + "unspecified" -> Just RRUnspecified + "trademark" -> Just RRTrademark + "publicInterest" -> Just RRPublicInterest + "offensive" -> Just RROffensive + "internal" -> Just RRInternal + "premium" -> Just RRPremium + "unknown" -> Just (RRUnknown "unknown") + _ -> Nothing + +-- | Keeps its word rather than losing the reservation. +reservedReason :: Text -> NameReservedReason +reservedReason t = fromMaybe (RRUnknown t) (textDecode t) + -- | Name resolution error data NameErrorType @@ -2074,11 +2106,11 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ - RNAME r - | v >= nameAvailSMPVersion -> e (RNAME_, ' ', r) - | otherwise -> case r of - NRNameRecord {nameRecord} -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode nameRecord) - _ -> e (ERR_, ' ', NAME NOT_FOUND) + RNAME reserved_ reg_ rec_ + | v >= nameAvailSMPVersion -> e (RNAME_, ' ', reserved_, ' ', reg_, ' ', Tail $ LB.toStrict $ J.encode rec_) + | otherwise -> case rec_ of + Just rec -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode rec) + Nothing -> e (ERR_, ' ', NAME NOT_FOUND) where e :: Encoding a => a -> ByteString e = smpEncode @@ -2126,8 +2158,8 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where ERR_ -> ERR <$> _smpP PONG_ -> pure PONG RNAME_ - | v >= nameAvailSMPVersion -> RNAME <$> _smpP - | otherwise -> fmap (RNAME . (`NRNameRecord` Nothing)) . J.eitherDecodeStrict . unTail <$?> _smpP + | v >= nameAvailSMPVersion -> RNAME <$> _smpP <*> _smpP <*> (J.eitherDecodeStrict . unTail <$?> _smpP) + | otherwise -> RNAME Nothing Nothing . Just <$> (J.eitherDecodeStrict . unTail <$?> _smpP) where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP @@ -2150,7 +2182,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PKEY {} -> noEntityMsg RRES _ -> noEntityMsg ALLS -> noEntityMsg - RNAME _ -> noEntityMsg + RNAME {} -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY @@ -2522,4 +2554,14 @@ $(J.deriveJSON defaultJSON ''BlockingInfo) $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''NameErrorType, ''ErrorType]) -- clients report the reason to the user, so it has to reach their API as JSON -$(J.deriveJSON (enumJSON $ dropPrefix "NR") ''NameReservedReason) +-- | The JSON API keeps the closed set, so clients can localise it. An +-- unrecognised reason is "unknown" here; its word stays on the SMP wire for a +-- version that knows it. +instance ToJSON NameReservedReason where + toJSON = + J.String . \case + RRUnknown _ -> "unknown" + r -> textEncode r + +instance FromJSON NameReservedReason where + parseJSON = textParseJSON "NameReservedReason" diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index adf096a3a..74697a070 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1499,7 +1499,7 @@ client st <- asks (rslvStats . serverStats) (selector, msg) <- liftIO (resolveName nenv d) <&> \case - Right r -> (rslvSucc, RNAME r) + Right (reserved_, reg_, rec_) -> (rslvSucc, RNAME reserved_ reg_ rec_) Left e@NOT_FOUND -> (rslvNotFound, ERR $ NAME e) Left e -> (rslvResolverErrs, ERR $ NAME e) incStat (selector st) $> msg diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index bccc07020..9b23f3112 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -3,6 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -17,10 +18,10 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), NameRecord, NameReservedReason (..), NameResponse (..)) +import Simplex.Messaging.Protocol (NameErrorType (..), MicroUSD (..), NamePricing (..), NameRecord, NameRegistration (..), NameResult, NameReservedReason, reservedReason) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, @@ -59,7 +60,7 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) +resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) resolveName env d = do r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env d)) case r of @@ -70,52 +71,62 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) --- | The controller's reservation reasons, as the resolver spells them. -mapReason :: Text -> NameReservedReason -mapReason = \case - "unspecified" -> NRUnspecified - "trademark" -> NRTrademark - "publicInterest" -> NRPublicInterest - "offensive" -> NROffensive - "internal" -> NRInternal - "premium" -> NRPremium - -- a code this router has no word for: still reserved, just unworded - t -> NRUnknown t +-- | A code this router has no word for still reserves the name, and travels on +-- as itself. Bounded to one wire token: it is the resolver's text, and the slot +-- it goes into ends at a space. +resolverReason :: Text -> NameReservedReason +resolverReason = reservedReason . T.take 32 . T.takeWhile (/= ' ') -fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResponse) +fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) fetch NamesEnv {resolverEnv} d = - either (Left . mapResolverError) nameResponse <$> resolveHttp resolverEnv (fullDomainName d) + either (Left . mapResolverError) nameResult <$> resolveHttp resolverEnv (fullDomainName d) --- | A record answers on its own; the status only dates it. Without a record the --- status is the whole answer, and a status this router has no word for is not --- one - "taken" would assert a registration nobody read. -nameResponse :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResponse -nameResponse = \case - (Just nameRecord, ns_) -> Right NRNameRecord {nameRecord, expires = nsExpires =<< ns_} - (Nothing, Just ns) -> mapStatus ns +-- | A record answers what the name points to; the status answers whether it can +-- be taken; a reservation is orthogonal to both. A resolver that reports no +-- status at all is an older one, and only ever returned a record for a live +-- registration - the client reads the absent status that way. +nameResult :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameResult +nameResult = \case + (rec_, Just ns) -> (\(reserved_, reg) -> (reserved_, Just reg, rec_)) <$> mapStatus ns + (Just rec, Nothing) -> Right (Nothing, Nothing, Just rec) (Nothing, Nothing) -> Left NOT_FOUND --- | The resolver's vocabulary for a name that does not resolve. -mapStatus :: NameStatusResp -> Either NameErrorType NameResponse -mapStatus NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsAuctionEnds, nsPremium, nsReasonCode} = - case nsStatus of - "unregistered" -> Right NRNameAvailable - "expired" -> Right NRNameAvailable - "grace" -> Right $ maybe lapsed NRNameInGrace nsGraceEnds - "auction" -> Right $ fromMaybe lapsed (NRNameAuction <$> nsPremium <*> nsAuctionEnds) - "reserved" -> Right $ NRNameReserved (maybe NRUnknown mapReason nsReasonCode) +-- | The resolver's vocabulary. A status this router has no word for is not an +-- answer: "registered" would assert a registration nobody read, and +-- "unregistered" would offer a name that may be held. +mapStatus :: NameStatusResp -> Either NameErrorType (Maybe NameReservedReason, NameRegistration) +mapStatus ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode} = + (reserved_,) <$> case nsStatus of + "registered" -> registered -- registered, but its records point nowhere - "noResolver" -> Right $ NRNameTaken nsExpires - -- the resolver's own words, bounded: they reach the client inside ERR + "noResolver" -> registered + "grace" -> registered + "unregistered" -> Right unregistered + "expired" -> Right unregistered + "auction" -> Right unregistered s -> Left (RESOLVER (T.take 32 s)) where - -- lapsed, but missing the deadline or price its status carries. Withhold it - -- rather than quote the ordinary price; its expiry is already past. - lapsed = NRNameTaken Nothing + reserved_ = resolverReason <$> nsReasonCode + -- a registration the router could not date is not one it can report + registered = maybe (Left $ RESOLVER "no expiry") Right $ do + expires <- nsExpires + graceUntil <- nsGraceEnds + pure NRRegistered {expires, graceUntil} + -- a held-back name is not for sale at the registry's price, so it is quoted + -- no price at all: what it costs is a conversation with SimpleX + unregistered = NRUnregistered {pricing = if isJust reserved_ then Nothing else namePricing ns} + +-- | Absent when the TLD has no controller or price oracle configured. The +-- surcharge start is absent for a name that never lapsed. +namePricing :: NameStatusResp -> Maybe NamePricing +namePricing NameStatusResp {nsRentPrices, nsMinLabelLength, nsPremiumFrom, nsStartPremium, nsEndPremium} = do + rentPrices <- map MicroUSD <$> nsRentPrices + minLabelLength <- nsMinLabelLength + startPremium <- MicroUSD <$> nsStartPremium + endPremium <- MicroUSD <$> nsEndPremium + pure NamePricing {rentPrices, minLabelLength, premiumFrom = nsPremiumFrom, startPremium, endPremium} + --- | Only reached when there was no status to read: a working resolver names one --- on every 4xx, and a lapsed registration is GRACE or AUCTION. The 4xx codes --- keep NOT_FOUND, which is what a client below v22 was told for them. mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr 404 -> NOT_FOUND diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 4da4ce2df..0e063c080 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -42,11 +42,9 @@ import qualified Data.Aeson.KeyMap as JKM import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) -import Data.Char (isDigit) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL import Data.Int (Int64) -import qualified Data.Text as T import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client @@ -90,9 +88,17 @@ data NameStatusResp = NameStatusResp { nsStatus :: Text, nsExpires :: Maybe Int64, nsGraceEnds :: Maybe Int64, - nsAuctionEnds :: Maybe Int64, - nsPremium :: Maybe Text, - nsReasonCode :: Maybe Text + -- | reported alongside the status: a reservation is orthogonal to it + nsReasonCode :: Maybe Text, + -- | when the post-grace surcharge began + nsPremiumFrom :: Maybe Int64, + -- | the TLD's price oracle, in MicroUSD - per year for the rents. The + -- resolver converts from the registry's attoUSD, so nothing 256-bit gets + -- this far and every value fits a JSON number exactly. + nsRentPrices :: Maybe [Int64], + nsMinLabelLength :: Maybe Int, + nsStartPremium :: Maybe Int64, + nsEndPremium :: Maybe Int64 } deriving (Show) @@ -167,18 +173,14 @@ resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseByt { nsStatus = t, nsExpires = jsonField o "expires", nsGraceEnds = jsonField o "graceEnds", - nsAuctionEnds = jsonField o "auctionEnds", - nsPremium = jsonField o "premium" >>= decimalPrice, - nsReasonCode = jsonField o "reasonCode" + nsReasonCode = jsonField o "reasonCode", + nsPremiumFrom = jsonField o "premiumFrom", + nsRentPrices = jsonField o "rentPrices", + nsMinLabelLength = jsonField o "minLabelLength", + nsStartPremium = jsonField o "startPremium", + nsEndPremium = jsonField o "endPremium" } --- | A price is at most 78 decimal digits. The wire format length-prefixes it --- with one byte, which would wrap on anything longer, so drop it instead. -decimalPrice :: Text -> Maybe Text -decimalPrice t - | not (T.null t) && T.length t <= 78 && T.all isDigit t = Just t - | otherwise = Nothing - -- | A field the resolver omits or nulls for statuses that do not carry it. jsonField :: J.FromJSON a => J.Object -> Key -> Maybe a jsonField o k = JT.parseMaybe J.parseJSON =<< JKM.lookup k o diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index f3899a4e4..5acb59b28 100644 --- a/tests/AgentTests/ResolveNameTests.hs +++ b/tests/AgentTests/ResolveNameTests.hs @@ -87,15 +87,15 @@ resolveNameTests = do describe "success path" $ it "returns NameRecord" testDirectSuccess describe "name availability" $ - it "an unregistered name answers AVAILABLE" testAvailSuccess + it "an unregistered name answers as unregistered" testAvailSuccess testAvailSuccess :: HasCallStack => IO () testAvailSuccess = withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right a -> a `shouldBe` SMP.NRNameAvailable - _ -> expectationFailure $ "expected Right NRNameAvailable, got: " <> show r + Right (Nothing, Just (SMP.NRUnregistered _), Nothing) -> pure () + _ -> expectationFailure $ "expected Right (_, NRUnregistered, _), got: " <> show r testDirectNotFound :: HasCallStack => IO () testDirectNotFound = @@ -158,5 +158,5 @@ testDirectSuccess = withDirectResolver (status200, J.encode testNameRecord) $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (SMP.NRNameRecord nr _) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (NRNameRecord ..), got: " <> show r + Right (_, _, Just nr) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (_, _, Just record), got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 9b39c3b49..c7cd54b21 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -34,7 +34,9 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), - NameResponse (..), + MicroUSD (..), + NamePricing (..), + NameRegistration (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -146,7 +148,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResponse)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResult)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -169,8 +171,8 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, J.encode testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (Right (NRNameRecord ..)), got: " <> show r + Right (Right (_, _, Just nr)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (Right (_, _, Just record)), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = @@ -179,8 +181,8 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME (NRNameRecord nr _)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (RNAME (NRNameRecord ..)), got: " <> show resp + Right (RNAME Nothing Nothing (Just nr)) -> nr `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (RNAME _ _ (Just record)), got: " <> show resp testRslvAvailable :: IO () testRslvAvailable = @@ -188,21 +190,21 @@ testRslvAvailable = testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME NRNameAvailable) + resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered Nothing)) Nothing) testRslvAuction :: IO () testRslvAuction = withResolverServer (status410, auctionBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (RNAME (NRNameAuction "99999952316384526016153087" 1798191621)) + resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered (Just auctionPricing))) Nothing) testRslvReserved :: IO () testRslvReserved = - withResolverServer (status404, "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}") $ + withResolverServer (status404, "{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"}") $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") - resp `shouldBe` Right (RNAME (NRNameReserved NRTrademark)) + resp `shouldBe` Right (RNAME (Just RRTrademark) (Just (NRUnregistered Nothing)) Nothing) -- | A client that predates v22 must see exactly what it saw before: the record -- for a name that resolves, and NOT_FOUND for one that does not. @@ -222,7 +224,7 @@ testRslvOldClientRecord = withResolverServer (status200, J.encode testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - r `shouldBe` NRNameRecord testNameRecord Nothing + r `shouldBe` (Nothing, Nothing, Just testNameRecord) testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = @@ -237,12 +239,24 @@ testRslvForwardedAuction :: IO () testRslvForwardedAuction = withProxyAndResolver (status410, auctionBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right a) -> a `shouldBe` NRNameAuction "99999952316384526016153087" 1798191621 - _ -> expectationFailure $ "expected Right (Right (NRNameAuction ..)), got: " <> show r + Right (Right (Nothing, Just (NRUnregistered (Just p)), Nothing)) -> premiumFrom p `shouldBe` Just 1788480000 + _ -> expectationFailure $ "expected Right (Right unregistered-with-premium), got: " <> show r --- a name one day past its grace period, priced by the .testing auction curve +-- a name three days past its grace period, priced by the .testing auction curve auctionBody :: LB.ByteString -auctionBody = "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" +auctionBody = + "{\"error\":\"auction\",\"premiumFrom\":1788480000,\"rentPrices\":[0,0,127930000,31980000,999300],\ + \\"minLabelLength\":3,\"startPremium\":100000000000000,\"endPremium\":47683716}" + +auctionPricing :: NamePricing +auctionPricing = + NamePricing + { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], + minLabelLength = 3, + premiumFrom = Just 1788480000, + startPremium = MicroUSD 100000000000000, + endPremium = MicroUSD 47683716 + } -- keccak-256("alice"), the registry key aliceHash :: Text @@ -270,8 +284,8 @@ testRslvSendsTheHash = resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] -- the record names what the caller asked for case r of - NRNameRecord nr _ -> SMP.nrName nr `shouldBe` "alice.simplex" - _ -> expectationFailure $ "expected NRNameRecord, got: " <> show r + (_, _, Just nr) -> SMP.nrName nr `shouldBe` "alice.simplex" + _ -> expectationFailure $ "expected a record, got: " <> show r where -- the resolver echoes what it was asked about, which is the hash echoed = testNameRecord {SMP.nrName = aliceHash <> ".simplex"} diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 25c912de7..e2e5dc2b1 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -17,7 +17,7 @@ import Network.HTTP.Types (status200, status400, status404, status410, status500 import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NameRecord (..), NameReservedReason (..), NameResponse (..)) +import Simplex.Messaging.Protocol (ErrorType (..), MicroUSD (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..)) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -104,53 +104,63 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do - -- one lookup answers both questions: what the name points to, and whether it - -- could be registered - it "a resolvable name answers with the record and its expiry" $ - answers status200 (recordWith "\"status\":\"registered\",\"expires\":1811232000") (NRNameRecord testNameRecord (Just 1811232000)) + -- one lookup answers all three questions: what the name points to, whether it + -- can be taken, and whether the registry holds it back + it "a resolvable name answers with the record and its registration" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483") $ + (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) + -- an older resolver reports no status; the record is still the answer it "a resolver that sends no status still answers with the record" $ - answers status200 (J.encode testNameRecord) (NRNameRecord testNameRecord Nothing) - it "unregistered name is available" $ - answers status404 "{\"error\":\"unregistered\"}" NRNameAvailable - it "expired name is available" $ - answers status410 "{\"error\":\"expired\"}" NRNameAvailable - it "name in grace carries graceEnds" $ - answers status410 "{\"error\":\"grace\",\"graceEnds\":1796377221}" (NRNameInGrace 1796377221) - it "auction carries premium and auctionEnds" $ - answers - status410 - "{\"error\":\"auction\",\"premium\":\"99999952316384526016153087\",\"auctionEnds\":1798191621}" - (NRNameAuction "99999952316384526016153087" 1798191621) - it "reserved name carries the reason" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"trademark\"}" (NRNameReserved NRTrademark) + answers status200 (J.encode testNameRecord) (Nothing, Nothing, Just testNameRecord) + -- registered, but its records point nowhere + it "registered without a resolver is a registration with no record" $ + answers status404 "{\"error\":\"noResolver\",\"expires\":1813853483,\"graceEnds\":1821629483}" $ + (Nothing, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Nothing) + -- the record travels through grace: the UI decides how long to keep opening it + it "a name in grace keeps its record" $ + answers status200 (recordWith "\"status\":\"grace\",\"expires\":1785000000,\"graceEnds\":1792776000") $ + (Nothing, Just NRRegistered {expires = 1785000000, graceUntil = 1792776000}, Just testNameRecord) + -- a registration the router could not date is not one it can report + it "registered without expiry is a resolver error" $ + refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") + it "unregistered carries the price" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\"," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) + it "past grace carries the premium start" $ + answers status410 (jsonBody ("{\"error\":\"auction\",\"premiumFrom\":1788480000," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing {premiumFrom = Just 1788480000})), Nothing) + it "expired is unregistered" $ + answers status410 (jsonBody ("{\"error\":\"expired\"," <> pricingJson <> "}")) $ + (Nothing, Just (NRUnregistered (Just testPricing)), Nothing) + -- a TLD with no controller or price oracle: registrable, price unknown + it "no pricing from the resolver is no pricing on the wire" $ + answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + -- a held-back name is not for sale at the registry's price + it "reserved carries the reason and no price" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"," <> pricingJson <> "}")) $ + (Just RRTrademark, Just (NRUnregistered Nothing), Nothing) + -- reservation is orthogonal: it is why the name will not free up at expiry + it "reserved and registered keeps both" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ + (Just RRInternal, Just NRRegistered {expires = 1813853483, graceUntil = 1821629483}, Just testNameRecord) -- an older resolver sends no reasonCode; that is not the chain saying "none" - it "no reasonCode still reads as reserved" $ - answers status404 "{\"error\":\"reserved\"}" (NRNameReserved NRUnknown) - -- a later version may name reasons this one cannot; the reservation must - -- survive that, or a client would offer a name it cannot register - it "a reason from a later version still reads as reserved" $ - smpDecode "RESERVED SOMETHING_NEW" `shouldBe` Right (NRNameReserved NRUnknown) - -- the resolver names this one explicitly; it is not the same as not knowing - it "unspecified reason reads as unspecified" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"unspecified\"}" (NRNameReserved NRUnspecified) - it "unknown reason still reads as reserved" $ - answers status404 "{\"error\":\"reserved\",\"reasonCode\":\"astrology\"}" (NRNameReserved NRUnknown) - it "registered without a resolver is taken, with expiry" $ - answers status404 "{\"error\":\"noResolver\",\"expires\":1811232000}" (NRNameTaken (Just 1811232000)) - -- an answer missing its payload withholds the name: quoting the usual price - -- for one that costs a premium is the wrong answer - it "grace without graceEnds is taken" $ - answers status410 "{\"error\":\"grace\"}" (NRNameTaken Nothing) - it "auction without premium is taken" $ - answers status410 "{\"error\":\"auction\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) - -- the wire length-prefixes the price with one byte, so a longer or - -- non-numeric string is dropped rather than re-encoded - it "over-long premium is dropped" $ - answers status410 (jsonBody ("{\"error\":\"auction\",\"premium\":\"" <> replicate 300 '9' <> "\",\"auctionEnds\":1798191621}")) (NRNameTaken Nothing) - it "non-decimal premium is dropped" $ - answers status410 "{\"error\":\"auction\",\"premium\":\"1e26\",\"auctionEnds\":1798191621}" (NRNameTaken Nothing) - -- a resolver that could not answer must not look like an answer: TAKEN would - -- assert a registration nobody read, AVAILABLE would offer a name that is held + it "no reasonCode is not a reservation" $ + answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + -- a later version may reserve names for reasons this one cannot name; the + -- reservation must survive that, or a client would offer a name it cannot get + it "a reason from a later version still reserves the name" $ + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" $ + (Just (RRUnknown "seasonal"), Just (NRUnregistered Nothing), Nothing) + -- the reason re-encodes into a slot that ends at a space, so the router keeps + -- it to one bounded token rather than trusting the resolver's text + it "a reason with a space is cut at the space" $ + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" $ + (Just (RRUnknown "two"), Just (NRUnregistered Nothing), Nothing) + it "an over-long reason is truncated" $ + answers status404 (jsonBody ("{\"error\":\"unregistered\",\"reasonCode\":\"" <> replicate 100 'z' <> "\"}")) $ + (Just (RRUnknown (T.replicate 32 "z")), Just (NRUnregistered Nothing), Nothing) + -- a resolver that could not answer must not look like an answer: a registration + -- would assert one nobody read, and unregistered would offer a name that is held it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") it "unconfigured TLD is a resolver error" $ @@ -167,24 +177,29 @@ availabilitySpec = do withResolverServer (resolveResp status200 (jsonBody ("{\"status\":\"registered\",\"pad\":\"" <> replicate 400 'x' <> "\"}"))) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) {resolverMaxResponseBytes = 200} resolveName env navlDomain `shouldReturn` Left (RESOLVER "response too large") - it "every answer survives the wire" $ + it "every registration survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NRNameRecord testNameRecord (Just 1811232000), - NRNameRecord testNameRecord Nothing, - NRNameAvailable, - NRNameTaken (Just 1811232000), - NRNameTaken Nothing, - NRNameInGrace 1796377221, - NRNameAuction "99999952316384526016153087" 1798191621, - NRNameReserved NRUnspecified, - NRNameReserved NRTrademark, - NRNameReserved NRPublicInterest, - NRNameReserved NROffensive, - NRNameReserved NRInternal, - NRNameReserved NRPremium, - NRNameReserved NRUnknown + [ NRRegistered {expires = 1813853483, graceUntil = 1821629483}, + NRUnregistered Nothing, + NRUnregistered (Just testPricing), + NRUnregistered (Just testPricing {premiumFrom = Just 1788480000}) ] + it "every reason survives the wire" $ + mapM_ + (\a -> smpDecode (smpEncode a) `shouldBe` Right a) + [ RRUnspecified, + RRTrademark, + RRPublicInterest, + RROffensive, + RRInternal, + RRPremium, + RRUnknown "seasonal" + ] + -- the JSON API keeps a closed set so clients can localise it + it "an unknown reason is \"unknown\" in JSON" $ do + J.encode (RRUnknown "seasonal") `shouldBe` "\"unknown\"" + J.encode RRTrademark `shouldBe` "\"trademark\"" where jsonBody = LB.fromStrict . B.pack -- the resolver returns the record and the registration status in one body @@ -197,6 +212,23 @@ availabilitySpec = do resolveName env navlDomain `shouldReturn` expected navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} +-- | The .testing oracle: MicroUSD per year by label length, and a premium that +-- halves daily from $100,000,000 down to a $47.68 floor. +testPricing :: NamePricing +testPricing = + NamePricing + { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], + minLabelLength = 3, + premiumFrom = Nothing, + startPremium = MicroUSD 100000000000000, + endPremium = MicroUSD 47683716 + } + +pricingJson :: String +pricingJson = + "\"rentPrices\":[0,0,127930000,31980000,999300],\"minLabelLength\":3,\ + \\"startPremium\":100000000000000,\"endPremium\":47683716" + parseNameSpec :: Spec parseNameSpec = do -- asking by hash tells the client if a name is taken without naming it @@ -270,7 +302,7 @@ resolverSpec = do it "returns NameRecord on 200 OK" $ withResolverServer (resolveResp status200 (J.encode testNameRecord)) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) - resolveName env aliceDomain `shouldReturn` Right (NRNameRecord testNameRecord Nothing) + resolveName env aliceDomain `shouldReturn` Right (Nothing, Nothing, Just testNameRecord) it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do