Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions contracts/src/L1/dns/DNSRegistryResolver.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.13;

import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
import {ERC165Checker} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";

import {CCIPReader} from "@ens/contracts/ccipRead/CCIPReader.sol";
import {HexUtils} from "@ens/contracts/utils/HexUtils.sol";
import {NameCoder} from "@ens/contracts/utils/NameCoder.sol";
import {IRegistryResolver} from "../../common/IRegistryResolver.sol";
import {IFeatureSupporter} from "@ens/contracts/utils/IFeatureSupporter.sol";
import {ResolverFeatures} from "@ens/contracts/resolvers/ResolverFeatures.sol";
import {IExtendedDNSResolver} from "@ens/contracts/resolvers/profiles/IExtendedDNSResolver.sol";

/// @notice Gasless DNSSEC resolver that continues resolution on Namechain (or any remote registry).
///
/// Format: `ENS1 <this> <context>`
///
/// 1. Continue: `context = <parentRegistry> <suffix>`
/// eg. "*.nick.com" + `ENS1 <this> 0x1234 com" &rarr; 0x1234 w/["nick", ...]
///
contract DNSRegistryResolver is
ERC165,
CCIPReader,
IFeatureSupporter,
IExtendedDNSResolver
{
IRegistryResolver public immutable registryResolver;

/// @notice The DNS context was invalid.
/// @dev Error selector: `0x206fb1e7`
error InvalidContext(bytes context);

constructor(IRegistryResolver _registryResolver) CCIPReader(0) {
registryResolver = _registryResolver;
}

/// @inheritdoc ERC165
function supportsInterface(
bytes4 interfaceId
) public view virtual override(ERC165) returns (bool) {
return
type(IExtendedDNSResolver).interfaceId == interfaceId ||
type(IFeatureSupporter).interfaceId == interfaceId ||
super.supportsInterface(interfaceId);
}

/// @inheritdoc IFeatureSupporter
function supportsFeature(bytes4 feature) external view returns (bool) {
return
ResolverFeatures.RESOLVE_MULTICALL == feature &&
ERC165Checker.supportsInterface(
address(registryResolver),
type(IFeatureSupporter).interfaceId
);
}

/// @dev Resolve the records using `registryResolver` starting from `parentRegistry` for `name` before `nodeSuffix`.
function resolve(
bytes calldata name,
bytes calldata data,
bytes calldata context
) external view returns (bytes memory) {
(address parentRegistry, bytes32 nodeSuffix) = _parseContext(context);
ccipRead(
address(registryResolver),
abi.encodeCall(
IRegistryResolver.resolveWithRegistry,
(parentRegistry, nodeSuffix, name, data)
)
);
}

/// @dev Parse context string.
/// @param context The formatted context string.
/// @return parentRegistry The parent registry to start traversal.
/// @return nodeSuffix The suffix to drop from the name before resolving.
function _parseContext(
bytes calldata context
) internal pure returns (address parentRegistry, bytes32 nodeSuffix) {
if (
context.length < 43 ||
context[0] != "0" ||
context[1] != "x" ||
context[42] != " "
) {
revert InvalidContext(context); // expected "<address> <suffix>"
}
bool valid;
(parentRegistry, valid) = HexUtils.hexToAddress(context, 2, 42);
if (!valid) {
revert InvalidContext(context); // invalid address
}
nodeSuffix = NameCoder.namehash(
NameCoder.encode(string(context[43:])),
0
);
}
}
205 changes: 205 additions & 0 deletions contracts/test/dns/DNSRegistryResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import hre from "hardhat";
import { afterAll, describe, expect, it } from "vitest";
import { namehash, stringToHex, zeroAddress } from "viem";
import { BrowserProvider } from "ethers/providers";
import { serve } from "@namestone/ezccip/serve";
import { Gateway } from "../../lib/unruggable-gateways/src/gateway.js";
import { UncheckedRollup } from "../../lib/unruggable-gateways/src/UncheckedRollup.js";
import { shouldSupportInterfaces } from "@ensdomains/hardhat-chai-matchers-viem/behaviour";

import { shouldSupportFeatures } from "../utils/supportsFeatures.js";
import { deployV1Fixture } from "../fixtures/deployV1Fixture.js";
import { deployV2Fixture } from "../fixtures/deployV2Fixture.js";
import {
bundleCalls,
COIN_TYPE_ETH,
type KnownProfile,
makeResolutions,
} from "../utils/resolutions.ts";
import { dnsEncodeName, splitName } from "../utils/utils.js";
import { encodeRRs, makeTXT } from "./rr.js";
import { deployArtifact } from "../fixtures/deployArtifact.js";
import { urgArtifact } from "../fixtures/externalArtifacts.js";
import { expectVar } from "../utils/expectVar.ts";

// sufficient to satisfy: `abi.decode(DNSSEC.RRSetWithSignature[])`
const dnsOracleGateway =
'data:application/json,{"data":"0x0000000000000000000000000000000000000000000000000000000000000000"}';

const network = await hre.network.connect();

