From d3a5f09d1ee5ba7997bcf58d948f9b39cb082079 Mon Sep 17 00:00:00 2001 From: jim Date: Fri, 8 May 2020 18:19:20 -0400 Subject: [PATCH] Initial implementation of GeoENS resolver - EIP 2390 GeoENS brings geographic split horizon capabilities to ENS. See more at EIP 2390 https://github.com/ethereum/EIPs/pull/2390 --- contracts/OwnedResolver.sol | 4 +- contracts/PublicResolver.sol | 3 +- contracts/profiles/GeoENSResolver.sol | 162 ++++++++++++++++++++++++++ test/TestPublicResolver.js | 70 +++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 contracts/profiles/GeoENSResolver.sol diff --git a/contracts/OwnedResolver.sol b/contracts/OwnedResolver.sol index 890f7b0..d21feff 100644 --- a/contracts/OwnedResolver.sol +++ b/contracts/OwnedResolver.sol @@ -4,6 +4,8 @@ import "openzeppelin-solidity/contracts/ownership/Ownable.sol"; import "./profiles/ABIResolver.sol"; import "./profiles/AddrResolver.sol"; import "./profiles/ContentHashResolver.sol"; +import "./profiles/DNSResolver.sol"; +import "./profiles/GeoENSResolver.sol"; import "./profiles/InterfaceResolver.sol"; import "./profiles/NameResolver.sol"; import "./profiles/PubkeyResolver.sol"; @@ -13,7 +15,7 @@ import "./profiles/TextResolver.sol"; * A simple resolver anyone can use; only allows the owner of a node to set its * address. */ -contract OwnedResolver is Ownable, ABIResolver, AddrResolver, ContentHashResolver, InterfaceResolver, NameResolver, PubkeyResolver, TextResolver { +contract OwnedResolver is Ownable, ABIResolver, AddrResolver, ContentHashResolver, DNSResolver, GeoENSResolver, InterfaceResolver, NameResolver, PubkeyResolver, TextResolver { function isAuthorised(bytes32 node) internal view returns(bool) { return msg.sender == owner(); } diff --git a/contracts/PublicResolver.sol b/contracts/PublicResolver.sol index 03b883f..8c3b939 100644 --- a/contracts/PublicResolver.sol +++ b/contracts/PublicResolver.sol @@ -6,6 +6,7 @@ import "./profiles/ABIResolver.sol"; import "./profiles/AddrResolver.sol"; import "./profiles/ContentHashResolver.sol"; import "./profiles/DNSResolver.sol"; +import "./profiles/GeoENSResolver.sol"; import "./profiles/InterfaceResolver.sol"; import "./profiles/NameResolver.sol"; import "./profiles/PubkeyResolver.sol"; @@ -15,7 +16,7 @@ import "./profiles/TextResolver.sol"; * A simple resolver anyone can use; only allows the owner of a node to set its * address. */ -contract PublicResolver is ABIResolver, AddrResolver, ContentHashResolver, DNSResolver, InterfaceResolver, NameResolver, PubkeyResolver, TextResolver { +contract PublicResolver is ABIResolver, AddrResolver, ContentHashResolver, DNSResolver, GeoENSResolver, InterfaceResolver, NameResolver, PubkeyResolver, TextResolver { ENS ens; /** diff --git a/contracts/profiles/GeoENSResolver.sol b/contracts/profiles/GeoENSResolver.sol new file mode 100644 index 0000000..2a764cf --- /dev/null +++ b/contracts/profiles/GeoENSResolver.sol @@ -0,0 +1,162 @@ +pragma solidity ^0.5.0; + +import "../ResolverBase.sol"; + +contract GeoENSResolver is ResolverBase { + bytes4 constant ERC2390 = 0xa263115e; + uint constant MAX_ADDR_RETURNS = 64; + uint constant TREE_VISITATION_QUEUESZ = 64; + uint8 constant ASCII_0 = 48; + uint8 constant ASCII_9 = 57; + uint8 constant ASCII_a = 97; + uint8 constant ASCII_b = 98; + uint8 constant ASCII_i = 105; + uint8 constant ASCII_l = 108; + uint8 constant ASCII_o = 111; + uint8 constant ASCII_z = 122; + + struct Node { + address data; // 0 if not leaf + uint256 parent; + uint256[] children; // always length 32 + } + + // A geohash is 8, base-32 characters. + // A geomap is stored as tree of fan-out 32 (because + // geohash is base 32) and height 8 (because geohash + // length is 8 characters) + mapping(bytes32=>Node[]) private geomap; + + event GeoENSRecordChanged(bytes32 indexed node, string geohash, address addr); + + // only 5 bits of ret value are used + function chartobase32(byte c) pure internal returns (uint8 b) { + uint8 ascii = uint8(c); + require( (ascii >= ASCII_0 && ascii <= ASCII_9) || + (ascii > ASCII_a && ascii <= ASCII_z)); + require(ascii != ASCII_a); + require(ascii != ASCII_i); + require(ascii != ASCII_l); + require(ascii != ASCII_o); + + if (ascii <= (ASCII_0 + 9)) { + b = ascii - ASCII_0; + + } else { + // base32 b = 10 + // ascii 'b' = 0x60 + // note base32 skips the letter 'a' + b = ascii - ASCII_b + 10; + + // base32 also skips the following letters + if (ascii > ASCII_i) + b --; + if (ascii > ASCII_l) + b --; + if (ascii > ASCII_o) + b --; + } + require(b < 32); // base 32 cant be larger than 32 + return b; + } + + function geoAddr(bytes32 node, string calldata geohash) external view returns (address[] memory ret) { + bytes32(node); // single node georesolver ignores node + require(bytes(geohash).length < 9); // 8 characters = +-1.9 meter resolution + + ret = new address[](MAX_ADDR_RETURNS); + if (geomap[node].length == 0) { return ret; } + uint ret_i = 0; + + // walk into the geomap data structure + uint pointer = 0; // not actual pointer but index into geomap + for(uint i=0; i < bytes(geohash).length; i++) { + + uint8 c = chartobase32(bytes(geohash)[i]); + uint next = geomap[node][pointer].children[c]; + if (next == 0) { + // nothing found for this geohash. + // return early. + return ret; + } else { + pointer = next; + } + } + + // pointer is now node representing the resolution of the query geohash. + // DFS until all addresses found or ret[] is full. + // Do not use recursion because this is a blockchain... + uint[] memory indexes_to_visit = new uint[](TREE_VISITATION_QUEUESZ); + indexes_to_visit[0] = pointer; + uint front_i = 0; + uint back_i = 1; + + while(front_i != back_i) { + Node memory cur_node = geomap[node][indexes_to_visit[front_i]]; + front_i ++; + + // if not a leaf node... + if (cur_node.data == address(0)) { + // visit all the chilin's + for(uint i=0; i MAX_ADDR_RETURNS) break; + } + } + + return ret; + } + + // when setting, geohash must be precise to 8 digits. + function setGeoAddr(bytes32 node, string calldata geohash, address addr) external authorised(node) { + bytes32(node); // single node georesolver ignores node + require(bytes(geohash).length == 8); // 8 characters = +-1.9 meter resolution + + // create root node if not yet created + if (geomap[node].length == 0) { + geomap[node].push( Node({ + data: address(0), + parent: 0, + children: new uint256[](32) + })); + } + + // walk into the geomap data structure + uint pointer = 0; // not actual pointer but index into geomap + for(uint i=0; i < bytes(geohash).length; i++) { + + uint8 c = chartobase32(bytes(geohash)[i]); + + if (geomap[node][pointer].children[c] == 0) { + // nothing found for this geohash. + // we need to create a path to the leaf + geomap[node].push( Node({ + data: address(0), + parent: pointer, + children: new uint256[](32) + })); + geomap[node][pointer].children[c] = geomap[node].length - 1; + } + pointer = geomap[node][pointer].children[c]; + } + + Node storage cur_node = geomap[node][pointer]; // storage = get reference + cur_node.data = addr; + + emit GeoENSRecordChanged(node, geohash, addr); + } + + function supportsInterface(bytes4 interfaceID) public pure returns (bool) { + return interfaceID == ERC2390 || super.supportsInterface(interfaceID); + } +} diff --git a/test/TestPublicResolver.js b/test/TestPublicResolver.js index dd05784..94c683a 100644 --- a/test/TestPublicResolver.js +++ b/test/TestPublicResolver.js @@ -665,6 +665,76 @@ contract('PublicResolver', function (accounts) { assert.equal(web3.eth.abi.decodeParameters(['string'], results[1])[0], "https://ethereum.org/"); }); }); + + describe('geoens', async () => { + var geo1 = 'ezs42bcd'; + var geo2 = 'ezs42bdd'; + let differentNode = namehash.hash('yeth'); + + it("should directly resolve a simple geohash query", async () => { + await resolver.setGeoAddr(node, geo1, accounts[1], {from: accounts[0]}); + + a = await resolver.geoAddr(node, geo1); + assert.equal(a[0], accounts[1], "Did not correctly resolve address on direct query"); + assert.equal(a[1], 0, "Did not correctly resolve address on direct query"); + }); + + + it("should not resolve a non-existant geohash query", async () => { + await resolver.setGeoAddr(node, geo1, accounts[1], {from: accounts[0]}); + + a = await resolver.geoAddr(node, geo2); + assert.equal(a[0], 0, "Resolved a geohash which was never set in the contract"); + + a = await resolver.geoAddr(differentNode, geo1); + assert.equal(a[0], 0, "Resolved a domain which was never set in the contract"); + }); + + + it("should resolve only one geohash on direct query", async () => { + await resolver.setGeoAddr(node, geo1, accounts[1], {from: accounts[0]}); + await resolver.setGeoAddr(node, geo2, accounts[2], {from: accounts[0]}); + await ens.setSubnodeOwner('0x0', sha3('yeth'), accounts[0], {from: accounts[0]}); + await resolver.setGeoAddr(differentNode, geo2, accounts[3], {from: accounts[0]}); + + a = await resolver.geoAddr(node, geo1); + assert.equal(a[0], accounts[1], "Did not correctly resolve address on direct query"); + assert.equal(a[1], 0, "Did not correctly resolve address on direct query"); + + a = await resolver.geoAddr(node, geo2); + assert.equal(a[0], accounts[2], "Did not correctly resolve address on direct query"); + assert.equal(a[1], 0, "Did not correctly resolve address on direct query"); + }); + + + it("should resolve only one geohash on indirect query", async () => { + await resolver.setGeoAddr(node, geo1, accounts[1], {from: accounts[0]}); + await resolver.setGeoAddr(node, geo2, accounts[2], {from: accounts[0]}); + await ens.setSubnodeOwner('0x0', sha3('yeth'), accounts[0], {from: accounts[0]}); + await resolver.setGeoAddr(differentNode, geo2, accounts[3], {from: accounts[0]}); + + a = await resolver.geoAddr(node, 'ezs42bc'); + assert.equal(a[0], accounts[1], "Did not correctly resolve address on indirect query"); + assert.equal(a[1], 0, "Returned geohash that doesn't match query"); + + a = await resolver.geoAddr(node, 'ezs42bd'); + assert.equal(a[0], accounts[2], "Did not correctly resolve address on indirect query"); + assert.equal(a[1], 0, "Returned geohash that doesn't match query"); + }); + + + it("should resolve multiple geohashes on range query for a specific node", async () => { + await resolver.setGeoAddr(node, geo1, accounts[1], {from: accounts[0]}); + await resolver.setGeoAddr(node, geo2, accounts[2], {from: accounts[0]}); + await ens.setSubnodeOwner('0x0', sha3('yeth'), accounts[0], {from: accounts[0]}); + await resolver.setGeoAddr(differentNode, geo2, accounts[3], {from: accounts[0]}); + + a = await resolver.geoAddr(node, 'ezs42b'); + assert.equal(a[0], accounts[1], "Did not correctly resolve address on indirect query"); + assert.equal(a[1], accounts[2], "Returned geohash that doesn't match query"); + assert.equal(a[2], 0, "Returned geohash that doesn't match query"); + }); + }); }); function dnsName(name) {