Skip to content
Merged
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
42 changes: 42 additions & 0 deletions contracts/fork-obol/README.md
Original file line number Diff line number Diff line change
@@ -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 <initial-holder> 100000000000000000000000000

forge create src/BaseSepoliaObolFaucet.sol:BaseSepoliaObolFaucet \
--rpc-url "$BASE_SEPOLIA_RPC_URL" \
--private-key "$DEPLOYER_PRIVATE_KEY" \
--constructor-args <token-address> <owner> 100000000000000000000 86400
```

After deploying and seeding the faucet, configure the frontend with:

```bash
NEXT_PUBLIC_BASE_SEPOLIA_OBOL_TOKEN_ADDRESS=<token-address>
NEXT_PUBLIC_BASE_SEPOLIA_OBOL_FAUCET_ADDRESS=<faucet-address>
NEXT_PUBLIC_BASE_SEPOLIA_OBOL_FAUCET_AMOUNT="100 OBOL"
```
2 changes: 1 addition & 1 deletion contracts/fork-obol/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
src = "src"
out = "out"
libs = []
solc_version = "0.8.24"
solc_version = "0.8.35"
89 changes: 89 additions & 0 deletions contracts/fork-obol/src/BaseSepoliaObolFaucet.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
145 changes: 145 additions & 0 deletions contracts/fork-obol/src/BaseSepoliaObolToken.sol
Original file line number Diff line number Diff line change
@@ -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)
)
);
}
}
Loading