async function fixture() {
const mainnetV1 = await deployV1Fixture(network);
const mainnetV2 = await deployV2Fixture(network, true); // CCIP on UR
const namechain = await deployV2Fixture(network);
const mockDNSSEC = await network.viem.deployContract("MockDNSSEC");
const dnsTLDResolverV1 = await network.viem.deployContract(
"OffchainDNSResolver",
[mainnetV1.ensRegistry.address, mockDNSSEC.address, dnsOracleGateway],
);
const oracleGatewayProvider = await network.viem.deployContract(
"GatewayProvider",
[mainnetV2.walletClient.account.address, [dnsOracleGateway]],
);
const dnsTLDResolver = await network.viem.deployContract("DNSTLDResolver", [
mainnetV1.ensRegistry.address,
dnsTLDResolverV1.address,
mainnetV2.rootRegistry.address,
mockDNSSEC.address,
oracleGatewayProvider.address,
mainnetV2.batchGatewayProvider.address,
]);
await mainnetV2.setupName({
name: "com",
resolverAddress: dnsTLDResolver.address,
});
const gateway = new Gateway(
new UncheckedRollup(new BrowserProvider(network.provider)),
);
gateway.disableCache();
const ccip = await serve(gateway, { protocol: "raw", log: false }); // enable to see gateway calls
afterAll(ccip.shutdown);
const GatewayVM = await deployArtifact(mainnetV2.walletClient, {
file: urgArtifact("GatewayVM"),
});
const hooksAddress = await deployArtifact(mainnetV2.walletClient, {
file: urgArtifact("UncheckedVerifierHooks"),
});
const verifierAddress = await deployArtifact(mainnetV2.walletClient, {
file: urgArtifact("UncheckedVerifier"),
args: [[ccip.endpoint], 0, hooksAddress],
libs: { GatewayVM },
});
const ethResolver = await mainnetV2.deployDedicatedResolver();
const ethTLDResolver = await network.viem.deployContract("ETHTLDResolver", [
mainnetV1.ensRegistry.address,
mainnetV1.batchGatewayProvider.address,
zeroAddress, // burnAddressV1
ethResolver.address,
verifierAddress,
namechain.datastore.address,
namechain.ethRegistry.address,
32,
]);
const dnsRegistryResolver = await network.viem.deployContract(
"DNSRegistryResolver",
[ethTLDResolver.address],
);
return {
mainnetV2,
namechain,
mockDNSSEC,
dnsTLDResolver,
dnsRegistryResolver,
ethTLDResolver,
ethResolver,
};
}

describe("DNSRegistryResolver", () => {
shouldSupportInterfaces({
contract: () =>
network.networkHelpers
.loadFixture(fixture)
.then((F) => F.dnsRegistryResolver),
interfaces: ["IERC165", "IExtendedDNSResolver", "IFeatureSupporter"],
});

shouldSupportFeatures({
contract: () =>
network.networkHelpers
.loadFixture(fixture)
.then((F) => F.dnsRegistryResolver),
features: {
RESOLVER: ["RESOLVE_MULTICALL"],
},
});

function testRegistry(name: string, suffixName: string) {
const prefixName = suffixName
? name.slice(0, -(suffixName.length + 1))
: name;

it(`${prefixName}[${suffixName}]`, async () => {
const F = await network.networkHelpers.loadFixture(fixture);
const kp: KnownProfile = {
name,
addresses: [
{
coinType: COIN_TYPE_ETH,
value: "0x8000000000000000000000000000000000000001",
},
],
texts: [{ key: "url", value: "https://ens.domains" }],
};
if (!name.endsWith(suffixName)) throw new Error("expected suffix");
const { dedicatedResolver } = await F.namechain.setupName({
name: prefixName,
});
const parentRegistry = F.namechain.rootRegistry;
await F.mockDNSSEC.write.setResponse([
encodeRRs([
makeTXT(
kp.name,
`ENS1 ${F.dnsRegistryResolver.address} ${parentRegistry.address} ${suffixName}`,
),
]),
]);
const bundle = bundleCalls(makeResolutions(kp));
await dedicatedResolver.write.multicall([
bundle.resolutions.map((x) => x.writeDedicated),
]);
const [answer, resolver] =
await F.mainnetV2.universalResolver.read.resolve([
dnsEncodeName(kp.name),
bundle.call,
]);
expectVar({ resolver }).toEqualAddress(F.dnsTLDResolver.address);
bundle.expect(answer);
const directAnswer = await F.ethTLDResolver.read.resolveWithRegistry([
parentRegistry.address,
namehash(suffixName),
dnsEncodeName(name),
bundle.call,
]);
bundle.expect(directAnswer);
});
}

testRegistry("test.com", "com");
testRegistry("test.com", "");
testRegistry("sub.test.com", "com");
testRegistry("a.b.c.test.com", "test.com");

it(`invalid suffix`, async () => {
const F = await network.networkHelpers.loadFixture(fixture);
await expect(
F.ethTLDResolver.read.resolveWithRegistry([
F.namechain.rootRegistry.address,
namehash("org"),
dnsEncodeName("test.com"),
"0x00000000",
]),
).toBeRevertedWithCustomError("UnreachableName");
});

describe("invalid context", () => {
for (const context of [
"0x", // too short
"com", // not 0x
zeroAddress, // missing trailing space
"0x000000000000000000000000000000000000000g ", // not hex
]) {
it(context, async () => {
const F = await network.networkHelpers.loadFixture(fixture);
await expect(
F.dnsRegistryResolver.read.resolve([
dnsEncodeName("test.com"),
"0x00000000",
stringToHex(context),
]),
).toBeRevertedWithCustomError("InvalidContext");
});
}
});
});