Smart Contract
The on-chain component of Opentip. Written in Solidity, deployed on Base.
Deployment
V2 (Multi-token)
0xAf1b70F5BdDFfD64c5D7B971bA670e1ff65cB1bCBasescan →Supports USDC and ETH. OAR is coming soon.
Solidity
^0.8.26
License
MIT
Tokens
USDC · ETH
Fee
feeBps, shown on the tip page
Inheritance
The contract inherits from four OpenZeppelin libraries:
Ownable2Step
Two-step ownership transfer for safety
Pausable
Emergency pause/unpause mechanism
ReentrancyGuard
Protection against reentrancy attacks
EIP712
Off-chain typed signature verification
Constants
| Constant | Value | Description |
|---|---|---|
| MAX_FEE_BPS | 1000 | Maximum fee: 10% |
| MAX_REPO_ID_LENGTH | 200 | Maximum characters for a repo ID |
Functions
User Functions
| Function | Parameters | Returns | Modifier | Description |
|---|---|---|---|---|
| registerRepo | repoId, payoutAddress, expiry, nonce, signature | — | whenNotPaused | Register a repo with an EIP-712 signed permit. Validates format, expiry, and signature. |
| updatePayoutAddress | repoId, newAddress | — | whenNotPaused | Rotate the payout wallet for a registered repo. Only callable by current payout address. |
| receiveTipEth | repoId | — | payable, nonReentrant, whenNotPaused | Accept an ETH tip. Splits fee, credits pending balance in ETH. |
| receiveTip | repoId, token, amount | — | nonReentrant, whenNotPaused | Accept an ERC-20 tip (USDC). OAR is coming soon. Pulls tokens from msg.sender, splits fee, credits pending balance. |
| claimAll | repoId | — | nonReentrant, whenNotPaused | Withdraw all pending tokens (ETH + ERC-20) in a single transaction. Only callable by the payout address. |
Admin Functions
| Function | Parameters | Returns | Modifier | Description |
|---|---|---|---|---|
| pause | — | — | onlyOwner | Emergency pause. Blocks register, update, tip, and claim. |
| unpause | — | — | onlyOwner | Resume operations after a pause. |
| setFeeBps | newFeeBps | — | onlyOwner | Update platform fee. Capped at MAX_FEE_BPS (1000 = 10%). |
| setTreasuryAddress | newTreasury | — | onlyOwner | Change the treasury address. |
| withdrawTreasury | token, amount | — | onlyOwner, nonReentrant | Withdraw accumulated fees for a specific token. |
| setRegistrarSigner | newSigner | — | onlyOwner | Rotate the EIP-712 registrar signer. |
| adminReassignPayout | repoId, newAddress | — | onlyOwner | Emergency recovery: reassign payout address. Developer must first link and verify the new wallet in their dashboard. |
| adminMigrateRepo | repoId, payoutAddress | — | onlyOwner | Migrate a v1 repo to v2. Disabled after migrationDeadline. |
| addToken | token, decimals | — | onlyOwner | Register a new ERC-20 token for tipping. |
| removeToken | token | — | onlyOwner | Remove a token from accepting new tips. Existing pending balances remain claimable via everAcceptedTokens. |
| sweepStrayEth | to | — | onlyOwner, nonReentrant | Recover ETH sent directly to the contract outside receiveTipEth. |
| setMigrationDeadline | deadline | — | onlyOwner | Set a deadline after which adminMigrateRepo is disabled. Pass 0 to re-enable. |
View Functions
| Function | Parameters | Returns | Modifier | Description |
|---|---|---|---|---|
| getPendingBalance | repoId, token | uint256 | view | Amount of a specific token available to claim for a repo. |
| getPayoutAddress | repoId | address | view | The wallet that can claim tips for a repo. |
| getTotalTipped | repoId, token | uint256 | view | Lifetime gross tip amount for a repo in a specific token. |
| getTotalTipCount | repoId | uint256 | view | Lifetime tip count for a repo. |
| feeBps | — | uint256 | view | Current platform fee in basis points. |
| treasuryBalances | token | uint256 | view | Accumulated unwithdrawn fees for a token. |
| isRegistered | repoId | bool | view | Whether a repo has been registered. |
Events
| Event | Parameters | Description |
|---|---|---|
| RepoRegistered | repoId, payoutAddress (indexed), timestamp | Emitted when a new repo is registered. |
| PayoutAddressUpdated | repoId, oldAddress, newAddress (indexed) | Emitted when a payout wallet is rotated. |
| TipReceived | tipper (indexed), repoId, token, amount, feeAmount, timestamp | Emitted on every tip (ETH or ERC-20). |
| Claimed | repoId, payoutAddress (indexed), token, amount, timestamp | Emitted when a developer claims tips. |
| TreasuryWithdrawn | to (indexed), token, amount, timestamp | Emitted when the treasury balance is withdrawn. |
| FeeUpdated | oldFeeBps, newFeeBps | Emitted when the platform fee changes. |
| TreasuryAddressUpdated | oldTreasury (indexed), newTreasury (indexed) | Emitted when the treasury address changes. |
| RegistrarSignerUpdated | oldSigner (indexed), newSigner (indexed) | Emitted when the registrar signer is rotated. |
| TokenAdded | token (indexed), decimals | Emitted when a new token is registered for tipping. |
| TokenRemoved | token (indexed) | Emitted when a token is removed. |
Fee Structure
The platform fee is expressed in basis points (bps). 100 bps is 1 percent. The fee is capped at 1000 bps (10%) and enforced in both the constructor and setFeeBps. The tip page shows the current rate.
How fees flow
- Tipper sends tokens via
receiveTipEth()(ETH) orreceiveTip()(ERC-20) - Fee =
amount * feeBps / 10000 - Fee credited to
treasuryBalance[token] - Net amount (amount - fee) credited to repo's
pendingBalance[token] - Developer calls
claimAll()to withdraw all tokens - Owner calls
withdrawTreasury(token, amount)to collect fees per token
Registration Flow
Registration uses EIP-712 typed signatures to prevent squatting. The process:
- Server verifies GitHub ownership (owner or write collaborator)
- Server signs a
Registertyped struct with the registrar key - User submits the signature on-chain via
registerRepo() - Contract verifies the registrar signer matches, stores the repo
The signature expires after 5 minutes (backend) with a hard cap of 10 minutes enforced on-chain. This limits the window for replay attacks while giving ample time for normal transaction submission.
Repo ID validation rules
- Must be lowercase (no uppercase A-Z)
- Must contain exactly one
/separator - Minimum 3 characters, maximum 200
- No leading or trailing
/ - Must not be already registered
EIP-712 domain
{
name: "Opentip",
version: "2",
chainId: 8453,
verifyingContract: "0x..."
}Security Properties
Reentrancy protection
All state-changing external functions (receiveTip, receiveTipEth, claimAll, withdrawTreasury) use nonReentrant.
Pausable
The owner can pause the contract in an emergency. Paused contract blocks registration, tip updates, tips, and claims.
Two-step ownership
Uses Ownable2Step — ownership transfer requires the new owner to accept. Prevents accidental transfer to a wrong address.
Checks-effects-interactions
All functions update state before making external calls (USDC transfers), following the CEI pattern.
Stray ETH protection
ETH sent outside receiveTipEth() is tracked separately as strayEth and can be swept by the owner via sweepStrayEth().
Token removal safety
Removing a token stops new tips but existing pending balances remain claimable. claimAll() iterates over everAcceptedTokens, a permanent record of all tokens that were ever added.
Signature expiry bound
Registration signatures are capped at 10 minutes maximum validity. The backend signs with a 5-minute expiry.
Source code
The full contract source is available at contracts/src/OpentipV2.sol in the repository.