mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-02 04:08:01 +00:00
* implement resolving 2LD names by labelhash * tiny test addition * simplify language * report expiry and availability correctly * compare block instead of wall clock * simplify language * simplify language * map resolver 410 to NAME NOT_FOUND (a lapsed name is an answer, not a failure) * split error into a machine-readable code and a human message * resolver: reduce comments * resolver: reduce comments, remove REVIEW.html * extend SMP protocol to support name availability queries with accurate and meaningful replies * review fixes * more review fixes * docs shortening and other review fixes * add catch all for furture variants * adversarial review (against simplex-chat) fix * next iteration fixes * adapt house style * eth_call guard and cache constants * revert NAVL command and wrap it all into RSLV * doc fixes * Update src/Simplex/Messaging/Server/Names.hs Co-authored-by: Evgeny <evgeny@poberezkin.com> * Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny <evgeny@poberezkin.com> * Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny <evgeny@poberezkin.com> * Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny <evgeny@poberezkin.com> * Update src/Simplex/Messaging/Protocol.hs Co-authored-by: Evgeny <evgeny@poberezkin.com> * protocol types refactoring * next iteration on types only * rentPrices map * claude answering ep review. to be continued... * separate labelhash and plaintext names cleanly * align implementation with latest type changes to test against client * fix adversarial review findings * trim diff * align resolver with contract changes for names v2 * fix reverse compatibility with .testing mainnet * fix more robustly * NameQuery simplification * revert drive-by refactoring * implement full type change and introduce resolver endpoint versioning * fix stale docs * derive NameRegistration JSON the same way on every build sumTypeJSON switches to the _owsf form on swift builds, but this JSON is the RNAME payload and the resolver's HTTP contract, so a swift client and a Linux relay would disagree on every field. taggedObjectJSON is what it already resolves to everywhere else. The label modifier keeps the reservedReason_ collision escape out of the API, as AgentWorkersDetails does: both arms now say reservedReason. * fix resolver boundary: status before body, cover /v2, drop dead field httpGet read the response body before checking the status, so an oversized error page surfaced as a transient "response too large" instead of the authoritative status. Reverting it to its previous shape restores that and removes the status test both callers had been re-deriving. registration() had no tests at all, though it is the endpoint SMP v22 consumes. RegistrationV2Tests covers the three answer shapes, the error paths and the exact key set of each, which is the wire contract. auctionUntil was always None with no consumer. The spec claimed a reason word travels unchanged; a resolver can only send a word it has, and SNRC's registry records a number. * fix review findings * resolver errors say what went wrong, not "no such name" /v2/resolve answers 200, 400 or 502, and an unregistered name is NRAvailable, so no status means "not registered". Mapping 400/404/410 to NOT_FOUND made a misconfigured relay deny every name, and hid a relay upgraded ahead of its resolver. All three now surface as RESOLVER. rslvNotFound would have gone dead, so it counts what its name says: an availability answer the encoder downgrades for a session below v22. The wire is unchanged. A hashed query the registrar cannot name is refused with 502 rather than answered with a record named "unknown", which the client rejects anyway. NRRUnknown is capped to 32 printable characters again, as the spec says. A registered name that is also reserved no longer offers a date it will never free up on. rentPrices is registrationPrices throughout, and yearPriceUSD is USDCents rather than a bare Int64. * fix regressions found reviewing the last two commits resolveNameMsg read the version off thParams', which on the PFWD path is the proxy's session, not the client's. It takes the version as an argument now, so each call site passes its own — the forwarded one uses fwdVersion. reservedReasonOf matched the reason words before capping, so "internal review" became NRRUnknown "internal", which encodes back as NRRInternal. Capping precedes the match, so what is kept encodes to what it decoded. Four agent tests still pinned NAME NOT_FOUND from a 404 stub, and two spec statements still described the old mapping. A registrar that does not record labels cannot answer a hashed query, which the resolver README now says. * docs sweep * simplify encoding * drop resolver caching (#1866) * rename * remove trailing_ filter * fix resolver v2 for subnames (#1867) * fix resolver v2 for subnames * simplify doc * resolver should report response truth freshness (#1868) * first shot at reporting freshness * fix review findings * renaming --------- Co-authored-by: sh <github.shum@liber.li> Co-authored-by: Evgeny <evgeny@poberezkin.com>
1040 lines
43 KiB
Python
1040 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for snrc-resolve helpers.
|
|
|
|
Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`.
|
|
"""
|
|
|
|
import contextlib
|
|
import importlib.util
|
|
import io
|
|
import os
|
|
import time
|
|
import unittest
|
|
|
|
# snrc-resolve.py has a hyphen, so import it via importlib instead of `import`.
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_SPEC = importlib.util.spec_from_file_location(
|
|
"snrc_resolve", os.path.join(_HERE, "snrc-resolve.py")
|
|
)
|
|
snrc = importlib.util.module_from_spec(_SPEC)
|
|
_SPEC.loader.exec_module(snrc)
|
|
|
|
|
|
def registration(name):
|
|
"""registration() answers a NameResponse; most tests assert what is in it."""
|
|
status, body = snrc.registration(name)
|
|
return status, (body["registration"] if status == 200 else body)
|
|
|
|
|
|
class SplitLinksTests(unittest.TestCase):
|
|
"""`split_links` decodes the multi-URL convention for simplex.contact /
|
|
simplex.channel text records. Reuses the same rule the dApp's
|
|
`parseSimplexUrls` uses (separator `;`), so the two sides round-trip
|
|
cleanly."""
|
|
|
|
def test_empty_string_yields_empty_list(self):
|
|
self.assertEqual(snrc.split_links(""), [])
|
|
|
|
def test_whitespace_only_yields_empty_list(self):
|
|
self.assertEqual(snrc.split_links(" "), [])
|
|
self.assertEqual(snrc.split_links(" ; ; "), [])
|
|
|
|
def test_single_url_yields_singleton_list(self):
|
|
self.assertEqual(
|
|
snrc.split_links("https://smp16.simplex.im/a#H1"),
|
|
["https://smp16.simplex.im/a#H1"],
|
|
)
|
|
|
|
def test_two_urls_split_on_separator(self):
|
|
self.assertEqual(
|
|
snrc.split_links(
|
|
"https://smp16.simplex.im/a#H1;https://smp19.simplex.im/a#H1"
|
|
),
|
|
[
|
|
"https://smp16.simplex.im/a#H1",
|
|
"https://smp19.simplex.im/a#H1",
|
|
],
|
|
)
|
|
|
|
def test_whitespace_around_separators_is_trimmed(self):
|
|
self.assertEqual(
|
|
snrc.split_links(
|
|
" https://smp16.simplex.im/a#H1 ;\thttps://smp19.simplex.im/a#H1 "
|
|
),
|
|
[
|
|
"https://smp16.simplex.im/a#H1",
|
|
"https://smp19.simplex.im/a#H1",
|
|
],
|
|
)
|
|
|
|
def test_trailing_separator_does_not_produce_empty_entry(self):
|
|
self.assertEqual(
|
|
snrc.split_links("https://smp16.simplex.im/a#H1;"),
|
|
["https://smp16.simplex.im/a#H1"],
|
|
)
|
|
|
|
def test_doubled_separator_does_not_produce_empty_entry(self):
|
|
self.assertEqual(
|
|
snrc.split_links(
|
|
"https://smp16.simplex.im/a#H1;;https://smp19.simplex.im/a#H1"
|
|
),
|
|
[
|
|
"https://smp16.simplex.im/a#H1",
|
|
"https://smp19.simplex.im/a#H1",
|
|
],
|
|
)
|
|
|
|
def test_order_is_preserved(self):
|
|
self.assertEqual(
|
|
snrc.split_links("c;a;b"),
|
|
["c", "a", "b"],
|
|
)
|
|
|
|
|
|
class EncodedLabelhashTests(unittest.TestCase):
|
|
# keccak-256("alice"), written out in full wherever a test needs it.
|
|
# 9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501
|
|
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
GRACE = 90 * 86400
|
|
|
|
def setUp(self):
|
|
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now)
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": ""}
|
|
snrc.chain_now = lambda: int(time.time())
|
|
|
|
def tearDown(self):
|
|
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved
|
|
|
|
def test_the_encoded_form_is_recognised(self):
|
|
self.assertTrue(
|
|
snrc.is_encoded_labelhash(
|
|
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
|
|
)
|
|
)
|
|
|
|
def test_an_ordinary_label_is_not(self):
|
|
self.assertFalse(snrc.is_encoded_labelhash("alice"))
|
|
self.assertFalse(snrc.is_encoded_labelhash("[alice]"))
|
|
self.assertFalse(snrc.is_encoded_labelhash("9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501"))
|
|
|
|
def test_non_hex_between_the_brackets_is_not(self):
|
|
self.assertFalse(snrc.is_encoded_labelhash("[" + "z" * 64 + "]"))
|
|
# uppercase is rejected because the handler lowercases the whole name
|
|
self.assertFalse(snrc.is_encoded_labelhash("[" + "A" * 64 + "]"))
|
|
self.assertFalse(snrc.is_encoded_labelhash("[0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"))
|
|
|
|
def test_the_wrong_length_is_not(self):
|
|
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 63 + "]"))
|
|
self.assertFalse(snrc.is_encoded_labelhash("[" + "a" * 65 + "]"))
|
|
|
|
def test_hash_and_label_reach_the_same_node(self):
|
|
self.assertEqual(
|
|
snrc.node_of("alice.testing"),
|
|
snrc.node_of(
|
|
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
|
|
".testing"
|
|
),
|
|
)
|
|
|
|
def test_a_plain_name_is_unaffected(self):
|
|
self.assertEqual(snrc.node_of("alice.testing"), snrc.namehash("alice.testing"))
|
|
|
|
def test_a_bracket_subname_label_stays_literal(self):
|
|
"""Only the 2LD is a key, so a bracket label left of it is hashed as
|
|
written."""
|
|
self.assertNotEqual(
|
|
snrc.node_of(
|
|
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
|
|
".alice.testing"
|
|
),
|
|
snrc.namehash("alice.alice.testing"),
|
|
)
|
|
|
|
def test_a_0x_prefixed_label_is_taken_literally(self):
|
|
name = "0x9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501.testing"
|
|
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
|
|
self.assertNotEqual(snrc.node_of(name), snrc.node_of("alice.testing"))
|
|
|
|
def test_a_malformed_bracket_label_falls_back_to_a_literal_name(self):
|
|
name = "[nothex].testing"
|
|
self.assertEqual(snrc.node_of(name), snrc.namehash(name))
|
|
|
|
def test_status_by_hash_matches_status_by_name(self):
|
|
future = int(time.time()) + 86400
|
|
seen = []
|
|
|
|
def eth_call(to, data):
|
|
seen.append(data)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(self.GRACE)
|
|
return "0x" + snrc.encode_uint(future)
|
|
|
|
snrc.eth_call = eth_call
|
|
by_name = snrc.name_status("alice.testing")
|
|
by_hash = snrc.name_status(
|
|
"[9c0257114eb9399a2985f8e75dad7600c5d89fe3824ffa99ec1c3eb8bf3b0501]"
|
|
".testing"
|
|
)
|
|
self.assertEqual(by_name, by_hash)
|
|
self.assertEqual(by_name["status"], "registered")
|
|
# nothing in either request carried the label itself
|
|
self.assertTrue(all("alice".encode().hex() not in d for d in seen))
|
|
|
|
|
|
class NameStatusTests(unittest.TestCase):
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
|
|
GRACE = 90 * 86400
|
|
|
|
def _expiry(self, value):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(self.GRACE)
|
|
self.assertTrue(data.startswith(snrc.selector("nameExpires(uint256)")))
|
|
return "0x" + snrc.encode_uint(value)
|
|
|
|
return eth_call
|
|
|
|
def _keys(self, status, expires, grace_ends, read_at=-1):
|
|
"""Every branch answers with the same keys; only some carry values."""
|
|
return {
|
|
"status": status,
|
|
"lastBlockTs": self.now if read_at == -1 else read_at,
|
|
"expires": expires,
|
|
"graceEnds": grace_ends,
|
|
"reasonCode": None,
|
|
"reason": None,
|
|
}
|
|
|
|
def setUp(self):
|
|
self._saved = (
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
snrc.rpc,
|
|
)
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
# Expiry alone; ReservedTests covers a configured controller.
|
|
snrc.CONTROLLERS = {"testing": ""}
|
|
self.now = int(time.time())
|
|
snrc.chain_now = lambda: self.now
|
|
|
|
def tearDown(self):
|
|
(
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
snrc.rpc,
|
|
) = self._saved
|
|
|
|
def test_now_is_the_latest_blocks_timestamp(self):
|
|
# setUp replaced chain_now with the fixture clock; test the real one
|
|
real_chain_now = self._saved[3]
|
|
snrc.rpc = lambda method, params: {"timestamp": "0x65f1a2c0", "number": "0x123"}
|
|
self.assertEqual(real_chain_now(), 0x65F1A2C0)
|
|
|
|
def test_status_reads_the_chain_clock_not_the_host_clock(self):
|
|
future = int(time.time()) + 3600
|
|
snrc.eth_call = self._expiry(future)
|
|
self.assertEqual(snrc.name_status("alice.testing")["status"], "registered")
|
|
snrc.chain_now = lambda: future + 3650 * 86400
|
|
self.assertEqual(snrc.name_status("alice.testing")["status"], "expired")
|
|
|
|
def test_a_registrar_that_is_not_a_contract_is_an_error_not_a_free_name(self):
|
|
"""An address with no code answers eth_call with empty data. Read as
|
|
zero, that would make every name look free."""
|
|
snrc.eth_call = self._saved[2] # the real one, so its guard runs
|
|
snrc.rpc = lambda method, params: "0x"
|
|
with self.assertRaises(RuntimeError):
|
|
snrc.name_status("alice.testing")
|
|
|
|
def test_zero_expiry_means_never_registered(self):
|
|
snrc.eth_call = self._expiry(0)
|
|
self.assertEqual(
|
|
snrc.name_status("alice.testing"),
|
|
self._keys("unregistered", None, None),
|
|
)
|
|
|
|
def test_recently_expired_is_in_grace_and_says_when_it_ends(self):
|
|
past = int(time.time()) - 3600
|
|
snrc.eth_call = self._expiry(past)
|
|
self.assertEqual(
|
|
snrc.name_status("alice.testing"),
|
|
self._keys("grace", past, past + self.GRACE),
|
|
)
|
|
|
|
def test_past_the_grace_window_it_is_expired_and_claimable(self):
|
|
past = int(time.time()) - self.GRACE - 3600
|
|
snrc.eth_call = self._expiry(past)
|
|
self.assertEqual(snrc.name_status("alice.testing")["status"], "expired")
|
|
|
|
def test_the_boundary_belongs_to_grace(self):
|
|
"""The registrar frees a name only when expires + GRACE < now."""
|
|
now = int(time.time())
|
|
snrc.eth_call = self._expiry(now - self.GRACE)
|
|
self.assertEqual(snrc.name_status("alice.testing")["status"], "grace")
|
|
|
|
def test_future_expiry_is_registered(self):
|
|
future = int(time.time()) + 3600
|
|
snrc.eth_call = self._expiry(future)
|
|
self.assertEqual(
|
|
snrc.name_status("alice.testing"),
|
|
self._keys("registered", future, future + self.GRACE),
|
|
)
|
|
|
|
def test_never_registered_is_not_confused_with_claimable(self):
|
|
"""`available(id)` is true for both, since 0 + GRACE < now."""
|
|
snrc.eth_call = self._expiry(0)
|
|
self.assertEqual(snrc.name_status("alice.testing")["status"], "unregistered")
|
|
self.assertNotEqual(snrc.name_status("alice.testing")["status"], "expired")
|
|
|
|
def test_a_subname_reports_the_status_of_its_2ld(self):
|
|
future = int(time.time()) + 3600
|
|
seen = []
|
|
|
|
def eth_call(to, data):
|
|
seen.append(data)
|
|
return "0x" + snrc.encode_uint(future)
|
|
|
|
snrc.eth_call = eth_call
|
|
self.assertEqual(snrc.name_status("x.alice.testing")["status"], "registered")
|
|
# the token asked about is keccak("alice"), not keccak("x")
|
|
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
|
|
|
|
def test_a_hashed_2ld_is_queried_by_its_hash_at_any_depth(self):
|
|
"""The token must come from the hash, not from hashing the brackets."""
|
|
seen = []
|
|
|
|
def eth_call(to, data):
|
|
seen.append(data)
|
|
return "0x" + snrc.encode_uint(0)
|
|
|
|
snrc.eth_call = eth_call
|
|
hashed = "[" + snrc.keccak(b"alice").hex() + "]"
|
|
snrc.name_status("x." + hashed + ".testing")
|
|
self.assertTrue(seen[0].endswith(snrc.keccak(b"alice").hex()))
|
|
|
|
def test_unconfigured_tld_is_unknown_rather_than_unregistered(self):
|
|
snrc.REGISTRARS = {"testing": ""}
|
|
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
|
|
self.assertEqual(
|
|
snrc.name_status("alice.testing"),
|
|
self._keys("unknown", None, None, read_at=None),
|
|
)
|
|
|
|
def test_every_branch_returns_the_same_keys(self):
|
|
keys = {
|
|
"status",
|
|
"lastBlockTs",
|
|
"expires",
|
|
"graceEnds",
|
|
"reasonCode",
|
|
"reason",
|
|
}
|
|
snrc.eth_call = self._expiry(0)
|
|
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
|
|
snrc.eth_call = self._expiry(int(time.time()) + 3600)
|
|
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
|
|
snrc.REGISTRARS = {"testing": ""}
|
|
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
|
|
self.assertEqual(set(snrc.name_status("alice.testing")), keys)
|
|
|
|
|
|
class ReservedTests(unittest.TestCase):
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
|
|
|
|
def setUp(self):
|
|
self._saved = (snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now)
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
|
|
snrc.chain_now = lambda: int(time.time())
|
|
|
|
def tearDown(self):
|
|
snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now = self._saved
|
|
|
|
def _chain(self, expires, reserved):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
self.assertEqual(to, self.CONTROLLER)
|
|
return "0x" + snrc.encode_uint(1 if reserved else 0)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(90 * 86400)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
return "0x" + snrc.encode_uint(0) # no price oracle, no auction
|
|
return "0x" + snrc.encode_uint(expires)
|
|
|
|
return eth_call
|
|
|
|
def test_unregistered_and_reserved_reports_the_reservation(self):
|
|
snrc.eth_call = self._chain(0, True)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["status"], "unregistered")
|
|
self.assertEqual(reg["reasonCode"], "internal")
|
|
|
|
def test_unregistered_and_not_reserved_reads_unregistered(self):
|
|
snrc.eth_call = self._chain(0, False)
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
|
|
|
|
def test_a_lapsed_reserved_name_keeps_its_reservation(self):
|
|
past = int(time.time()) - 91 * 86400
|
|
snrc.eth_call = self._chain(past, True)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["status"], "expired")
|
|
self.assertEqual(reg["reasonCode"], "internal")
|
|
|
|
def test_a_live_name_is_registered_even_if_reserved(self):
|
|
snrc.eth_call = self._chain(int(time.time()) + 86400, True)
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "registered")
|
|
|
|
def test_a_name_in_grace_belongs_to_its_owner_not_the_reserved_set(self):
|
|
snrc.eth_call = self._chain(int(time.time()) - 3600, True)
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
|
|
|
|
def test_no_controller_configured_means_reserved_is_never_reported(self):
|
|
snrc.CONTROLLERS = {"testing": ""}
|
|
snrc.eth_call = self._chain(0, True) # reserved on chain, but unread
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "unregistered")
|
|
|
|
def test_reserved_is_asked_by_labelhash_so_a_hashed_query_works(self):
|
|
# keccak-256("acme")
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
snrc.eth_call = self._chain(0, True)
|
|
self.assertEqual(snrc.name_status(hashed + ".testing")["reasonCode"], "internal")
|
|
|
|
|
|
class ReservedReasonTests(unittest.TestCase):
|
|
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
|
|
|
|
def setUp(self):
|
|
self._saved = (
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
)
|
|
snrc.REGISTRIES = {"testing": self.REGISTRY}
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
|
|
snrc.chain_now = lambda: int(time.time())
|
|
|
|
def tearDown(self):
|
|
(
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
) = self._saved
|
|
|
|
def _chain(self, expires, reserved):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
return "0x" + snrc.encode_uint(1 if reserved else 0)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(90 * 86400)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
return "0x" + snrc.encode_uint(0) # no price oracle, no auction
|
|
return "0x" + snrc.encode_uint(expires)
|
|
|
|
return eth_call
|
|
|
|
def _reserved_as(self, code):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
return "0x" + snrc.encode_uint(code)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(90 * 86400)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
return "0x" + snrc.encode_uint(0)
|
|
return "0x" + snrc.encode_uint(0)
|
|
|
|
return eth_call
|
|
|
|
def test_every_enum_value_has_a_code_and_a_sentence(self):
|
|
for code, (name, sentence) in snrc.RESERVED_REASONS.items():
|
|
snrc.eth_call = self._reserved_as(code)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["reasonCode"], name)
|
|
self.assertEqual(reg["reason"], sentence)
|
|
|
|
def test_a_trademark_reservation_says_so(self):
|
|
snrc.eth_call = self._reserved_as(2)
|
|
_, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(body["reasonCode"], "trademark")
|
|
|
|
def test_a_controller_storing_a_bool_reads_as_internal(self):
|
|
"""Before the enum `reservedNames` was a bool; its `true` decodes as 1."""
|
|
snrc.eth_call = self._reserved_as(1)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["reasonCode"], "internal")
|
|
self.assertEqual(reg["reason"], "reserved for SimpleX")
|
|
|
|
def test_an_enum_value_this_resolver_predates_is_not_dropped(self):
|
|
"""A new Reason still reserves the name, and says it is unknown rather
|
|
than claiming the chain recorded none."""
|
|
snrc.eth_call = self._reserved_as(99)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["reasonCode"], "unknown")
|
|
self.assertEqual(reg["reason"], "reserved")
|
|
|
|
def test_a_reserved_name_carries_the_reason(self):
|
|
snrc.eth_call = self._chain(0, True)
|
|
status, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(status, 404)
|
|
self.assertEqual(body["status"], "unregistered")
|
|
self.assertEqual(body["reason"], "reserved for SimpleX")
|
|
|
|
def test_the_message_does_not_claim_a_trademark(self):
|
|
snrc.eth_call = self._chain(0, True)
|
|
_, body = snrc.resolve("acme.testing")
|
|
self.assertNotIn("trademark", body["message"])
|
|
|
|
def test_an_unregistered_name_has_no_reason(self):
|
|
snrc.eth_call = self._chain(0, False)
|
|
status, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(status, 404)
|
|
self.assertEqual(body["status"], "unregistered")
|
|
self.assertIsNone(body["reason"])
|
|
|
|
def test_an_expired_name_has_no_reason(self):
|
|
snrc.eth_call = self._chain(1, False)
|
|
status, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(status, 410)
|
|
self.assertEqual(body["status"], "expired")
|
|
self.assertIsNone(body["reason"])
|
|
|
|
def test_a_hashed_query_gets_the_reason_too(self):
|
|
snrc.eth_call = self._chain(0, True)
|
|
# keccak-256("acme")
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
_, body = snrc.resolve(hashed + ".testing")
|
|
self.assertEqual(body["reason"], "reserved for SimpleX")
|
|
|
|
|
|
class PricingTests(unittest.TestCase):
|
|
"""The oracle keeps the curve in US cents per year, and a lapsed name costs
|
|
the ordinary price: this registry runs no auction."""
|
|
|
|
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
|
|
ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50"
|
|
|
|
GRACE = 90 * 86400
|
|
BASE = 200
|
|
EXCEPTIONS = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500}
|
|
MIN_LENGTH = 3
|
|
|
|
def setUp(self):
|
|
self._saved = (
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
)
|
|
snrc.REGISTRIES = {"testing": self.REGISTRY}
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
|
|
self.now = int(time.time())
|
|
snrc.chain_now = lambda: self.now
|
|
|
|
def tearDown(self):
|
|
(
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
) = self._saved
|
|
|
|
def _prices_return(self):
|
|
words = [snrc.encode_uint(self.BASE), snrc.encode_uint(0x40),
|
|
snrc.encode_uint(len(self.EXCEPTIONS))]
|
|
for length, cents in self.EXCEPTIONS.items():
|
|
words += [snrc.encode_uint(length), snrc.encode_uint(cents)]
|
|
return "0x" + "".join(words)
|
|
|
|
def _chain(self, expires, oracle=None, reserved=0):
|
|
oracle = self.ORACLE if oracle is None else oracle
|
|
self.oracle_calls = []
|
|
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("nameExpires(uint256)")):
|
|
return "0x" + snrc.encode_uint(expires)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(self.GRACE)
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
return "0x" + snrc.encode_uint(reserved)
|
|
if data.startswith(snrc.selector("minCharLength()")):
|
|
self.assertEqual(to, self.CONTROLLER)
|
|
return "0x" + snrc.encode_uint(self.MIN_LENGTH)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
if to == self.CONTROLLER:
|
|
return "0x" + snrc.encode_uint(int(oracle, 16))
|
|
self.oracle_calls.append(data[:10])
|
|
self.assertEqual(to, oracle)
|
|
return self._prices_return()
|
|
return self.fail("unexpected call " + data[:10])
|
|
|
|
return eth_call
|
|
|
|
def _lapsed(self, days_past_grace):
|
|
"""An expiry whose grace ended `days_past_grace` days ago. The extra
|
|
second clears the boundary, which counts as still in grace."""
|
|
return self.now - self.GRACE - 1 - days_past_grace * 86400
|
|
|
|
def test_the_prices_are_the_oracles_cents_per_year(self):
|
|
snrc.eth_call = self._chain(self._lapsed(0))
|
|
reg = snrc.name_status("acme.testing")
|
|
# 1 and 2 are below minCharLength
|
|
self.assertEqual(reg["registrationPrices"], {3: 1600, 4: 800, 5: 500})
|
|
self.assertEqual(reg["basePrice"], self.BASE)
|
|
self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH)
|
|
|
|
def test_a_lapsed_name_costs_the_ordinary_price(self):
|
|
snrc.eth_call = self._chain(self._lapsed(0))
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["status"], "expired")
|
|
|
|
def test_a_controller_with_no_oracle_leaves_the_name_merely_expired(self):
|
|
snrc.eth_call = self._chain(self._lapsed(0), oracle=snrc.ZERO_ADDR)
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "expired")
|
|
|
|
def test_a_name_in_grace_never_reaches_the_oracle(self):
|
|
snrc.eth_call = self._chain(self.now - 3600)
|
|
self.assertEqual(snrc.name_status("acme.testing")["status"], "grace")
|
|
self.assertEqual(self.oracle_calls, [])
|
|
|
|
def test_a_reserved_lapsed_name_keeps_its_reservation(self):
|
|
snrc.eth_call = self._chain(self._lapsed(0), reserved=2)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["status"], "expired")
|
|
self.assertEqual(reg["reasonCode"], "trademark")
|
|
|
|
def test_resolve_reports_the_prices(self):
|
|
snrc.eth_call = self._chain(self._lapsed(1))
|
|
status, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(status, 410)
|
|
self.assertEqual(body["status"], "expired")
|
|
self.assertEqual(body["basePrice"], self.BASE)
|
|
|
|
def test_a_hashed_query_is_priced_too(self):
|
|
# keccak-256("acme")
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
snrc.eth_call = self._chain(self._lapsed(0))
|
|
_, body = snrc.resolve(hashed + ".testing")
|
|
self.assertEqual(body["status"], "expired")
|
|
self.assertEqual(body["basePrice"], self.BASE)
|
|
|
|
|
|
class EnsOracleTests(unittest.TestCase):
|
|
""".testing runs an ENS-shaped oracle: it prices in attoUSD per second and
|
|
charges a premium on lapsed names that it does not expose."""
|
|
|
|
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
|
|
ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50"
|
|
GRACE = 90 * 86400
|
|
MIN_LENGTH = 6
|
|
|
|
def setUp(self):
|
|
self._saved = (snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now)
|
|
snrc.REGISTRIES = {"testing": self.REGISTRY}
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
|
|
self.now = int(time.time())
|
|
snrc.chain_now = lambda: self.now
|
|
|
|
def tearDown(self):
|
|
(snrc.REGISTRIES, snrc.REGISTRARS, snrc.CONTROLLERS, snrc.eth_call, snrc.chain_now) = self._saved
|
|
|
|
def _chain(self, expires, letter_cents=0):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("nameExpires(uint256)")):
|
|
return "0x" + snrc.encode_uint(expires)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(self.GRACE)
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
return "0x" + snrc.encode_uint(0)
|
|
if data.startswith(snrc.selector("minCharLength()")):
|
|
return "0x" + snrc.encode_uint(self.MIN_LENGTH)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
if to == self.CONTROLLER:
|
|
return "0x" + snrc.encode_uint(int(self.ORACLE, 16))
|
|
raise RuntimeError("eth_call returned 0x") # no prices() on this oracle
|
|
for n in range(1, 7):
|
|
if data.startswith(snrc.selector(f"price{n}Letter()")):
|
|
rate = letter_cents * snrc.ATTO_PER_CENT // snrc.SECONDS_PER_YEAR
|
|
return "0x" + snrc.encode_uint(rate)
|
|
return self.fail("unexpected call " + data[:10])
|
|
|
|
return eth_call
|
|
|
|
def test_a_never_registered_name_is_priced_from_the_letter_curve(self):
|
|
snrc.eth_call = self._chain(0)
|
|
reg = snrc.name_status("ghost.testing")
|
|
self.assertEqual(reg["status"], "unregistered")
|
|
self.assertEqual(reg["basePrice"], 0)
|
|
self.assertEqual(reg["minLabelLength"], self.MIN_LENGTH)
|
|
|
|
def test_a_non_zero_letter_curve_converts_to_cents_per_year(self):
|
|
snrc.eth_call = self._chain(0, letter_cents=1200)
|
|
self.assertEqual(snrc.name_status("ghost.testing")["basePrice"], 1200)
|
|
|
|
def test_a_lapsed_name_is_not_priced_because_the_premium_is_unreadable(self):
|
|
snrc.eth_call = self._chain(self.now - self.GRACE - 1)
|
|
reg = snrc.name_status("acme.testing")
|
|
self.assertEqual(reg["status"], "expired")
|
|
self.assertNotIn("basePrice", reg)
|
|
|
|
|
|
class ErrorCodeTests(unittest.TestCase):
|
|
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
|
|
def setUp(self):
|
|
self._saved = (
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
)
|
|
snrc.REGISTRIES = {"testing": self.REGISTRY, "simplex": ""}
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": ""}
|
|
snrc.chain_now = lambda: int(time.time())
|
|
|
|
def tearDown(self):
|
|
(
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
) = self._saved
|
|
|
|
def _chain(self, expires, resolver=None):
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(90 * 86400)
|
|
if data.startswith(snrc.selector("resolver(bytes32)")):
|
|
return "0x" + "00" * 12 + (resolver or "00" * 20)
|
|
return "0x" + snrc.encode_uint(expires)
|
|
|
|
return eth_call
|
|
|
|
def test_an_unconfigured_tld_names_the_ones_that_are(self):
|
|
status, body = snrc.resolve("alice.nosuchtld")
|
|
self.assertEqual(status, 400)
|
|
self.assertEqual(body["error"], "tldNotConfigured")
|
|
self.assertEqual(body["configuredTlds"], ["testing"])
|
|
self.assertIn("nosuchtld", body["message"])
|
|
|
|
def test_a_registration_problem_reports_the_status_as_the_code(self):
|
|
for expires, code in (
|
|
(0, "unregistered"),
|
|
(int(time.time()) - 91 * 86400, "expired"),
|
|
):
|
|
with self.subTest(code=code):
|
|
snrc.eth_call = self._chain(expires)
|
|
_, body = snrc.resolve("alice.testing")
|
|
self.assertEqual(body["error"], code)
|
|
self.assertEqual(body["status"], code)
|
|
|
|
def test_a_name_in_grace_still_resolves(self):
|
|
snrc.eth_call = self._chain(int(time.time()) - 3600)
|
|
status, body = snrc.resolve("alice.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["status"], "grace")
|
|
self.assertNotIn("error", body)
|
|
|
|
def test_a_registered_name_pointing_nowhere_resolves_with_empty_records(self):
|
|
snrc.eth_call = self._chain(int(time.time()) + 86400)
|
|
status, body = snrc.resolve("alice.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["status"], "registered")
|
|
self.assertEqual(body["resolver"], snrc.ZERO_ADDR)
|
|
self.assertEqual(body["simplexContact"], [])
|
|
|
|
def test_every_error_body_carries_both_fields(self):
|
|
snrc.eth_call = self._chain(0)
|
|
for name in ("alice.nosuchtld", "alice.testing"):
|
|
with self.subTest(name=name):
|
|
_, body = snrc.resolve(name)
|
|
self.assertIsInstance(body["error"], str)
|
|
self.assertIsInstance(body["message"], str)
|
|
self.assertNotEqual(body["error"], body["message"])
|
|
|
|
def test_an_upstream_failure_does_not_echo_the_exception(self):
|
|
with contextlib.redirect_stderr(io.StringIO()) as log:
|
|
body = snrc.upstream_error(
|
|
{"name": "alice.testing"},
|
|
RuntimeError("http://user:secret@rpc.example/kEy8 refused"),
|
|
)
|
|
# the operator still sees the detail in the log
|
|
self.assertIn("secret", log.getvalue())
|
|
self.assertEqual(body["error"], "upstreamError")
|
|
self.assertIn("RuntimeError", body["message"])
|
|
self.assertNotIn("secret", body["message"])
|
|
self.assertNotIn("kEy8", body["message"])
|
|
|
|
|
|
class RegistrationV2Tests(unittest.TestCase):
|
|
"""`/v2/resolve` answers with the SMP protocol's NameRegistration, which the
|
|
relay decodes as is. The key names are the wire contract, so they are pinned
|
|
here: renaming one without the Haskell side is a silent break."""
|
|
|
|
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
|
|
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
|
|
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
|
|
ORACLE = "0x1e0c9a2b9d1a4c8f7b3e5d6a9c2f4b8e1d7a3c50"
|
|
OWNER = "0xd83bd7e0e6b8a4c1f2593a7b0c4e8d1a6f9b2c37"
|
|
|
|
GRACE = 90 * 86400
|
|
BASE = 200
|
|
EXCEPTIONS = {1: 64000, 2: 16000, 3: 1600, 4: 800, 5: 500}
|
|
MIN_LENGTH = 3
|
|
|
|
def setUp(self):
|
|
self._saved = (
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
)
|
|
snrc.REGISTRIES = {"testing": self.REGISTRY}
|
|
snrc.REGISTRARS = {"testing": self.REGISTRAR}
|
|
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
|
|
self.now = int(time.time())
|
|
snrc.chain_now = lambda: self.now
|
|
|
|
def tearDown(self):
|
|
(
|
|
snrc.REGISTRIES,
|
|
snrc.REGISTRARS,
|
|
snrc.CONTROLLERS,
|
|
snrc.eth_call,
|
|
snrc.chain_now,
|
|
) = self._saved
|
|
|
|
def _prices_return(self):
|
|
words = [snrc.encode_uint(self.BASE), snrc.encode_uint(0x40),
|
|
snrc.encode_uint(len(self.EXCEPTIONS))]
|
|
for length, cents in self.EXCEPTIONS.items():
|
|
words += [snrc.encode_uint(length), snrc.encode_uint(cents)]
|
|
return "0x" + "".join(words)
|
|
|
|
@staticmethod
|
|
def _abi_bytes(value: bytes) -> str:
|
|
"""head offset, length, then the payload padded to a 32-byte word."""
|
|
pad = (-len(value)) % 32
|
|
return ("0x" + snrc.encode_uint(0x20) + snrc.encode_uint(len(value))
|
|
+ (value + b"\x00" * pad).hex())
|
|
|
|
def _chain(self, expires, reserved=0, oracle=None, label=b"acme", owner=None):
|
|
"""The registry answers a zero resolver, so name_record returns the
|
|
empty record a registered name still has. `label` is what the registrar
|
|
recorded for the 2LD; b"" means it recorded none. `owner` is the owner of
|
|
the queried node; ZERO_ADDR means that node was never created."""
|
|
oracle = self.ORACLE if oracle is None else oracle
|
|
owner = self.OWNER if owner is None else owner
|
|
|
|
def eth_call(to, data):
|
|
if data.startswith(snrc.selector("labelOf(uint256)")):
|
|
return self._abi_bytes(label)
|
|
if data.startswith(snrc.selector("nameExpires(uint256)")):
|
|
return "0x" + snrc.encode_uint(expires)
|
|
if data.startswith(snrc.selector("GRACE_PERIOD()")):
|
|
return "0x" + snrc.encode_uint(self.GRACE)
|
|
if data.startswith(snrc.selector("reservedNames(bytes32)")):
|
|
return "0x" + snrc.encode_uint(reserved)
|
|
if data.startswith(snrc.selector("minCharLength()")):
|
|
return "0x" + snrc.encode_uint(self.MIN_LENGTH)
|
|
if data.startswith(snrc.selector("prices()")):
|
|
if to == self.CONTROLLER:
|
|
return "0x" + snrc.encode_uint(int(oracle, 16))
|
|
return self._prices_return()
|
|
if data.startswith(snrc.selector("resolver(bytes32)")):
|
|
return "0x" + snrc.encode_uint(0)
|
|
if data.startswith(snrc.selector("owner(bytes32)")):
|
|
return "0x" + snrc.encode_uint(int(owner, 16))
|
|
return self.fail("unexpected call " + data[:10])
|
|
|
|
return eth_call
|
|
|
|
def _lapsed(self, days_past_grace):
|
|
return self.now - self.GRACE - 1 - days_past_grace * 86400
|
|
|
|
def test_a_live_name_is_registered_and_carries_its_record(self):
|
|
expires = self.now + 3600
|
|
snrc.eth_call = self._chain(expires)
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["type"], "registered")
|
|
self.assertEqual(body["expires"], expires)
|
|
self.assertEqual(body["graceUntil"], expires + self.GRACE)
|
|
self.assertIsNone(body["reservedReason_"])
|
|
self.assertEqual(body["nameRecord"]["name"], "acme.testing")
|
|
|
|
def test_a_name_in_grace_is_still_registered(self):
|
|
expires = self.now - 3600
|
|
snrc.eth_call = self._chain(expires)
|
|
_, body = registration("acme.testing")
|
|
self.assertEqual(body["type"], "registered")
|
|
self.assertGreater(body["graceUntil"], self.now)
|
|
|
|
def test_a_registered_name_that_is_held_back_says_so(self):
|
|
snrc.eth_call = self._chain(self.now + 3600, reserved=1)
|
|
_, body = registration("acme.testing")
|
|
self.assertEqual(body["type"], "registered")
|
|
self.assertEqual(body["reservedReason_"], "internal")
|
|
|
|
def test_an_unregistered_name_is_available_with_its_pricing(self):
|
|
snrc.eth_call = self._chain(0)
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["type"], "available")
|
|
# lengths below minCharLength are unregistrable, so they are not priced
|
|
self.assertEqual(body["pricing"]["registrationPrices"], {3: 1600, 4: 800, 5: 500})
|
|
self.assertEqual(body["pricing"]["basePrice"], self.BASE)
|
|
self.assertEqual(body["pricing"]["minLabelLength"], self.MIN_LENGTH)
|
|
|
|
def test_a_lapsed_name_is_available_at_the_ordinary_price(self):
|
|
snrc.eth_call = self._chain(self._lapsed(1))
|
|
_, body = registration("acme.testing")
|
|
self.assertEqual(body["type"], "available")
|
|
self.assertEqual(body["pricing"]["basePrice"], self.BASE)
|
|
|
|
def test_a_held_back_name_is_reserved_and_is_never_priced(self):
|
|
snrc.eth_call = self._chain(0, reserved=2)
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["type"], "reserved")
|
|
self.assertEqual(body["reservedReason"], "trademark")
|
|
self.assertNotIn("pricing", body)
|
|
|
|
def test_a_hashed_query_answers_the_same_as_the_name(self):
|
|
# keccak-256("acme")
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
snrc.eth_call = self._chain(0)
|
|
_, by_name = registration("acme.testing")
|
|
_, by_hash = registration(hashed + ".testing")
|
|
self.assertEqual(by_name, by_hash)
|
|
|
|
def test_an_unconfigured_tld_is_refused_not_answered(self):
|
|
snrc.REGISTRIES = {"testing": ""}
|
|
snrc.eth_call = lambda *a: self.fail("must not reach the chain")
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 400)
|
|
self.assertEqual(body["error"], "tldNotConfigured")
|
|
|
|
def test_no_price_oracle_is_an_error_not_a_free_name(self):
|
|
snrc.eth_call = self._chain(0, oracle=snrc.ZERO_ADDR)
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 502)
|
|
self.assertEqual(body["error"], "noPriceOracle")
|
|
|
|
def test_a_status_it_cannot_read_is_an_error_not_a_registration(self):
|
|
snrc.REGISTRARS = {"testing": ""}
|
|
snrc.eth_call = self._chain(0)
|
|
status, body = registration("acme.testing")
|
|
self.assertEqual(status, 502)
|
|
self.assertEqual(body["error"], "unknown")
|
|
|
|
def test_each_answer_carries_exactly_its_own_fields(self):
|
|
"""The relay decodes by these names; an extra or missing one is a break."""
|
|
cases = {
|
|
"registered": (self._chain(self.now + 3600),
|
|
{"type", "expires", "graceUntil", "reservedReason_", "nameRecord"}),
|
|
"available": (self._chain(0), {"type", "pricing"}),
|
|
"reserved": (self._chain(0, reserved=1), {"type", "reservedReason"}),
|
|
}
|
|
for expected_type, (chain, keys) in cases.items():
|
|
with self.subTest(type=expected_type):
|
|
snrc.eth_call = chain
|
|
_, body = registration("acme.testing")
|
|
self.assertEqual(body["type"], expected_type)
|
|
self.assertEqual(set(body), keys)
|
|
def test_a_hashed_query_the_registrar_cannot_name_is_refused(self):
|
|
"""The client checks the record names what it asked about, so answering
|
|
with a record the registrar could not name would only fail there."""
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
snrc.eth_call = self._chain(self.now + 3600, label=b"")
|
|
status, body = registration(hashed + ".testing")
|
|
self.assertEqual(status, 502)
|
|
self.assertEqual(body["error"], "labelNotRecorded")
|
|
|
|
def test_a_hashed_query_is_answered_with_the_name_the_registrar_recorded(self):
|
|
hashed = "[e29dae06ef4c3e336b7538b6d4f52ca1ecec009b1df6fb501320e11b223aeeaf]"
|
|
snrc.eth_call = self._chain(self.now + 3600)
|
|
status, body = registration(hashed + ".testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["nameRecord"]["name"], "acme.testing")
|
|
def test_a_subname_that_exists_is_registered_with_its_parents_dates(self):
|
|
expires = self.now + 3600
|
|
snrc.eth_call = self._chain(expires)
|
|
status, body = registration("sub.acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["type"], "registered")
|
|
self.assertEqual(body["expires"], expires)
|
|
self.assertEqual(body["nameRecord"]["name"], "sub.acme.testing")
|
|
|
|
def test_a_subname_nobody_created_is_not_registered(self):
|
|
"""The registrar only tracks 2LDs, so the parent's registration says
|
|
nothing about a child that was never created: its node has no owner."""
|
|
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
|
|
status, body = registration("sub.acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["type"], "available")
|
|
|
|
def test_a_2ld_is_not_subject_to_the_owner_check(self):
|
|
"""Only a subname can be absent under a registered parent."""
|
|
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
|
|
_, body = registration("acme.testing")
|
|
self.assertEqual(body["type"], "registered")
|
|
|
|
def test_v1_does_not_report_an_uncreated_subname_as_registered(self):
|
|
"""v1 has no availability, so the only honest answer is not-found. The
|
|
2LD case is untouched: a registered name with no resolver still resolves."""
|
|
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
|
|
status, body = snrc.resolve("sub.acme.testing")
|
|
self.assertEqual(status, 404)
|
|
self.assertEqual(body["error"], "unregistered")
|
|
|
|
def test_v1_still_resolves_a_2ld_with_no_resolver_set(self):
|
|
snrc.eth_call = self._chain(self.now + 3600, owner=snrc.ZERO_ADDR)
|
|
status, body = snrc.resolve("acme.testing")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["resolver"], snrc.ZERO_ADDR)
|
|
|
|
def test_the_answer_says_which_block_it_was_read_at(self):
|
|
"""The resolver is only as current as its node. Without this a client
|
|
cannot tell an answer that predates its own registration."""
|
|
snrc.eth_call = self._chain(self.now + 3600)
|
|
_, res = snrc.registration("acme.testing")
|
|
self.assertEqual(res["lastBlockTs"], self.now)
|
|
self.assertEqual(res["registration"]["type"], "registered")
|
|
|
|
def test_an_available_name_says_so_too(self):
|
|
"""This is the path that reads no block otherwise, and the one where
|
|
staleness matters most: the name may already be taken."""
|
|
snrc.eth_call = self._chain(0)
|
|
_, res = snrc.registration("acme.testing")
|
|
self.assertEqual(res["lastBlockTs"], self.now)
|
|
self.assertEqual(res["registration"]["type"], "available")
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|