diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index c9f699da8..37a47de28 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1462,31 +1462,43 @@ The `RSLV` command carries the canonical fully-qualified name directly as the payload (not JSON): ```abnf -rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consuming the remainder of the transmission +rslv = %s"RSLV" SP query +query = tld label sub +tld = %s"s" / %s"t" / %s"w" ; .simplex / .testing / a web name +label = %s"N" shortString ; the second-level label as text + / %s"H" 32*32 OCTET ; its keccak-256 +sub = length *shortString ; subname labels, parent to child ``` `domain` is the UTF-8 canonical fully-qualified name with the TLD always explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to 253 bytes. -**Hashed labels.** The second-level label MAY be given as `[` + 64 lowercase hex -+ `]`, the keccak-256 hash of that label, so a router can answer without being -told the name. This is ENS's encoding for an unknown preimage; brackets are -outside the name character set, so it cannot collide with a real name. A hashed -label is 66 characters and is exempt from the 63-byte label limit — it is a -registry key, not a DNS label. A bare `0x` hex string is an ordinary label, and -would be hashed again, keying a different name. +**Hashed labels.** `RSLV` does not carry a name. It carries a query, whose +second-level label is either the label itself or the keccak-256 of it, tagged so +that the two are told apart by the encoding rather than by their shape. Nothing +decides what a label is by counting characters or looking for punctuation. -Only the second-level label may be hashed; subname labels are needed as text to -reach the record. `[].simplex` and `sub.[].simplex` reach the nodes -their plain names do; a bracket label anywhere else is an ordinary label. Routers -MUST reject a name whose hashed label is not the second-level one. +Only the second-level label may be hashed: subname labels are needed as text to +reach the record, and a web TLD has no registry to key on. `sub..simplex` +reaches the node `sub.name.simplex` does. -From v22 a client MUST hash the second-level label of every `RSLV`. -Older routers cannot parse the form, so a client on an older session sends the -name. A hashed query's record names the hash; the client restores the name it -used. A router answering a hashed query does not know the name's length, so it -cannot check a minimum-length policy either. +From v22 a client MUST send the hash. Older routers can only read the name, so a +client on an older session sends it, and gets what it always got. A router +answering a hashed query does not know the name's length, so it cannot check a +minimum-length policy either — the client does that, from the pricing it is sent. + +The hash reaches the backing resolver as `[` + 64 lowercase hex + `]`, ENS's +encoding for a label whose text is unknown, because that is what its HTTP API +takes. That form appears nowhere in SMP. + +A hashed query still answers with the name. The registrar records the plaintext +label when a name is registered, keyed by the hash of that label, so a router can +look up what the hash stands for without ever being told. It is not the client's +word for it and needs no checking: the key is the hash of the value. A name +registered without that record answers `unknown`. What stays impossible is +learning a name that is *not* registered — there is nothing recorded to look up, +so a name someone is merely considering never becomes known. **Server-side validation.** The names router parses `domain` as a fully-qualified name (TLD required — bare labels are rejected) and forwards it @@ -1502,7 +1514,7 @@ several configured servers can act on distinctly: | `RNAME` | the router read the registry | use it | | `ERR NAME NOT_FOUND` | the router could not read any answer for the name; below v22 also every name that does not resolve | stop, and do not read it as registrable | | `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next | -| `ERR NAME RESOLVER ` | the resolver answered something the router cannot act on: an unconfigured TLD, an unreachable chain, a transport failure, a timeout | surface ``; retry only if it reads as transient | +| `ERR NAME RESOLVER ` | the router cannot state an answer completely: no registrar or price oracle for the TLD, an unreachable chain, a transport failure, a timeout, a registration it could not date or resolve | surface ``; retry only if it reads as transient | A client SHOULD NOT broadcast a `name` to further servers after a name-capable router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already @@ -1513,56 +1525,53 @@ fact that this router cannot resolve, so iterating past it is safe. 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 three facts, from three -contracts - the controller, the registrar and the resolver - and any of them may -be absent. +register wants to show what took it. `RNAME` carries what the registry holds. ```abnf -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" / word +rname = %s"RNAME" SP registration +registration = %s"N" optTime optTime reserved SP json-bytes ; registered + / %s"A" optTime pricing ; available + / %s"R" reason ; reserved +optTime = %s"0" / (%s"1" 8*8 OCTET) ; Int64, big-endian, unix seconds +reserved = %s"0" / (%s"1" reason) ; absent = not held back +pricing = tiers basePrice minLabelLength +tiers = length *(2*2 OCTET 8*8 OCTET) ; label length -> US cents per year +basePrice = 8*8 OCTET ; US cents per year for every other length +minLabelLength = 2*2 OCTET ; characters +reason = %s"internal" / %s"trademark" / %s"community" / word word = 1*32(%x21-7E) ; a reason this version has no word for ``` -`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. +On `N` the two `optTime` fields are the expiry and the end of the grace period, +in that order, and `json-bytes` is the record, consuming the remainder of the +transmission. On `A` the `optTime` is when a post-grace surcharge decays to +nothing. The reason words are the same on the wire, in the backing resolver's +JSON and in a client's own API. -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. +Money is US cents; 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. -A client reads the three facts together: - -| The client sees | Meaning | +| Answer | 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 | +| `N` | registered: held by someone until the expiry, renewable by its owner alone until the end of grace. It always carries a record: where the owner set none, every field is unset and the resolver address is zero | +| `A` | available: held by nobody and registrable now, at `pricing` | +| `R` | reserved: held back by the registry and not registered | -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. +Availability is not a state of its own but the conjunction the registry itself +computes: registrable means `A`, since a name that is held back answers `R` instead. A reservation on a name that *is* registered rides along in +the `reserved` field, and is why that name will not free up when it expires. -A router MUST NOT send `pricing` for a reserved name. A name the registry holds +An auction is not a state either. A name past its grace period answers `A` +with the ordinary price, plus the time its surcharge expires. The +surcharge itself is deliberately not carried: it decays continuously, so it +cannot be quoted as a purchase price. A client shows the ordinary price and +counts down to when it applies. + +A router MUST NOT quote a price 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 registry will not honour - which is why `R` has no pricing field at all +rather than an empty one. 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 @@ -1570,52 +1579,48 @@ 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: +**Computing the price.** In US cents, for a duration in 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) +price len duration = tier len * duration / 31536000 +tier len = the entry for len in tiers, or basePrice when len is not in tiers ``` -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. +The registry's minimum registration is 28 days, a contract constant rather than +a per-deployment value, so it is specified here rather than sent. `tiers` omits +any length below `minLabelLength`, those being unregistrable. `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. -`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 and nothing else, and every answer +without one 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. In the other direction a v22 client reads such an +answer as `N` with no expiry, grace or reservation - which is the only +reason those three fields are optional. -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 `A` +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. -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 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 router that cannot state an answer completely MUST say so as `ERR NAME +RESOLVER ` rather than answer partially. That covers a TLD with no +registrar or no price oracle configured, an unreachable chain, a timeout, a +registration it could not date, a registered name it could not resolve, and any +status word it does not recognise. Neither a registration nor availability may +be guessed: one would assert a registration nobody read, the other would offer a +name that may be held. 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. 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. +registered. The word itself travels, unchanged, so that a later client can act +on it and a current one can show or log it - which is why the set is open rather +than an enumeration. A router sends at most one bounded token of printable +ASCII, since the field ends at a space. +`json-bytes` MUST be a UTF-8 JSON object with the following schema: `json-bytes` MUST be a UTF-8 JSON object with the following schema: | Field | JSON type | Constraints | @@ -1640,11 +1645,10 @@ 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 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. +The record carries no expiry field of its own: `N` carries it alongside. +The backing resolver stops resolving a name once it is registrable by anyone, so +a record only ever accompanies `N`. 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 66f32c40b..23f54f837 100755 --- a/scripts/resolver/service/snrc-resolve.py +++ b/scripts/resolver/service/snrc-resolve.py @@ -107,13 +107,12 @@ CONTROLLERS = { # `reservedNames` holds a SimplexController.Reason; 0 means not reserved. A # controller from before the enum stores a bool, whose `true` decodes as 1. +# SimplexController.Reason. 1 is also what the boolean reservedNames of the +# first .testing deployment set, which is why it reads as "internal". RESERVED_REASONS = { - 1: ("unspecified", "reserved for a brand or public interest"), + 1: ("internal", "reserved for SimpleX"), 2: ("trademark", "reserved to protect a trademark"), - 3: ("publicInterest", "reserved in the public interest"), - 4: ("offensive", "reserved as an offensive name"), - 5: ("internal", "reserved for SimpleX"), - 6: ("premium", "reserved as a premium name"), + 3: ("community", "reserved for the community"), } # a Reason added to the contract after this resolver: still reserved, unworded UNKNOWN_REASON = ("unknown", "reserved") @@ -126,8 +125,8 @@ COIN_DOT = 354 ZERO_ADDR = "0x0000000000000000000000000000000000000000" -# The registry prices in attoUSD (1e-18 USD); the protocol carries MicroUSD. -ATTO_PER_MICRO = 10**12 +# The registry prices in attoUSD (1e-18 USD); the protocol carries US cents. +ATTO_PER_CENT = 10**16 SECONDS_PER_YEAR = 31536000 @@ -262,13 +261,11 @@ def read_pricing_params(tld: str): 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 = [] + # The oracle prices rent in attoUSD per second. Quotes round up, so one is + # never below what the registry charges. An oracle built before the + # six-letter tier stops at five, and the contract then charges price5Letter + # for anything longer - which is what basePrice means here. + tiers = {} for n in range(1, 7): try: rate = decode_uint(eth_call(oracle, selector(f"price{n}Letter()"))) @@ -276,15 +273,27 @@ def read_oracle_prices(controller: str, oracle: str): if n <= 5: raise break - rents.append(ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_MICRO)) + tiers[n] = ceil_div(rate * SECONDS_PER_YEAR, ATTO_PER_CENT) + base = tiers.pop(max(tiers)) + min_len = decode_uint(eth_call(controller, selector("minCharLength()"))) 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, + # lengths the registry refuses are left out rather than priced at zero + "rentPrices": {n: c for n, c in tiers.items() if n >= min_len}, + "basePrice": base, + "minLabelLength": min_len, + # not sent: only used to date the end of the surcharge window + "_auctionDays": auction_days(oracle), } +def auction_days(oracle: str) -> int: + """The surcharge halves daily from startPremium until it falls below + endValue, so the window is log2(startPremium / endValue) days.""" + start = decode_uint(eth_call(oracle, selector("startPremium()"))) + end = decode_uint(eth_call(oracle, selector("endValue()"))) + return (start // end).bit_length() - 1 if end and start > end else 0 + + def ceil_div(a: int, b: int) -> int: return -(-a // b) @@ -300,17 +309,13 @@ def name_status(name: str): "graceEnds": None, "reasonCode": None, "reason": None, - "premiumFrom": None, + "auctionUntil": None, } # nameExpires and reservedNames are keyed on uint256(keccak(label)). # Only the 2LD's label is a registry key, wherever it sits - the same rule # node_of applies to the node. - label = labels[-2] - if is_encoded_labelhash(label): - token = int(label[1:-1], 16) - else: - token = int.from_bytes(keccak(label.encode()), "big") + token = label_token(labels[-2]) expires = decode_uint( eth_call(registrar, selector("nameExpires(uint256)") + encode_uint(token)) ) @@ -332,12 +337,17 @@ def name_status(name: str): "graceEnds": (expires + grace) if expires else None, "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, + "auctionUntil": None, } if status in ("unregistered", "expired"): - out.update(pricing_params(tld) or {}) + pricing = pricing_params(tld) + if pricing: + out.update({k: v for k, v in pricing.items() if not k.startswith("_")}) + # past grace the name is registrable again, but at a surcharge until + # the oracle's window closes; the surcharge itself never travels + ends = (expires + grace + pricing["_auctionDays"] * 86400) if expires else 0 + if status == "expired" and ends > now: + out["auctionUntil"] = ends return out @@ -364,6 +374,35 @@ def decode_bytes(hex_data: str) -> bytes: return raw[64:64 + length] +def registered_label(registrar: str, token: int) -> str: + """The registrar records the plaintext label at registration, keyed by its + own hash, so a hashed query still answers with the name it asked about. A + name registered without registerWithLabel has none, which is an error we + name rather than paper over.""" + raw = decode_bytes(eth_call(registrar, selector("labelOf(uint256)") + encode_uint(token))) + return raw.decode("utf-8", errors="replace") if raw else "unknown" + + +def canonical_name(name: str) -> str: + """The name the registry holds. A hashed query never told anyone the name, + so the registrar's own record of it is what comes back; a plaintext query + already carries it.""" + labels = name.split(".") + registrar = REGISTRARS.get(labels[-1]) + if not registrar or len(labels) < 2 or not is_encoded_labelhash(labels[-2]): + return name + label = registered_label(registrar, label_token(labels[-2])) + return ".".join(labels[:-2] + [label, labels[-1]]) + + +def label_token(label: str) -> int: + """The registry key for a second-level label, whether it arrived as text or + already hashed.""" + if is_encoded_labelhash(label): + return int(label[1:-1], 16) + return int.from_bytes(keccak(label.encode()), "big") + + def decode_uint(hex_data: str) -> int: raw = hex_data[2:] if hex_data.startswith("0x") else hex_data return int(raw[-64:], 16) if raw else 0 @@ -666,12 +705,24 @@ def resolve(name: str): 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, + # A registered name always resolves. With no resolver set the record is + # still returned, every field unset, so that "taken until " stays + # answerable for the name a would-be registrant is asking about. + owner = decode_address(eth_call(registry, selector("owner(bytes32)") + node_hex)) + return 200, { + "name": canonical_name(name), + "nickname": "", + "website": "", + "location": "", + "simplexContact": [], + "simplexChannel": [], + "eth": None, + "btc": None, + "xmr": None, + "dot": None, + "owner": owner, + "resolver": ZERO_ADDR, **reg, - "status": "noResolver", - "error": "noResolver", - "message": "no resolver set for this name", } owner_raw = eth_call(registry, selector("owner(bytes32)") + node_hex) @@ -696,7 +747,7 @@ def resolve(name: str): # use the ENSIP-5 dot convention (e.g. "simplex.contact") — only the # resolver's JSON surface camelCases them. return 200, { - "name": name, + "name": canonical_name(name), "nickname": nickname, "website": texts.get("url", ""), "location": texts.get("location", ""), diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 6306f4045..06295948f 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 (..), - NameResult, + NameRegistration (..), 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 NameResult +resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AE NameRegistration 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 NameResult +resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameRegistration 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 c45a34c2d..1890ac8bc 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, - NameResult, + NameRegistration (..), 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 NameResult +resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexDomain -> AM NameRegistration 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 94e91c0fa..ea0db5257 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -166,7 +166,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON import Simplex.Messaging.Protocol import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo -import Simplex.Messaging.SimplexName (SimplexDomain, fullDomainName, hashedDomain) +import Simplex.Messaging.SimplexName (SimplexDomain) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -1054,20 +1054,11 @@ proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm p -- through `proxySMPCommand` and pattern-matches the expected RNAME response. -- Version-gated on the destination relay (mirrors `connectSMPProxiedRelay`): -- the client never sends RSLV to a relay that predates names support. --- | How a name goes on the wire. From v22 the second-level label is sent as its --- hash; older routers can only parse the name. -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 -> Maybe NameRecord -> Maybe NameRecord -askedName name = fmap $ \nr -> nr {nrName = fullDomainName name} - -proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameResult) +proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> SimplexDomain -> ExceptT SMPClientError IO (Either ProxyClientError NameRegistration) proxyResolveName c nm proxiedRelay name | v >= namesSMPVersion = - proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (queryDomain v name)) >>= \case - Right (RNAME reserved_ reg_ rec_) -> pure $ Right (reserved_, reg_, askedName name rec_) + proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV (nameQuery v name)) >>= \case + Right (RNAME reg) -> pure $ Right reg Right r -> throwE $ unexpectedResponse r Left e -> pure $ Left e | otherwise = throwE $ PCETransportError TEVersion @@ -1079,11 +1070,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 NameResult +directResolveName :: SMPClient -> NetworkRequestMode -> SimplexDomain -> ExceptT SMPClientError IO NameRegistration directResolveName c nm name | v >= namesSMPVersion = - sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (queryDomain v name))) >>= \case - RNAME reserved_ reg_ rec_ -> pure (reserved_, reg_, askedName name rec_) + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV (nameQuery v name))) >>= \case + RNAME reg -> pure reg r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index d23ef15ec..ae012c2c4 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -82,6 +82,8 @@ module Simplex.Messaging.Protocol ProxyError (..), NameQuery (..), NameQueryLabel (..), + nameQuery, + queryName, NameRegistration (..), NamePricing (..), USDCents (..), @@ -281,7 +283,7 @@ import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme import Simplex.Messaging.SystemTime (SystemSeconds) -import Simplex.Messaging.SimplexName (LabelHash, SimplexTLD) +import Simplex.Messaging.SimplexName (LabelHash, SimplexDomain (..), SimplexTLD (..), domainName, labelHash, labelHashText) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) @@ -1627,6 +1629,26 @@ instance Encoding NameQueryLabel where 'H' -> NQHash <$> smpP _ -> fail "bad NameQueryLabel" +-- | How the backing resolver is addressed for this query. The only place a +-- hashed label is written as text, and it faces the resolver's HTTP API - the +-- SMP protocol tags the choice instead of spelling it. +queryName :: NameQuery -> Text +queryName NameQuery {queryTLD, queryLabel, querySub} = domainName queryTLD label querySub + where + label = case queryLabel of + NQName t -> t + NQHash h -> labelHashText h + +-- | The name a client asked about, hashed from v22 so the router is never told +-- what it is. A web TLD has no registry, so it is never hashed. +nameQuery :: VersionSMP -> SimplexDomain -> NameQuery +nameQuery v SimplexDomain {nameTLD, domain, subDomain} = + NameQuery {queryTLD = nameTLD, queryLabel = label, querySub = subDomain} + where + label + | v >= nameAvailSMPVersion && nameTLD /= TLDWeb = NQHash (labelHash domain) + | otherwise = NQName domain + instance Encoding NameQuery where smpEncode NameQuery {queryTLD, queryLabel, querySub} = smpEncode (queryTLD, queryLabel, EncList querySub) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 74697a070..f50f694e2 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1494,12 +1494,12 @@ client Just nenv -> pure (Just nenv) -- Runs on a forked thread so RSLV does not block other commands; -- concurrency is limited by serverResolverConcurrency in forkCmd. - resolveNameMsg :: NamesEnv -> SimplexDomain -> M s BrokerMsg + resolveNameMsg :: NamesEnv -> NameQuery -> M s BrokerMsg resolveNameMsg nenv d = do st <- asks (rslvStats . serverStats) (selector, msg) <- liftIO (resolveName nenv d) <&> \case - Right (reserved_, reg_, rec_) -> (rslvSucc, RNAME reserved_ reg_ rec_) + Right reg -> (rslvSucc, RNAME reg) 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 9b23f3112..985cf21a3 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -3,7 +3,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} -{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -18,10 +17,11 @@ where import qualified Control.Exception as E import Control.Logger.Simple (logError) -import Data.Maybe (fromMaybe, isJust) +import Data.Maybe (fromMaybe) import Data.Text (Text) +import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Messaging.Protocol (NameErrorType (..), MicroUSD (..), NamePricing (..), NameRecord, NameRegistration (..), NameResult, NameReservedReason, reservedReason) +import Simplex.Messaging.Protocol (NameErrorType (..), NamePricing (..), NameRecord, NameQuery, NameRegistration (..), NameReservedReason, USDCents (..), oldRegistration, parseReservedReason, queryName) import Simplex.Messaging.Server.Names.HttpResolver ( NameStatusResp (..), ResolverEnv, @@ -32,7 +32,7 @@ import Simplex.Messaging.Server.Names.HttpResolver newResolverEnv, resolveHttp, ) -import Simplex.Messaging.SimplexName (SimplexDomain, fullDomainName) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import System.Timeout (timeout) data NamesConfig = NamesConfig @@ -60,9 +60,9 @@ pingEndpoint :: NamesEnv -> IO (Either ResolverError ()) pingEndpoint NamesEnv {resolverEnv, config} = fromMaybe (Left ResolverTimeout) <$> timeout (resolverTimeoutMs config * 1000) (healthHttp resolverEnv) -resolveName :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) -resolveName env d = do - r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env d)) +resolveName :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +resolveName env q = do + r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetch env q)) case r of Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) Left e @@ -71,61 +71,59 @@ resolveName env d = do logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) pure (Left (RESOLVER "resolver error")) +fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameRegistration) +fetch NamesEnv {resolverEnv} q = + either (Left . mapResolverError) nameRegistration <$> resolveHttp resolverEnv (queryName q) + +-- | A resolver that reports no status at all is an older one, and only ever +-- returned a record for a live registration - which is what oldRegistration says. +nameRegistration :: (Maybe NameRecord, Maybe NameStatusResp) -> Either NameErrorType NameRegistration +nameRegistration = \case + (rec_, Just ns) -> mapStatus rec_ ns + (Just rec, Nothing) -> Right (oldRegistration rec) + (Nothing, Nothing) -> Left NOT_FOUND + +-- | The resolver's vocabulary. A status this router has no word for is not an +-- answer: a registration would assert one nobody read, and availability would +-- offer a name that may be held. +mapStatus :: Maybe NameRecord -> NameStatusResp -> Either NameErrorType NameRegistration +mapStatus rec_ ns@NameStatusResp {nsStatus, nsExpires, nsGraceEnds, nsReasonCode, nsAuctionUntil} = + case nsStatus of + "registered" -> registered + "grace" -> registered + "unregistered" -> available + "expired" -> available + s -> Left (RESOLVER (T.take 32 s)) + where + reservedReason_ = resolverReason <$> nsReasonCode + -- A registered name resolves: where the owner set no records the resolver + -- still returns one, every field unset. And a registration this router + -- could not date is not one it can report. + registered = case (rec_, nsExpires, nsGraceEnds) of + (Just nameRecord, Just expires, Just graceUntil) -> + Right NRRegistered {expires = Just (RoundedSystemTime expires), graceUntil = Just (RoundedSystemTime graceUntil), reservedReason_, nameRecord} + (Nothing, _, _) -> Left (RESOLVER "no record") + _ -> Left (RESOLVER "no expiry") + -- A held-back name is quoted no price: what it costs, and whether it can be + -- had at all, is a conversation with SimpleX. + available = case reservedReason_ of + Just r -> Right (NRReserved r) + Nothing -> case namePricing ns of + Just pricing -> Right NRAvailable {pricing, auctionUntil = RoundedSystemTime <$> nsAuctionUntil} + Nothing -> Left (RESOLVER "no price oracle") + -- | 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 (/= ' ') +resolverReason = parseReservedReason . T.take 32 . T.takeWhile (\c -> c > ' ' && c < '\DEL') -fetch :: NamesEnv -> SimplexDomain -> IO (Either NameErrorType NameResult) -fetch NamesEnv {resolverEnv} d = - either (Left . mapResolverError) nameResult <$> resolveHttp resolverEnv (fullDomainName d) - --- | 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. 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" -> registered - "grace" -> registered - "unregistered" -> Right unregistered - "expired" -> Right unregistered - "auction" -> Right unregistered - s -> Left (RESOLVER (T.take 32 s)) - where - 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 +namePricing NameStatusResp {nsRentPrices, nsBasePrice, nsMinLabelLength} = do + rentPrices <- M.map USDCents <$> nsRentPrices + basePrice <- USDCents <$> nsBasePrice minLabelLength <- nsMinLabelLength - startPremium <- MicroUSD <$> nsStartPremium - endPremium <- MicroUSD <$> nsEndPremium - pure NamePricing {rentPrices, minLabelLength, premiumFrom = nsPremiumFrom, startPremium, endPremium} - + pure NamePricing {rentPrices, basePrice, minLabelLength} mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 0e063c080..e0106a3ad 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -45,6 +45,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client @@ -90,15 +91,16 @@ data NameStatusResp = NameStatusResp nsGraceEnds :: Maybe Int64, -- | 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 + -- | when the post-grace surcharge decays to nothing, so the client can + -- count down to the ordinary price. The surcharge itself never travels. + nsAuctionUntil :: Maybe Int64, + -- | the TLD's price oracle, in US cents per year: the lengths it prices + -- specially, and the price for every other length. 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 (Map Int Int64), + nsBasePrice :: Maybe Int64, + nsMinLabelLength :: Maybe Int } deriving (Show) @@ -174,11 +176,10 @@ resolveHttp ResolverEnv {manager, baseUrl, authHdr, timeoutMicro, maxResponseByt nsExpires = jsonField o "expires", nsGraceEnds = jsonField o "graceEnds", nsReasonCode = jsonField o "reasonCode", - nsPremiumFrom = jsonField o "premiumFrom", + nsAuctionUntil = jsonField o "auctionUntil", nsRentPrices = jsonField o "rentPrices", - nsMinLabelLength = jsonField o "minLabelLength", - nsStartPremium = jsonField o "startPremium", - nsEndPremium = jsonField o "endPremium" + nsBasePrice = jsonField o "basePrice", + nsMinLabelLength = jsonField o "minLabelLength" } -- | A field the resolver omits or nulls for statuses that do not carry it. diff --git a/src/Simplex/Messaging/SimplexName.hs b/src/Simplex/Messaging/SimplexName.hs index 0a4ce79ab..7c9e380df 100644 --- a/src/Simplex/Messaging/SimplexName.hs +++ b/src/Simplex/Messaging/SimplexName.hs @@ -10,6 +10,7 @@ module Simplex.Messaging.SimplexName SimplexTLD (..), SimplexNameType (..), fullDomainName, + domainName, LabelHash (..), labelHash, labelHashText, @@ -150,9 +151,13 @@ instance Encoding SimplexTLD where _ -> fail "bad SimplexTLD" fullDomainName :: SimplexDomain -> Text -fullDomainName SimplexDomain {nameTLD, domain, subDomain} = T.intercalate "." (reverse subDomain ++ [domain] ++ tld') +fullDomainName SimplexDomain {nameTLD, domain, subDomain} = domainName nameTLD domain subDomain + +-- | A dotted name from its parts, whatever the second-level label is written as. +domainName :: SimplexTLD -> Text -> [Text] -> Text +domainName tld label sub = T.intercalate "." (reverse sub ++ [label] ++ tld') where - tld' = case nameTLD of + tld' = case tld of TLDSimplex -> ["simplex"] TLDTesting -> ["testing"] TLDWeb -> [] diff --git a/tests/AgentTests/ResolveNameTests.hs b/tests/AgentTests/ResolveNameTests.hs index 5acb59b28..9a769ddf6 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 as unregistered" testAvailSuccess + it "an unregistered name answers as available" testAvailSuccess testAvailSuccess :: HasCallStack => IO () testAvailSuccess = withDirectResolver (status404, "{\"error\":\"unregistered\"}") $ \c -> do r <- runExceptT $ resolveSimplexName c NRMInteractive 1 (SimplexDomain TLDSimplex "alice" []) case r of - Right (Nothing, Just (SMP.NRUnregistered _), Nothing) -> pure () - _ -> expectationFailure $ "expected Right (_, NRUnregistered, _), got: " <> show r + Right (SMP.NRAvailable {}) -> pure () + _ -> expectationFailure $ "expected Right NRAvailable, 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 (_, _, Just nr) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (_, _, Just record), got: " <> show r + Right (SMP.NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right NRRegistered, got: " <> show r diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index c7cd54b21..57467aeaf 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -15,6 +15,7 @@ import Control.Monad.Trans.Except (ExceptT, runExceptT) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB +import qualified Data.Map.Strict as M import Data.IORef (IORef, readIORef) import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) @@ -34,9 +35,10 @@ import Simplex.Messaging.Protocol Command (..), CorrId (..), ErrorType (..), - MicroUSD (..), NamePricing (..), NameRegistration (..), + NameReservedReason (..), + USDCents (..), NameErrorType (..), NameReservedReason (..), SParty (..), @@ -49,6 +51,7 @@ import Simplex.Messaging.Protocol ) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.SimplexName (SimplexDomain) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec hiding (fit, it) @@ -75,7 +78,7 @@ withProxyAndResolver (st, body) runTest = sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) sendRslv h@THandle {params} corrId d = do - let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV d)) + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (SMP.nameQuery currentClientSMPRelayVersion d))) [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) r :| _ <- tGetClient h pure r @@ -148,7 +151,7 @@ testRslvVersion = Left (PCETransportError TEVersion) -> pure () _ -> expectationFailure $ "expected Left (PCETransportError TEVersion), got: " <> show r -forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameResult)) +forwardedResolveAlice :: IO (Either SMPClientError (Either ProxyClientError SMP.NameRegistration)) forwardedResolveAlice = do g <- C.newRandom ts <- getCurrentTime @@ -171,8 +174,8 @@ testRslvForwardedSuccess :: IO () testRslvForwardedSuccess = withProxyAndResolver (status200, J.encode testNameRecord) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (_, _, Just nr)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (Right (_, _, Just record)), got: " <> show r + Right (Right NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (Right NRRegistered), got: " <> show r testRslvSuccess :: IO () testRslvSuccess = @@ -181,30 +184,30 @@ testRslvSuccess = (corrId, _entId, resp) <- sendRslv h "rs07" (domain "alice.simplex") corrId `shouldBe` CorrId "rs07" case resp of - Right (RNAME Nothing Nothing (Just nr)) -> nr `shouldBe` testNameRecord - _ -> expectationFailure $ "expected Right (RNAME _ _ (Just record)), got: " <> show resp + Right (RNAME NRRegistered {nameRecord}) -> nameRecord `shouldBe` testNameRecord + _ -> expectationFailure $ "expected Right (RNAME NRRegistered), got: " <> show resp testRslvAvailable :: IO () testRslvAvailable = - withResolverServer (status404, "{\"error\":\"unregistered\"}") $ + withResolverServer (status404, availableBody) $ testSMPClient @TLS $ \h -> do (corrId, _entId, resp) <- sendRslv h "na01" (domain "ghost.simplex") corrId `shouldBe` CorrId "na01" - resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered Nothing)) Nothing) + resp `shouldBe` Right (RNAME (NRAvailable auctionPricing Nothing)) testRslvAuction :: IO () testRslvAuction = withResolverServer (status410, auctionBody) $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na02" (domain "lapsed.simplex") - resp `shouldBe` Right (RNAME Nothing (Just (NRUnregistered (Just auctionPricing))) Nothing) + resp `shouldBe` Right (RNAME (NRAvailable auctionPricing (Just (RoundedSystemTime 1790294400)))) testRslvReserved :: IO () testRslvReserved = withResolverServer (status404, "{\"error\":\"unregistered\",\"reasonCode\":\"trademark\"}") $ testSMPClient @TLS $ \h -> do (_, _, resp) <- sendRslv h "na03" (domain "acme.simplex") - resp `shouldBe` Right (RNAME (Just RRTrademark) (Just (NRUnregistered Nothing)) Nothing) + resp `shouldBe` Right (RNAME (NRReserved NRRTrademark)) -- | 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. @@ -224,11 +227,11 @@ testRslvOldClientRecord = withResolverServer (status200, J.encode testNameRecord) $ do pc <- oldClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) - r `shouldBe` (Nothing, Nothing, Just testNameRecord) + r `shouldBe` NRRegistered Nothing Nothing Nothing testNameRecord testRslvOldClientNotFound :: IO () testRslvOldClientNotFound = - withResolverServer (status404, "{\"error\":\"unregistered\"}") $ do + withResolverServer (status404, availableBody) $ do pc <- oldClient r <- runExceptT (directResolveName pc NRMInteractive (domain "alice.simplex")) case r of @@ -239,23 +242,26 @@ testRslvForwardedAuction :: IO () testRslvForwardedAuction = withProxyAndResolver (status410, auctionBody) $ forwardedResolveAlice >>= \r -> case r of - Right (Right (Nothing, Just (NRUnregistered (Just p)), Nothing)) -> premiumFrom p `shouldBe` Just 1788480000 - _ -> expectationFailure $ "expected Right (Right unregistered-with-premium), got: " <> show r + Right (Right (NRAvailable _ auctionUntil)) -> auctionUntil `shouldBe` Just (RoundedSystemTime 1790294400) + _ -> expectationFailure $ "expected Right (Right NRAvailable), got: " <> show r --- a name three days past its grace period, priced by the .testing auction curve +pricingJson :: LB.ByteString +pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" + +availableBody :: LB.ByteString +availableBody = "{\"error\":\"unregistered\"," <> pricingJson <> "}" + +-- a name past its grace period, still inside the window where it costs a +-- surcharge above the ordinary price auctionBody :: LB.ByteString -auctionBody = - "{\"error\":\"auction\",\"premiumFrom\":1788480000,\"rentPrices\":[0,0,127930000,31980000,999300],\ - \\"minLabelLength\":3,\"startPremium\":100000000000000,\"endPremium\":47683716}" +auctionBody = "{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}" auctionPricing :: NamePricing auctionPricing = NamePricing - { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], - minLabelLength = 3, - premiumFrom = Just 1788480000, - startPremium = MicroUSD 100000000000000, - endPremium = MicroUSD 47683716 + { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], + basePrice = USDCents 100, + minLabelLength = 3 } -- keccak-256("alice"), the registry key @@ -278,21 +284,19 @@ currentClient = do testRslvSendsTheHash :: IO () testRslvSendsTheHash = - withResolverServerReqs (status200, J.encode echoed) $ \reqs -> do + withResolverServerReqs (status200, J.encode testNameRecord) $ \reqs -> do pc <- currentClient r <- runExceptT' (directResolveName pc NRMInteractive (domain "alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", aliceHash <> ".simplex"]] - -- the record names what the caller asked for + -- the client never sent the name, and the record still names it: the + -- registrar records the label at registration, keyed by its own hash case r of - (_, _, 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"} + NRRegistered {nameRecord} -> SMP.nrName nameRecord `shouldBe` "alice.simplex" + _ -> expectationFailure $ "expected NRRegistered, got: " <> show r testSubnameKeepsItsLabels :: IO () testSubnameKeepsItsLabels = - withResolverServerReqs (status404, "{\"error\":\"unregistered\"}") $ \reqs -> do + withResolverServerReqs (status404, availableBody) $ \reqs -> do pc <- currentClient _ <- runExceptT' (directResolveName pc NRMInteractive (domain "x.alice.simplex")) resolvePaths reqs `shouldReturn` [["resolve", "x." <> aliceHash <> ".simplex"]] diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index e2e5dc2b1..975938e55 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -11,13 +11,14 @@ import qualified Data.ByteString.Lazy as LB import Data.Either (isLeft, isRight) import Data.IORef (readIORef) import Data.List (sort) +import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Types (status200, status400, status404, status410, status500, status502) import NamesResolverServer (resolveResp, testNamesConfig, withResolverServer, withResolverServerDelayed) import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) -import Simplex.Messaging.Protocol (ErrorType (..), MicroUSD (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..)) +import Simplex.Messaging.Protocol (ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), USDCents (..), nameQuery, queryName) import Simplex.Messaging.Server.Main (validateUrl) import Simplex.Messaging.Server.Names ( NamesConfig (..), @@ -27,7 +28,9 @@ import Simplex.Messaging.Server.Names resolveName, ) import Simplex.Messaging.Server.Names.HttpResolver (ResolverError (..)) -import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName, hashedDomain) +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexTLD (..), fullDomainName) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..)) +import Simplex.Messaging.Transport (currentClientSMPRelayVersion, namesSMPVersion) import Test.Hspec testNameRecord :: NameRecord @@ -104,63 +107,51 @@ errorWireSpec = availabilitySpec :: Spec availabilitySpec = do - -- 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" $ + -- one lookup answers what the name points to, whether it can be taken, and + -- whether the registry holds it back + it "a registered name answers with its record and dates" $ 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) (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) + NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord} -- 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" $ + NRRegistered {expires = Just (RoundedSystemTime 1785000000), graceUntil = Just (RoundedSystemTime 1792776000), reservedReason_ = Nothing, nameRecord = testNameRecord} + -- reservation is orthogonal: it is why the name will not free up at expiry + it "a registered name can be held back too" $ + answers status200 (recordWith "\"status\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483,\"reasonCode\":\"internal\"") $ + NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Just NRRInternal, nameRecord = 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) $ + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord = testNameRecord} + it "an unregistered name answers with 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) + NRAvailable {pricing = testPricing, auctionUntil = Nothing} + it "expired is available, counting down to the ordinary price" $ + answers status410 (jsonBody ("{\"error\":\"expired\",\"auctionUntil\":1790294400," <> pricingJson <> "}")) $ + NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)} -- 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 is not a reservation" $ - answers status404 "{\"error\":\"unregistered\"}" (Nothing, Just (NRUnregistered Nothing), Nothing) + NRReserved NRRTrademark -- 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) + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"seasonal\"}" (NRReserved (NRRUnknown "seasonal")) -- 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) + answers status404 "{\"error\":\"unregistered\",\"reasonCode\":\"two words\"}" (NRReserved (NRRUnknown "two")) 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 + NRReserved (NRRUnknown (T.replicate 32 "z")) + -- a name that cannot be dated or priced is not one this router reports on + it "a registration without expiry is a resolver error" $ + refuses status200 (recordWith "\"status\":\"registered\"") (RESOLVER "no expiry") + it "a registered name without a record is a resolver error" $ + refuses status404 "{\"error\":\"registered\",\"expires\":1813853483,\"graceEnds\":1821629483}" (RESOLVER "no record") + it "no price oracle is a resolver error" $ + refuses status404 "{\"error\":\"unregistered\"}" (RESOLVER "no price oracle") it "upstream failure is a resolver error" $ refuses status502 "{\"error\":\"upstreamError\"}" (RESOLVER "upstreamError") it "unconfigured TLD is a resolver error" $ @@ -180,26 +171,19 @@ availabilitySpec = do it "every registration survives the wire" $ mapM_ (\a -> smpDecode (smpEncode a) `shouldBe` Right a) - [ NRRegistered {expires = 1813853483, graceUntil = 1821629483}, - NRUnregistered Nothing, - NRUnregistered (Just testPricing), - NRUnregistered (Just testPricing {premiumFrom = Just 1788480000}) + [ NRRegistered {expires = Just (RoundedSystemTime 1813853483), graceUntil = Just (RoundedSystemTime 1821629483), reservedReason_ = Nothing, nameRecord = testNameRecord}, + NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Just NRRInternal, nameRecord = testNameRecord}, + NRAvailable {pricing = testPricing, auctionUntil = Nothing}, + NRAvailable {pricing = testPricing, auctionUntil = Just (RoundedSystemTime 1790294400)}, + NRReserved NRRInternal, + NRReserved NRRTrademark, + NRReserved NRRCommunity, + NRReserved (NRRUnknown "seasonal") ] - 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\"" + -- one vocabulary: the same word on the wire, from the resolver, and in JSON + it "a reason reads the same in JSON as on the wire" $ do + J.encode (NRRUnknown "seasonal") `shouldBe` "\"seasonal\"" + J.encode NRRTrademark `shouldBe` "\"trademark\"" where jsonBody = LB.fromStrict . B.pack -- the resolver returns the record and the registration status in one body @@ -210,57 +194,43 @@ availabilitySpec = do withResolverServer (resolveResp st body) $ \port _ -> do env <- newNamesEnv (testNamesConfig port) resolveName env navlDomain `shouldReturn` expected - navlDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + navlDomain = nameQuery namesSMPVersion 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. +-- | The .testing oracle: US cents per year by label length. testPricing :: NamePricing testPricing = NamePricing - { rentPrices = map MicroUSD [0, 0, 127930000, 31980000, 999300], - minLabelLength = 3, - premiumFrom = Nothing, - startPremium = MicroUSD 100000000000000, - endPremium = MicroUSD 47683716 + { rentPrices = M.fromList [(3, USDCents 12793), (4, USDCents 3198)], + basePrice = USDCents 100, + minLabelLength = 3 } pricingJson :: String -pricingJson = - "\"rentPrices\":[0,0,127930000,31980000,999300],\"minLabelLength\":3,\ - \\"startPremium\":100000000000000,\"endPremium\":47683716" +pricingJson = "\"rentPrices\":{\"3\":12793,\"4\":3198},\"basePrice\":100,\"minLabelLength\":3" parseNameSpec :: Spec parseNameSpec = do - -- asking by hash tells the client if a name is taken without naming it - it "accepts a labelhash label" $ - parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight - it "refuses a hash of the wrong width" $ - parseN ("[" <> T.replicate 63 "b" <> "].simplex") `shouldSatisfy` isLeft - -- only the bracketed form is a key; a bare hex string would be hashed again - it "refuses a bare hex string" $ - parseN ("0x" <> T.replicate 64 "b" <> ".simplex") `shouldSatisfy` isLeft - it "keeps the brackets" $ - (strEncode <$> parseN ("[" <> T.replicate 64 "b" <> "].simplex")) - `shouldBe` Right (encodeUtf8 ("[" <> T.replicate 64 "b" <> "].simplex")) - -- only the 2LD is a registry key; subname labels are needed as text - it "accepts a hashed 2LD under a subname" $ - parseN ("x.[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isRight - it "refuses a hashed subname label" $ - parseN ("[" <> T.replicate 64 "b" <> "].alice.simplex") `shouldSatisfy` isLeft - it "refuses a labelhash under a web TLD" $ - parseN ("[" <> T.replicate 64 "b" <> "].com") `shouldSatisfy` isLeft + -- a name is a name: the hashed form is a query, and has its own type + it "a name is never a hash" $ + parseN ("[" <> T.replicate 64 "b" <> "].simplex") `shouldSatisfy` isLeft -- keccak-256("alice"), the same constant the resolver's own tests use it "hashes the 2LD to the registry key" $ - (fullDomainName . hashedDomain <$> parseN "alice.simplex") + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "alice.simplex") `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" it "leaves subname labels as text" $ - (fullDomainName . hashedDomain <$> parseN "x.alice.simplex") + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "x.alice.simplex") `shouldBe` Right "x.[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" - it "leaves a web name alone" $ - (fullDomainName . hashedDomain <$> parseN "example.com") `shouldBe` Right "example.com" - it "does not hash a hash" $ - (fullDomainName . hashedDomain . hashedDomain <$> parseN "alice.simplex") - `shouldBe` Right "[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501].simplex" + it "leaves a web name alone: no registry, nothing to key on" $ + (queryName . nameQuery currentClientSMPRelayVersion <$> parseN "example.com") `shouldBe` Right "example.com" + -- below v22 a router can only read the name + it "sends the name itself below v22" $ + (queryName . nameQuery namesSMPVersion <$> parseN "alice.simplex") `shouldBe` Right "alice.simplex" + it "a query survives the wire" $ + mapM_ + (\q -> smpDecode (smpEncode q) `shouldBe` Right q) + [ nameQuery currentClientSMPRelayVersion d, + nameQuery namesSMPVersion d + ] it "accepts a valid simplex-TLD name" $ case parseN "privacy.simplex" of Right d -> do @@ -296,13 +266,14 @@ parseNameSpec = do where parseN :: T.Text -> Either String SimplexDomain parseN = strDecode . encodeUtf8 + d = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = ["x"]} resolverSpec :: Spec 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 (Nothing, Nothing, Just testNameRecord) + resolveName env aliceDomain `shouldReturn` Right (NRRegistered Nothing Nothing Nothing testNameRecord) it "returns NOT_FOUND on 404" $ withResolverServer (resolveResp status404 "{}") $ \port _ -> do @@ -358,7 +329,7 @@ resolverSpec = do readIORef reqs `shouldReturn` [["resolve", "alice.simplex"]] where - aliceDomain = SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} + aliceDomain = nameQuery namesSMPVersion SimplexDomain {nameTLD = TLDSimplex, domain = "alice", subDomain = []} healthSpec :: Spec healthSpec = do