From 5b0b0bf779c22e21e822aa860f4664071bf4b8c6 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 7 May 2026 12:31:48 +0400 Subject: [PATCH] feat: add Base Sepolia OBOL faucet contracts --- contracts/fork-obol/README.md | 42 +++++ contracts/fork-obol/foundry.toml | 2 +- .../fork-obol/src/BaseSepoliaObolFaucet.sol | 89 +++++++++++ .../fork-obol/src/BaseSepoliaObolToken.sol | 145 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 contracts/fork-obol/README.md create mode 100644 contracts/fork-obol/src/BaseSepoliaObolFaucet.sol create mode 100644 contracts/fork-obol/src/BaseSepoliaObolToken.sol diff --git a/contracts/fork-obol/README.md b/contracts/fork-obol/README.md new file mode 100644 index 0000000..d8c9392 --- /dev/null +++ b/contracts/fork-obol/README.md @@ -0,0 +1,42 @@ +# Base Sepolia OBOL test token + faucet + +This Foundry project contains the fork-local OBOL test token used by the x402 +integration tests plus a bounded Base Sepolia faucet variant for public testnet +smokes. + +## Contracts + +- `ForkObolToken.sol` — existing unrestricted fork-local token used by Anvil integration tests. +- `BaseSepoliaObolToken.sol` — minimal ERC-20 + EIP-2612 token with the same OBOL metadata (`name: Obol Network`, `symbol: OBOL`, `decimals: 18`, `version: 1`) and owner-restricted minting. +- `BaseSepoliaObolFaucet.sol` — rate-limited faucet that transfers from its own OBOL balance via `claim()` / `claim(address to)`. + +The faucet intentionally does not mint. Deployers should mint test OBOL to a funded holder, transfer a bounded allocation into the faucet, and top it up as needed. + +The Foundry profile pins `solc_version = "0.8.35"` (latest `solc` on npm when this PR was updated). + +## Example deploy flow + +```bash +# Requires a funded Base Sepolia deployer key in your local shell environment. +# Do not commit or paste private keys. +export BASE_SEPOLIA_RPC_URL="https://..." +export DEPLOYER_PRIVATE_KEY="[REDACTED]" + +forge create src/BaseSepoliaObolToken.sol:BaseSepoliaObolToken \ + --rpc-url "$BASE_SEPOLIA_RPC_URL" \ + --private-key "$DEPLOYER_PRIVATE_KEY" \ + --constructor-args 100000000000000000000000000 + +forge create src/BaseSepoliaObolFaucet.sol:BaseSepoliaObolFaucet \ + --rpc-url "$BASE_SEPOLIA_RPC_URL" \ + --private-key "$DEPLOYER_PRIVATE_KEY" \ + --constructor-args 100000000000000000000 86400 +``` + +After deploying and seeding the faucet, configure the frontend with: + +```bash +NEXT_PUBLIC_BASE_SEPOLIA_OBOL_TOKEN_ADDRESS= +NEXT_PUBLIC_BASE_SEPOLIA_OBOL_FAUCET_ADDRESS= +NEXT_PUBLIC_BASE_SEPOLIA_OBOL_FAUCET_AMOUNT="100 OBOL" +``` diff --git a/contracts/fork-obol/foundry.toml b/contracts/fork-obol/foundry.toml index 1eff867..fa59103 100644 --- a/contracts/fork-obol/foundry.toml +++ b/contracts/fork-obol/foundry.toml @@ -2,4 +2,4 @@ src = "src" out = "out" libs = [] -solc_version = "0.8.24" +solc_version = "0.8.35" diff --git a/contracts/fork-obol/src/BaseSepoliaObolFaucet.sol b/contracts/fork-obol/src/BaseSepoliaObolFaucet.sol new file mode 100644 index 0000000..c339ac4 --- /dev/null +++ b/contracts/fork-obol/src/BaseSepoliaObolFaucet.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IBaseSepoliaObolToken { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); +} + +/// @notice Bounded Base Sepolia OBOL faucet for x402 smoke tests. +/// @dev Seed this contract with BaseSepoliaObolToken. Claims transfer from the +/// faucet balance and are rate-limited per caller, so the faucet cannot +/// emit more tokens than it has been explicitly funded with. +contract BaseSepoliaObolFaucet { + address public immutable token; + address public owner; + uint256 public claimAmount; + uint256 public cooldown; + + mapping(address => uint256) public nextClaimAt; + + event Claimed(address indexed caller, address indexed to, uint256 amount, uint256 nextClaimAt); + event ClaimTermsUpdated(uint256 claimAmount, uint256 cooldown); + event FaucetWithdrawal(address indexed to, uint256 amount); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + modifier onlyOwner() { + require(msg.sender == owner, "Faucet: caller is not owner"); + _; + } + + constructor(address token_, address owner_, uint256 claimAmount_, uint256 cooldown_) { + require(token_ != address(0), "Faucet: token is zero address"); + require(owner_ != address(0), "Faucet: owner is zero address"); + require(claimAmount_ > 0, "Faucet: claim amount is zero"); + + token = token_; + owner = owner_; + claimAmount = claimAmount_; + cooldown = cooldown_; + + emit OwnershipTransferred(address(0), owner_); + emit ClaimTermsUpdated(claimAmount_, cooldown_); + } + + function claim() external { + _claim(msg.sender, msg.sender); + } + + function claim(address to) external { + _claim(msg.sender, to); + } + + function setClaimTerms(uint256 claimAmount_, uint256 cooldown_) external onlyOwner { + require(claimAmount_ > 0, "Faucet: claim amount is zero"); + claimAmount = claimAmount_; + cooldown = cooldown_; + emit ClaimTermsUpdated(claimAmount_, cooldown_); + } + + function withdraw(address to, uint256 amount) external onlyOwner { + require(to != address(0), "Faucet: withdraw to zero address"); + _transferToken(to, amount); + emit FaucetWithdrawal(to, amount); + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "Faucet: new owner is zero address"); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + function _claim(address caller, address to) internal { + require(to != address(0), "Faucet: claim to zero address"); + require(block.timestamp >= nextClaimAt[caller], "Faucet: cooldown active"); + require( + IBaseSepoliaObolToken(token).balanceOf(address(this)) >= claimAmount, + "Faucet: insufficient balance" + ); + + uint256 nextClaim = block.timestamp + cooldown; + nextClaimAt[caller] = nextClaim; + _transferToken(to, claimAmount); + emit Claimed(caller, to, claimAmount, nextClaim); + } + + function _transferToken(address to, uint256 amount) internal { + require(IBaseSepoliaObolToken(token).transfer(to, amount), "Faucet: transfer failed"); + } +} diff --git a/contracts/fork-obol/src/BaseSepoliaObolToken.sol b/contracts/fork-obol/src/BaseSepoliaObolToken.sol new file mode 100644 index 0000000..1e80d93 --- /dev/null +++ b/contracts/fork-obol/src/BaseSepoliaObolToken.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Minimal ERC-20 + EIP-2612 token for Base Sepolia OBOL x402 testing. +/// @dev Mirrors the fork-local ForkObolToken metadata used by integration tests, +/// but restricts minting to an owner so the public testnet token can be +/// seeded into a bounded faucet instead of exposing public mint forever. +contract BaseSepoliaObolToken { + string public constant name = "Obol Network"; + string public constant symbol = "OBOL"; + uint8 public constant decimals = 18; + + uint256 public totalSupply; + address public owner; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + mapping(address => uint256) public nonces; + + bytes32 private constant _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 private constant _EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant _NAME_HASH = keccak256(bytes("Obol Network")); + bytes32 private constant _VERSION_HASH = keccak256(bytes("1")); + + uint256 private immutable _initialChainId; + bytes32 private immutable _initialDomainSeparator; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + modifier onlyOwner() { + require(msg.sender == owner, "OBOL: caller is not owner"); + _; + } + + constructor(address initialHolder, uint256 initialSupply) { + require(initialHolder != address(0), "OBOL: initial holder is zero address"); + owner = msg.sender; + _initialChainId = block.chainid; + _initialDomainSeparator = _computeDomainSeparator(); + _mint(initialHolder, initialSupply); + emit OwnershipTransferred(address(0), msg.sender); + } + + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparator(); + } + + function transfer(address to, uint256 value) external returns (bool) { + _transfer(msg.sender, to, value); + return true; + } + + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= value, "ERC20: insufficient allowance"); + if (allowed != type(uint256).max) { + unchecked { + allowance[from][msg.sender] = allowed - value; + } + emit Approval(from, msg.sender, allowance[from][msg.sender]); + } + _transfer(from, to, value); + return true; + } + + function mint(address to, uint256 value) external onlyOwner { + _mint(to, value); + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "OBOL: new owner is zero address"); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + function permit( + address owner_, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); + + uint256 nonce = nonces[owner_]++; + bytes32 structHash = keccak256( + abi.encode(_PERMIT_TYPEHASH, owner_, spender, value, nonce, deadline) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + address recovered = ecrecover(digest, v, r, s); + require(recovered != address(0) && recovered == owner_, "ERC20Permit: invalid signature"); + + allowance[owner_][spender] = value; + emit Approval(owner_, spender, value); + } + + function _transfer(address from, address to, uint256 value) internal { + require(to != address(0), "ERC20: transfer to the zero address"); + uint256 fromBalance = balanceOf[from]; + require(fromBalance >= value, "ERC20: transfer amount exceeds balance"); + unchecked { + balanceOf[from] = fromBalance - value; + balanceOf[to] += value; + } + emit Transfer(from, to, value); + } + + function _mint(address to, uint256 value) internal { + require(to != address(0), "ERC20: mint to the zero address"); + totalSupply += value; + balanceOf[to] += value; + emit Transfer(address(0), to, value); + } + + function _domainSeparator() internal view returns (bytes32) { + if (block.chainid == _initialChainId) { + return _initialDomainSeparator; + } + return _computeDomainSeparator(); + } + + function _computeDomainSeparator() internal view returns (bytes32) { + return keccak256( + abi.encode( + _EIP712_DOMAIN_TYPEHASH, + _NAME_HASH, + _VERSION_HASH, + block.chainid, + address(this) + ) + ); + } +}