# ENS源码分析 **Published by:** [hundredwz](https://paragraph.com/@hundredwz/) **Published on:** 2022-02-17 **URL:** https://paragraph.com/@hundredwz/ens ## Content 前言2021年ENS大火,有很多用户用户赚了不菲的空投,甚至部分用户赚了上千万。 但是ENS到底是怎么实现的?技术细节有什么? 目前笔者在中文网站暂未发现从技术角度进行全面讲解的文章,因此尝试从源码的角度来分析下。 注:本文所参考的合约地址为ens-contracts概述ens的整个技术架构大致类似于下图ENS架构如图所示,ens可以从功能上进行以下划分:用户层:注册ens的入口,通过外观模式,对请求进行相关校验,转发到下层进行实际处理核心层:ens的核心功能模块,包括ens的注册表(ens和owner对应关系)、ens注册器(注册一个实际的ens域名)、ens反向注册器(通过address反向获取ens域名)、ens注册价格计算、dns注册关联等功能解析器层:解析ens域名的实际处理层,支持将ens解析为地址、公钥、文本等。注册注册各个模块交互流程图如下:ENS注册为了防止域名抢注情况,ens使用了『请求-提交』二阶段注册模式。 用户在申请域名时,首先根据『待申请域名』和『秘密值(随机数)』生成commitment,然后提交至ENS控制器。 在一分钟以后,将域名的『注册请求』和『秘密值(随机数)』一起提交到控制器,完成ens域名的注册。commitment处理生成commitment是一个计算哈希的过程,核心实现如下代码: // 创建commitment function makeCommitment(string memory name, address owner, bytes32 secret) pure public returns(bytes32) { return makeCommitmentWithConfig(name, owner, secret, address(0), address(0)); } function makeCommitmentWithConfig(string memory name, address owner, bytes32 secret, address resolver, address addr) pure public returns(bytes32) { // 计算ens域名(不带.eth)的哈希 bytes32 label = keccak256(bytes(name)); if (resolver == address(0) && addr == address(0)) { // 将ens域名(不带.eth)哈希值、域名申请者地址、秘密值(随机数)组合计算哈希 return keccak256(abi.encodePacked(label, owner, secret)); } require(resolver != address(0)); return keccak256(abi.encodePacked(label, owner, resolver, addr, secret)); } // commit 过程 function commit(bytes32 commitment) public { // maxCommitmentAge commitment最大有效时间,是24h // 如果之前已经存在过该commitment,需要保证前commitment已超过最大有效期,即24小时 // 如果不存在该commitment,则该要求必定满足 require(commitments[commitment] + maxCommitmentAge < block.timestamp); commitments[commitment] = block.timestamp; } 即生成commitment,是将hash(ens name)、address、secret组合后求哈希,结果中包含了ens name和secret,也不会泄露原始信息。 然后用户执行申请过程,就是将commitment记录到区块链的过程。 验证commitment主要包含两个操作:根据提交的secret判断commitment是否正确commitment提交时间在预期范围内// 根据提交的secret计算commitment bytes32 commitment = makeCommitmentWithConfig(name, owner, secret, resolver, addr); // 消耗commitment,也就是验证commitment逻辑 uint cost = _consumeCommitment(name, duration, commitment); ... function _consumeCommitment(string memory name, uint duration, bytes32 commitment) internal returns (uint256) { // minCommitmentAge commitment最短有效时间,是1min // 如果刚提交完commitment就执行注册,该值会大于当前区块时间,不能注册通过 require(commitments[commitment] + minCommitmentAge <= block.timestamp); // maxCommitmentAge commitment最大有效时间,是24h // 如果该commitment提交时间过早,该值会小于当前区块时间,不能注册通过 require(commitments[commitment] + maxCommitmentAge > block.timestamp); // 保证该域名可用:1. 域名长度大于3;2. 该域名还未被注册或已超出保留时间(90天) require(available(name)); // 验证通过,删除该commitment delete(commitments[commitment]); ... } 价格计算注册ens域名包含不同的价格,目前在StablePriceOracle定义中,不同的域名长度价格不同。 function price(string calldata name, uint expires, uint duration) external view override returns(uint) { // 计算待注册域名成都 uint len = name.strlen(); if(len > rentPrices.length) { len = rentPrices.length; } require(len > 0); // 计算域名注册时长*域名单价 uint basePrice = rentPrices[len - 1].mul(duration); // 域名附加费用_premium价格目前定义为0 basePrice = basePrice.add(_premium(name, expires, duration)); // 将价格转换为eth价格 return attoUSDToWei(basePrice); } function attoUSDToWei(uint amount) internal view returns(uint) { // 通过预言机获取最新的eth/usd价格 uint ethPrice = uint(usdOracle.latestAnswer()); // 计算应当支付多少eth return amount.mul(1e8).div(ethPrice); } 可以发现,价格计算较为简单,获取待注册的域名长度,进而计算一共需要支付多少usd通过预言机合约,获取当前的eth/usd价格汇率计算当前需要支付多少eth注册注册包含两种类型:1. 为ens域名设置解析器;2. 使用默认的解析器 // 全新注册入口 function register(string calldata name, address owner, uint duration, bytes32 secret) external payable { registerWithConfig(name, owner, duration, secret, address(0), address(0)); } // 具体注册逻辑 function registerWithConfig(string memory name, address owner, uint duration, bytes32 secret, address resolver, address addr) public payable { // 校验commitment逻辑 bytes32 commitment = makeCommitmentWithConfig(name, owner, secret, resolver, addr); uint cost = _consumeCommitment(name, duration, commitment); // 计算域名的哈希 bytes32 label = keccak256(bytes(name)); // 计算tokenId,方便铸造nft uint256 tokenId = uint256(label); uint expires; // 如果要设置新的解析器,执行设定解析器的逻辑 if(resolver != address(0)) { // 临时设置域名的拥有者为合约地址,方便后续设置解析器 expires = base.register(tokenId, address(this), duration); // 计算域名哈希(包含.eth) bytes32 nodehash = keccak256(abi.encodePacked(base.baseNode(), label)); // 设置用户的指定解析器 base.ens().setResolver(nodehash, resolver); // 配置解析器,将ens和指定的addr对应 if (addr != address(0)) { Resolver(resolver).setAddr(nodehash, addr); } // 把ens拥有权转移给用户 base.reclaim(tokenId, owner); // nft转移 base.transferFrom(address(this), owner, tokenId); } else { // 不设定解析器,使用默认的解析器 require(addr == address(0)); expires = base.register(tokenId, owner, duration); } // 发起事件通知 emit NameRegistered(name, label, owner, cost, expires); // 钱付多了,返还多的钱 if(msg.value > cost) { payable(msg.sender).transfer(msg.value - cost); } } 默认解析器首先分析下使用默认解析器的逻辑: // 注册逻辑入口 function register(uint256 id, address owner, uint duration) external override returns(uint) { return _register(id, owner, duration, true); } function _register(uint256 id, address owner, uint duration, bool updateRegistry) internal live onlyController returns(uint) { // 需要保证该ens域名可用 require(available(id)); // 防止申请时间过长等导致的数据溢出 require(block.timestamp + duration + GRACE_PERIOD > block.timestamp + GRACE_PERIOD); // Prevent future overflow // 记录该域名的过期时间 // 域名是否可用也是通过expires判断 expiries[id] = block.timestamp + duration; // nft相关逻辑,之前被人拥有,重新铸造 if(_exists(id)) { _burn(id); } _mint(owner, id); // 由于是全新注册域名,需要更新注册表 if(updateRegistry) { ens.setSubnodeOwner(baseNode, bytes32(id), owner); } emit NameRegistered(id, owner, block.timestamp + duration); // 返回域名过期时间 return block.timestamp + duration; } 从以上逻辑可知,注册域名的逻辑如下域名统一使用了expiries map维护,记录域名的过期时间在ens注册表中记录该域名的拥有者信息由于ens兼容ERC721,所以注册一个ens域名,也会铸造一个NFT自定义解析器如果用户指定了解析器,相比于使用默认的解析器,逻辑要稍微复杂一些将域名注册给合约自身,以便于有权限设置解析器合约自身作为owner,设置注册表中的解析器信息用户若指定配置解析器,则在解析器中设定ens<->addr关系设置域名的拥有者为用户指定的owner涉及到NFT的操作,就执行NFT的转移操作可以发现,为了实现自定义解析器,需要临时赋予合约ens拥有权,执行设置解析器的操作,执行完成后,再重新授予用户。 到此位置,注册一个ens的域名就全部执行完成。ENS解析正向解析解析ens时各个模块交互如下:ENS解析可以发现,解析ens域名流程实现了注册表和解析器的解耦,注册表不维护具体内容,具体数据由解析器提供。 首先看下注册表设置和获取的核心逻辑 struct Record { address owner; // 域名拥有者 address resolver; // 域名解析器 uint64 ttl; // 域名解析存活时间 } // ens和记录的映射关系 mapping (bytes32 => Record) records; function setResolver(bytes32 node, address resolver) public virtual override authorised(node) { emit NewResolver(node, resolver); // 将解析器添加到records记录中 records[node].resolver = resolver; } function resolver(bytes32 node) public virtual override view returns (address) { // 根据ens域名,返回解析器地址 return records[node].resolver; } 我们以解析以太坊地址为例,查看下解析逻辑 uint constant private COIN_TYPE_ETH = 60; // 记录ens域名和地址的对应关系 mapping(bytes32=>mapping(uint=>bytes)) _addresses; // 设置地址 function setAddr(bytes32 node, address a) virtual external authorised(node) { setAddr(node, COIN_TYPE_ETH, addressToBytes(a)); } function setAddr(bytes32 node, uint coinType, bytes memory a) virtual public authorised(node) { emit AddressChanged(node, coinType, a); if(coinType == COIN_TYPE_ETH) { emit AddrChanged(node, bytesToAddress(a)); } // 将地址添加到address映射中 _addresses[node][coinType] = a; } // 解析地址 function addr(bytes32 node) virtual override public view returns (address payable) { bytes memory a = addr(node, COIN_TYPE_ETH); if(a.length == 0) { return payable(0); } return bytesToAddress(a); } function addr(bytes32 node, uint coinType) virtual override public view returns(bytes memory) { // 读取address映射关系,获取ens命名对应的地址 return _addresses[node][coinType]; } 反向解析反向注册器的功能是实现从以太坊地址到ens域名的解析。类似正向正向注册器支持『.eth』,反向注册器支持的是『.addr.reverse』。 理解了正向解析后,反向解析就比较容易理解:msg.sender地址求hex在ens注册表中添加namehash(hex(msg.sender).addr.reverse)=>owner的管理关系,即为反向域名设置Record在反向解析器中设置namehash=>address实现的代码逻辑为 // 设置反向域名解析 function setName(string memory name) public returns (bytes32) { // 在ens注册表中添加反向域名的record bytes32 node = _claimWithResolver( msg.sender, address(this), address(defaultResolver) ); // 在反向解析器中添加反向域名到ens域名的映射关系 defaultResolver.setName(node, name); return node; } function _claimWithResolver( address addr, address owner, address resolver ) internal returns (bytes32) { // 求address的hex编码 bytes32 label = sha3HexAddress(addr); // 计算namehash bytes32 node = keccak256(abi.encodePacked(ADDR_REVERSE_NODE, label)); // 获取当前address有没有设定反向解析器,如果已设置,就判断是否需要更新 address currentResolver = ens.resolver(node); bool shouldUpdateResolver = (resolver != address(0x0) && resolver != currentResolver); address newResolver = shouldUpdateResolver ? resolver : currentResolver; // 在ens注册表中添加对应关系 ens.setSubnodeRecord(ADDR_REVERSE_NODE, label, owner, newResolver, 0); emit ReverseClaimed(addr, node); return node; } DNS解析通过前文的ens正向解析和反向解析分析可知,往注册表添加数据的关键是,证明自身对数据的所有权,DNS解析亦如此。正向解析修改注册表是通过有且仅有ENS基础注册器具有『eth』这一baseNode操作权,作为唯一eth域名分配入口,保证分配给用户的域名一定是经过系统控制的反向解析的输入数据是msg.sender,除了调用者自身,其他人都不可能给msg.sender设定反向解析记录那么DNS解析是怎么操作的呢?DNS解析从交互图可以理解DNS解析的交互逻辑需要去域名提供商手动添加一条A记录,其中域名是_ens.{domain}.{suffix},a记录内容是a=address自身获取A记录的proof,方便DNS预言机验证DNS预言机内置了根公钥和支持的部分域名的证明,按照类似于证书验证体系,即验证树的形式完成proof的验证有了证明,就可以向DNS注册器提交DNS绑定了将该DNS记录添加到ENS注册表中相关代码如下: // 提交证明 function submitRRSets(RRSetWithSignature[] memory input, bytes calldata _proof) public override returns (bytes memory) { bytes memory proof = _proof; for(uint i = 0; i < input.length; i++) { proof = _submitRRSet(input[i], proof); } return proof; } // 验证并存储证明 function _submitRRSet(RRSetWithSignature memory input, bytes memory proof) internal returns (bytes memory) { RRUtils.SignedSet memory rrset; // 验证证明 rrset = validateSignedSet(input, proof); RRSet storage storedSet = rrsets[keccak256(rrset.name)][rrset.typeCovered]; if (storedSet.hash != bytes20(0)) { // To replace an existing rrset, the signature must be at least as new require(RRUtils.serialNumberGte(rrset.inception, storedSet.inception)); } // 存储证明 rrsets[keccak256(rrset.name)][rrset.typeCovered] = RRSet({ inception: rrset.inception, expiration: rrset.expiration, hash: bytes20(keccak256(rrset.data)) }); emit RRSetUpdated(rrset.name, rrset.data); return rrset.data; } // 声明域名拥有权 function claim(bytes memory name, bytes memory proof) public override { // 获取要存储到注册表的数据 (bytes32 rootNode, bytes32 labelHash, address addr) = _claim(name, proof); // 添加到ens注册表 ens.setSubnodeOwner(rootNode, labelHash, addr); } 其他1. 注册表权限是如何管理的?注册表实现了一个authorised验证逻辑,只有域名拥有者或授权操作者才能执行相关写操作。 modifier authorised(bytes32 node) { address owner = records[node].owner; require(owner == msg.sender || operators[owner][msg.sender]); _; } 合约在部署时,默认赋予了合约部署者0x0的操作权限。合约部署者后续会调用setSubnodeOwner赋予特定用户操作各个域名的权限,比如eth // 合约部署者,提交node为0x0,label为eth,owner为指定用户,即赋予了特定用户操作eth的权限 function setSubnodeOwner(bytes32 node, bytes32 label, address owner) public virtual override authorised(node) returns(bytes32) { bytes32 subnode = keccak256(abi.encodePacked(node, label)); _setOwner(subnode, owner); emit NewOwner(node, label, owner); return subnode; } 调用记录可查看etherscan交易2. 购买eth域名,默认没有配置解析器和ttl?是的3. ens的域名是怎么存储的?在ens注册表中,所有的域名都会进行namehash计算,然后使用bytes32进行存储。 namehash算法定义是def namehash(name): if name == '': return '\0' * 32 else: label, _, remainder = name.partition('.') return sha3(namehash(remainder) + sha3(label)) 比如有一个域名为mysite.swarm,则计算方式为node = '\0' * 32 node = sha3(node + sha3('swarm')) node = sha3(node + sha3('mysite')) # 计算结果 namehash('') = 0x0000000000000000000000000000000000000000000000000000000000000000 namehash('eth') = 0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae namehash('foo.eth') = 0xde9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f 使用这种形式有以下几种原因:合约不需要处理可读的文本字符串,降低不同编码的影响从一个域名(bob.eth)的namehash可以推导任意子域名(alice.bob.eth)的namehash推导过程无需知道或处理原域名(bob.eth) ## Publication Information - [hundredwz](https://paragraph.com/@hundredwz/): Publication homepage - [All Posts](https://paragraph.com/@hundredwz/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@hundredwz): Subscribe to updates