Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
57 changes: 57 additions & 0 deletions .github/workflows/contracts_ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: "Contracts: CI"

permissions:
contents: read

on:
workflow_dispatch:
push:
branches:
- main
paths:
- "contracts/**"
pull_request:
paths:
- "contracts/**"
Comment thread
sevenzing marked this conversation as resolved.

jobs:
discover:
name: "Discover contracts"
runs-on: blacksmith-4vcpu-ubuntu-2204
outputs:
contracts: ${{ steps.find.outputs.contracts }}
steps:
- uses: actions/checkout@v4

- name: Find Foundry projects
id: find
run: |
dirs=$(find contracts -name "foundry.toml" -not -path "*/lib/*" -exec dirname {} \; | sort | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "contracts=$dirs" >> $GITHUB_OUTPUT

ci:
name: "CI (${{ matrix.contract }})"
needs: discover
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
contract: ${{ fromJson(needs.discover.outputs.contracts) }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1

- name: Format
working-directory: ${{ matrix.contract }}
run: forge fmt --check

- name: Build
working-directory: ${{ matrix.contract }}
run: forge build --sizes

- name: Test
working-directory: ${{ matrix.contract }}
run: forge test -vvv
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[submodule "contracts/ens-label-healer/lib/openzeppelin-contracts-upgradeable"]
path = contracts/ens-label-healer/lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
[submodule "contracts/ens-label-healer/lib/forge-std"]
path = contracts/ens-label-healer/lib/forge-std
url = https://github.com/foundry-rs/forge-std
Comment thread
coderabbitai[bot] marked this conversation as resolved.
9 changes: 9 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# ENSNode Contracts

Solidity smart contracts used in the ENSNode ecosystem. Each contract lives in its own subdirectory as a standalone [Foundry](https://book.getfoundry.sh/) project.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Solidity smart contracts used in the ENSNode ecosystem. Each contract lives in its own subdirectory as a standalone [Foundry](https://book.getfoundry.sh/) project.
Solidity smart contracts created as an explicit part of the ENSNode platform. Each contract lives in its own subdirectory as a standalone [Foundry](https://book.getfoundry.sh/) project.
Note that this is not a directory of all ENS contracts. For general directories of ENS contracts see:
1. The [datasources package in the ENSNode monorepo](https://github.com/namehash/ensnode/tree/main/packages/datasources).
2. The [ens-contracts repo](https://github.com/ensdomains/ens-contracts).
3. The [contracts-v2 repo](https://github.com/ensdomains/contracts-v2).


## Contracts

| Directory | Contract | Purpose |
| ------------------- | ---------------- | ------------------------------------------------------------- |
| `ens-label-healer/` | `ENSLabelHealer` | Permissioned on-chain label emitter for unresolved ENS labels |
Comment thread
sevenzing marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| `ens-label-healer/` | `ENSLabelHealer` | Permissioned on-chain label emitter for unresolved ENS labels |
| `ens-name-healer/` | `ENSNameHealer` | Enable healed labels in ENS names to automatically propagate to all decentralized ENSNode instances |

Feedback:

  1. ENSNameHealer is better branding than ENSLabelHealer. As discussed we can name this according to it's ultimately value proposition, rather than as a technical description of how it is implemented. Please apply all the related renamings.
  2. Updated purpose to focus more on the value proposition.

17 changes: 17 additions & 0 deletions contracts/ens-label-healer/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Address that receives DEFAULT_ADMIN_ROLE on the proxy (required).
ADMIN_ADDRESS=

# Address that receives SUBMITTER_ROLE during deployment (optional).
# If omitted, grant it manually after deployment via grantRole.
SUBMITTER_ADDRESS=

# Private key of the deployer account (required for deployment).
# Must hold enough ETH to cover gas on the target network.
DEPLOYER_PRIVATE_KEY=

# RPC URLs for each network.
SEPOLIA_RPC_URL=
Comment thread
sevenzing marked this conversation as resolved.
MAINNET_RPC_URL=
Comment thread
sevenzing marked this conversation as resolved.

# Etherscan key for source verification.
ETHERSCAN_API_KEY=
Comment thread
sevenzing marked this conversation as resolved.
12 changes: 12 additions & 0 deletions contracts/ens-label-healer/.gas-snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ENSLabelHealerFuzzTest:testFuzz_submitBatch_emitsOneEventPerInput(string[8],uint8) (runs: 10000, μ: 42523, ~: 41433)
ENSLabelHealerFuzzTest:testFuzz_submit_emitsForAnyLabel(string) (runs: 10000, μ: 25399, ~: 25224)
ENSLabelHealerInvariantTest:invariant_noLabelHealedWhilePaused() (runs: 500, calls: 50000, reverts: 0)
ENSLabelHealerTest:test_initialize_assignsAdminRole() (gas: 15001)
ENSLabelHealerTest:test_submitBatch_emitsForEveryItem() (gas: 34182)
ENSLabelHealerTest:test_submitBatch_revertsWhenPaused() (gas: 48705)
ENSLabelHealerTest:test_submit_allowsDuplicates() (gas: 31011)
ENSLabelHealerTest:test_submit_allowsEmptyLabel() (gas: 23473)
ENSLabelHealerTest:test_submit_emitsLabelHealed() (gas: 24080)
ENSLabelHealerTest:test_submit_revertsForNonSubmitter() (gas: 18582)
ENSLabelHealerTest:test_submit_revertsWhenPaused() (gas: 48112)
ENSLabelHealerTest:test_submit_succeedsAfterUnpause() (gas: 38140)
8 changes: 8 additions & 0 deletions contracts/ens-label-healer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Foundry build artifacts
/out
/cache

# Deployment broadcast logs (contain tx hashes, gas costs, etc.)
# Commit these deliberately when you want to record a deployment.
/broadcast
.env
167 changes: 167 additions & 0 deletions contracts/ens-label-healer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# ENSLabelHealer

Permissioned on-chain label emitter for unresolved ENS labels.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see related feedback for how we document this contract, introduce its purpose, and document it's role and responsibilities within a larger set of systems.


Some ENS registry contracts emit events containing only a labelhash, without the plaintext label. This applies to both the original ENS Registry and ENS Registry With Fallback flows. `ENSLabelHealer` lets trusted submitters publish labels on-chain via `LabelHealed(string label)`.
Comment thread
sevenzing marked this conversation as resolved.

## Prerequisites

Install [Foundry](https://book.getfoundry.sh/getting-started/installation):

After cloning the repo, pull the submodules:

```bash
git submodule update --init --recursive contracts/
```

## Environment

Copy `.env.example` and fill in the values:

```bash
cp .env.example .env
```

## Development

All commands run from `contracts/ens-label-healer/`.

### Build

```bash
forge build
```

### Test

```bash
# Run all tests (unit + fuzz + invariant)
forge test -vvv

# Run a single test file
forge test --match-path test/ENSLabelHealer.t.sol -vvv

# Run a single test by name
forge test --match-test test_submit_emitsLabelHealed -vvv
```

### Format

```bash
# Check formatting (used in CI)
forge fmt --check

# Auto-fix formatting
forge fmt
```

### Gas snapshot

```bash
forge snapshot
```

Commit the `.gas-snapshot` file to track gas changes across PRs.

## Deployment

The deploy script (`script/Deploy.s.sol`) deploys `ENSLabelHealer` behind a UUPS proxy. It reads `ADMIN_ADDRESS` from the environment.

The grant script (`script/Grant.s.sol`) grants `SUBMITTER_ROLE` on an existing proxy.

### Local devnet (Anvil)

Start a local chain in a separate terminal:

```bash
anvil
```

Deploy using Anvil's pre-funded account 0:

```bash
ADMIN_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
forge script script/Deploy.s.sol \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
```

Grant submitter access:

```bash
PROXY_ADDRESS=<proxy address from deployment> \
SUBMITTER_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
forge script script/Grant.s.sol \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
```

Note the proxy address printed by deployment for manual testing.

#### Manual testing with cast

```bash
export PROXY=<proxy address from above>
export SUBMITTER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

# Submit a label
cast send $PROXY "submit(string)" "vitalik" \
--private-key $SUBMITTER_KEY \
--rpc-url http://localhost:8545

# Read the emitted event
cast logs --rpc-url http://localhost:8545 \
--address $PROXY \
"LabelHealed(string)"
```

### Sepolia testnet

**Current deployment:**

| | |
| -------- | ------------------------------------------------------------------------------------------------------- |
| Proxy | `0x1d7412e45265e935C5b083a8f278183175E8ce3d` |
| Explorer | [sepolia.etherscan.io](https://sepolia.etherscan.io/address/0x1d7412e45265e935C5b083a8f278183175E8ce3d) |
| Sourcify | [repo.sourcify.dev](https://repo.sourcify.dev/11155111/0x1d7412e45265e935C5b083a8f278183175E8ce3d) |


```bash
source .env && forge script script/Deploy.s.sol \
--rpc-url sepolia \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast \
--verify
```

Then grant `SUBMITTER_ROLE`. Requires `PROXY_ADDRESS` and `SUBMITTER_ADDRESS` ENV:

```bash
forge script script/Grant.s.sol \
--rpc-url sepolia \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast
```

### Mainnet

Always do a dry-run first (drop `--broadcast`) to simulate the deployment and review expected transactions:

```bash
source .env && forge script script/Deploy.s.sol \
--rpc-url mainnet \
--private-key $DEPLOYER_PRIVATE_KEY
```

Then broadcast:

```bash
source .env && forge script script/Deploy.s.sol \
--rpc-url mainnet \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY
```
22 changes: 22 additions & 0 deletions contracts/ens-label-healer/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"

[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}" }

[profile.default.fuzz]
runs = 10000

[profile.default.invariant]
runs = 500
depth = 100
3 changes: 3 additions & 0 deletions contracts/ens-label-healer/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
Comment thread
sevenzing marked this conversation as resolved.
Outdated
forge-std/=lib/forge-std/src/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
33 changes: 33 additions & 0 deletions contracts/ens-label-healer/script/Deploy.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Script.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../src/ENSLabelHealer.sol";

/// @notice Deploys ENSLabelHealer behind an ERC-1967 / UUPS proxy.
///
/// Required environment variables:
/// ADMIN_ADDRESS — address to assign DEFAULT_ADMIN_ROLE on the proxy.
///
/// Usage:
/// forge script script/Deploy.s.sol \
/// --rpc-url sepolia \
/// --broadcast \
/// --verify
contract Deploy is Script {
function run() external {
address admin = vm.envAddress("ADMIN_ADDRESS");

vm.startBroadcast();

ENSLabelHealer impl = new ENSLabelHealer();
bytes memory initData = abi.encodeCall(ENSLabelHealer.initialize, (admin));
ENSLabelHealer proxy = ENSLabelHealer(address(new ERC1967Proxy(address(impl), initData)));

vm.stopBroadcast();

console.log("Implementation:", address(impl));
console.log("Proxy: ", address(proxy));
}
}
31 changes: 31 additions & 0 deletions contracts/ens-label-healer/script/Grant.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Script.sol";
import "../src/ENSLabelHealer.sol";
Comment thread
sevenzing marked this conversation as resolved.

/// @notice Grants SUBMITTER_ROLE on an existing ENSLabelHealer proxy.
///
/// Required environment variables:
/// PROXY_ADDRESS — deployed ENSLabelHealer proxy address.
/// SUBMITTER_ADDRESS — address to receive SUBMITTER_ROLE.
///
/// Usage:
/// forge script script/Grant.s.sol \
/// --rpc-url sepolia \
/// --broadcast
contract Grant is Script {
Comment thread
sevenzing marked this conversation as resolved.
function run() external {
Comment thread
sevenzing marked this conversation as resolved.
address proxyAddress = vm.envAddress("PROXY_ADDRESS");
address submitter = vm.envAddress("SUBMITTER_ADDRESS");

ENSLabelHealer proxy = ENSLabelHealer(proxyAddress);

vm.startBroadcast();
proxy.grantRole(proxy.SUBMITTER_ROLE(), submitter);
vm.stopBroadcast();

console.log("Proxy: ", proxyAddress);
console.log("Submitter: ", submitter);
}
}
Loading
Loading