06 Contract
Contract reference
Every function, event and constant in Oopsend.sol, with access rules, the checks in the order they run, effects and storage layout.
Source: contracts/src/Oopsend.sol, Solidity 0.8.26, EVM prague, optimizer 500 runs with via_ir. Runtime code is 6,101 bytes. There is no proxy, no inheritance and no external dependency.
#Constants and immutables
| Name | Value | Notes |
|---|---|---|
MAX_FEE_BPS | 100 | hard ceiling, 1% |
MIN_EXPIRY | 10 minutes | shortest undo window |
MAX_EXPIRY | 90 days | longest undo window |
CLAIM_TYPEHASH | keccak256("Claim(bytes32 linkKey,address recipient)") | EIP-712 type of the claim message |
feeWallet | set in the constructor | immutable, cannot be changed |
| domain | name Oopsend, version 1, block.chainid, address(this) | recomputed if the chain id changes (fork safety) |
#Storage
struct Link {
address sender; // who locked the funds
uint64 createdAt;
uint16 feeBps; // locked at creation
address claimKey; // address of the throw-away key
uint64 expiresAt;
address token; // address(0) = native ETH
uint128 amount; // what the escrow actually received
}
mapping(bytes32 => Link) public links; // key = keccak256(abi.encode(sender, id))
mapping(address => uint256) public feesAccrued; // token (0 = ETH) => unswept fees
address public admin;
bool public paused;
uint16 public feeBps; // current fee for NEW links
uint16 public tokenCapBps; // per-link cap for ERC-20, share of totalSupply, in bps
uint128 public ethCap; // per-link cap for ETH, in wei
A link occupies four storage slots. A link that no longer exists reads as the zero struct, so links(key).sender == address(0) means "closed or never created".
#Views
| Function | Returns |
|---|---|
linkKey(address sender, uint64 id) | keccak256(abi.encode(sender, id)) |
DOMAIN_SEPARATOR() | the EIP-712 domain separator for the current chain id |
claimDigest(bytes32 key, address recipient) | the exact digest the claim key must sign |
splitFee(uint256 amount, uint16 bps) | (fee, net); fee rounds down, so sends under 334 base units at 0.3% carry no fee |
links(bytes32), feesAccrued(address), admin(), paused(), feeBps(), tokenCapBps(), ethCap(), feeWallet() | public state |
#Creating
#createLinkEth(uint64 id, address claimKey, uint64 expiresIn) payable
Access: anyone. Locks msg.value as ETH.
Checks, in order: re-entrancy lock, paused → Paused, msg.value == 0 → ZeroAmount, msg.value > ethCap → OverCap, claimKey == 0 → ZeroAddress, expiresIn outside 10 minutes to 90 days → BadExpiry, record exists → LinkExists.
Effects: stores the record with the current feeBps, expiresAt = block.timestamp + expiresIn. Emits: LinkCreated(linkKey, sender, id, token = 0, amount, expiresAt, claimKey).
#createLinkToken(uint64 id, address claimKey, address token, uint128 amount, uint64 expiresIn)
Access: anyone, after approve(oopsend, amount) on the token.
Checks, in order: lock, Paused, amount == 0 → ZeroAmount, token == 0 or no code → NotAToken, totalSupply() call fails → NotAToken, amount > totalSupply * tokenCapBps / 10000 → OverCap; then transferFrom (failure → TransferFailed); the contract compares its balance before and after and uses the difference as the amount; a zero difference → ZeroAmount; then the same claimKey, expiry and existence checks as the ETH version.
Effects and event as above, with token set and amount equal to what arrived.
#Claiming
#claim(address sender, uint64 id, address recipient, bytes sig)
Access: anyone. The authority is the signature, not msg.sender.
Checks, in order: lock, no record → NoLink, block.timestamp >= expiresAt → Expired, recipient == 0 → ZeroAddress, signature length not 65 → BadSignature, s in the upper half of the curve or v not 27/28 → BadSignature, ecrecover returns zero → BadSignature, recovered address differs from claimKey → BadSignature.
Effects: the record is deleted first, then fee = amount * feeBps / 10000 is added to feesAccrued[token], then amount - fee is paid to recipient (a failed payout reverts the whole call with TransferFailed). Emits: LinkClaimed(linkKey, recipient, net, fee).
The signed message is Claim(bytes32 linkKey, address recipient) under the contract's EIP-712 domain. See The claim signature.
#Undo and expire
#undo(uint64 id)
Access: the sender, implicitly. The record is looked up under keccak256(abi.encode(msg.sender, id)), so a caller can only reach their own links. Calling for someone else's id finds nothing.
Checks: lock, no record → NoLink. There is no expiry check: undo works before and after the deadline. Effects: deletes the record, pays the full amount to the sender, no fee. Emits: LinkUndone(linkKey, amount).
#expireRefund(address sender, uint64 id)
Access: anyone.
Checks: lock, no record → NoLink, block.timestamp < expiresAt → NotExpired. Effects: deletes the record, pays the full amount to the recorded sender, no fee. Emits: LinkExpired(linkKey, amount).
#Fees
#sweepFees(address token)
Access: anyone. Moves feesAccrued[token] to feeWallet. token = address(0) sweeps ETH.
Checks: lock, accrued balance of zero → ZeroAmount. Effects: sets the accrual to zero before paying. A failing payout reverts the call. Emits: FeesSwept(token, amount).
#Admin
#setParams(uint16 feeBps, uint128 ethCap, uint16 tokenCapBps, bool paused)
Access: admin only (NotAdmin). Checks: feeBps > 100 → FeeTooHigh; tokenCapBps == 0, tokenCapBps > 10000 or ethCap == 0 → BadCap. Effects: updates the four settings. Open links keep the fee they were created with. paused = true blocks createLinkEth and createLinkToken only. Emits: ParamsUpdated(feeBps, ethCap, tokenCapBps, paused).
#transferAdmin(address next)
Access: admin only. Sets the new admin; address(0) renounces the role for good. Emits AdminChanged(next).
The constructor takes (admin, feeWallet, feeBps, ethCap, tokenCapBps), validates them with the same rules as setParams, and reverts with ZeroAddress if the fee wallet is the zero address.
#Events
| Event | Fields |
|---|---|
LinkCreated | bytes32 indexed linkKey, address indexed sender, uint64 id, address token, uint256 amount, uint64 expiresAt, address claimKey |
LinkClaimed | bytes32 indexed linkKey, address indexed recipient, uint256 net, uint256 fee |
LinkUndone | bytes32 indexed linkKey, uint256 amount |
LinkExpired | bytes32 indexed linkKey, uint256 amount |
FeesSwept | address indexed token, uint256 amount |
ParamsUpdated | uint16 feeBps, uint128 ethCap, uint16 tokenCapBps, bool paused |
AdminChanged | address indexed admin |
LinkCreated carries the link id on purpose. linkKey is a one-way hash, so without the id a sender who lost their browser could not call undo(id). With it, a sender can list their own links by filtering LinkCreated on the indexed sender topic, then undo from any device. Re-sharing a link still needs its secret, which is not on chain.
LinkClaimed, LinkUndone and LinkExpired carry no token field. To know the asset of a closed link, read the matching LinkCreated event, which has the same linkKey.
#What the contract does not have
- No
receive()orfallback(): ETH sent directly to the address is rejected, so it cannot get stuck. - No
withdraw, no sweep of arbitrary balances, no rescue function. - No upgrade path, no
delegatecall, noselfdestruct. - No way to change the fee wallet, and no way to change an open link.
Errors are listed in Errors, costs in Gas and costs.