ADR-008: Dual-payment support (ETH + USDC)
- Status: Accepted
- Date: 2026-04-28
- Deciders: Core team
Context
RegistrarController currently accepts only native ETH (msg.value) for registration and renewal. PriceOracle quotes prices in wei via a Chainlink ETH/USD feed.
Three problems with ETH-only payments on Base:
- Price volatility for users. A $3/month registration costs different amounts of ETH week to week. Users who think in dollars must check the ETH price before every transaction.
- Base is a USDC-native ecosystem. Circle's native USDC is widely held on Base. Many Base users hold USDC as their primary spending asset, not ETH.
- UX friction for non-crypto-native users. Target users (emerging markets, newcomers) are more familiar with stable dollar amounts than fluctuating ETH values.
Decision
Add USDC as a parallel payment option alongside ETH. ETH remains the default. Neither removes the other.How it works
register() and renew() accept an optional paymentToken parameter:
address(0)→ ETH path (current behavior, unchanged)USDC_ADDRESS→ USDC path (new)
function register(
RegisterRequest calldata req,
address paymentToken // address(0) = ETH, USDC address = stablecoin
) external payable whenNotPaused {
uint256 total = _totalPrice(req.name, req.tld, req.duration);
if (paymentToken == address(0)) {
// ETH path — existing behavior
if (msg.value < total) revert InsufficientValue(total, msg.value);
if (msg.value > total) _refundExcess(msg.sender, msg.value - total);
} else if (paymentToken == USDC) {
// USDC path — price in 6-decimal USD units
uint256 usdcAmount = _toUsdc(total);
IERC20(USDC).transferFrom(msg.sender, address(this), usdcAmount);
} else {
revert UnsupportedPaymentToken(paymentToken);
}
// ... rest of registration logic unchanged
}Price conversion for USDC
PriceOracle already calculates basePriceUsd in 1e8 units. For USDC (6 decimals):
usdcAmount = basePriceUsd * duration / (YEAR * 1e2)No Chainlink feed needed for USDC — it is already denominated in USD. This eliminates oracle risk for the USDC path entirely.
User flow (USDC)
- User approves USDC spend:
USDC.approve(controller, amount) - User calls
register(req, USDC_ADDRESS)— nomsg.valueneeded - Controller calls
transferFrom→ pulls USDC from user - Name minted, records set — identical to ETH path from here
- User signs an off-chain permit message (no approve tx)
- Web app calls
USDC.permit(...)thenregister(req, USDC_ADDRESS)in one batched call via multicall wrapper
Withdraw
Protocol owner can withdraw both ETH and USDC via separate withdraw() functions:
function withdrawEth(address to) external onlyOwner { ... }
function withdrawToken(address token, address to) external onlyOwner { ... }Rationale
Why USDC specifically
- Native Circle USDC on Base — no bridging, maximum liquidity
- 1:1 USD peg removes all oracle risk on the payment path
- Already the dominant stablecoin for on-chain commerce on Base
- No additional price feed required — price is exact, not approximate
Why keep ETH
- ETH holders should not be forced to acquire USDC to use the protocol
- ETH path requires zero approvals — simpler UX for crypto-native users
- Removing ETH would break any existing integrations and SDK usage
Why not support arbitrary ERC-20 tokens
- Price oracle complexity explodes (need feed for every token)
- Smart contract attack surface grows with each whitelisted token
- USDC covers the stablecoin use case completely
Why not wrap ETH into USDC automatically on-chain
- Requires on-chain DEX integration (Uniswap / Aerodrome)
- Adds swap slippage, MEV risk, and smart contract dependency
- Out of scope for v1 — can be added as a frontend convenience layer later
Consequences
Positive
- Users can pay exact dollar amounts with zero ETH price exposure
- Widens addressable market to USDC holders on Base
- USDC path has no oracle dependency — simpler and safer
- ETH and USDC revenue flows are separate and independently withdrawable
Negative
- USDC path requires one extra
approvetransaction (or EIP-2612 permit complexity) - Contract size and logic surface increases slightly
RegistrarControllermust be redeployed — existing testnet deployments invalidated- SDK
register()andrenew()need apaymentTokenoption added
Mitigations
- Default
paymentTokentoaddress(0)— all existing SDK and script callers are unaffected - Add
permit2support in a follow-up (Uniswap Permit2 on Base is already deployed) - Redeploy is already planned for mainnet — testnet redeploy has low cost

