12 Guides
Integrate
For wallets and apps: the ABI, and the ethers v6 calls to create, claim, undo, refund and read links.
The calls on this page were run against the compiled contract on a local anvil with chain id 4663, by scripts/docs-e2e.mjs. Their output is in Examples. Your own deployment address replaces OOPSEND below, and none is published yet.
#ABI
Human-readable fragments for ethers v6:
export const ABI = [
'function linkKey(address sender, uint64 id) pure returns (bytes32)',
'function claimDigest(bytes32 key, address recipient) view returns (bytes32)',
'function DOMAIN_SEPARATOR() view returns (bytes32)',
'function splitFee(uint256 amount, uint16 bps) pure returns (uint256 fee, uint256 net)',
'function links(bytes32) view returns (address sender, uint64 createdAt, uint16 feeBps, address claimKey, uint64 expiresAt, address token, uint128 amount)',
'function feesAccrued(address token) view returns (uint256)',
'function feeWallet() view returns (address)',
'function feeBps() view returns (uint16)',
'function ethCap() view returns (uint128)',
'function tokenCapBps() view returns (uint16)',
'function paused() view returns (bool)',
'function createLinkEth(uint64 id, address claimKey, uint64 expiresIn) payable',
'function createLinkToken(uint64 id, address claimKey, address token, uint128 amount, uint64 expiresIn)',
'function claim(address sender, uint64 id, address recipient, bytes sig)',
'function undo(uint64 id)',
'function expireRefund(address sender, uint64 id)',
'function sweepFees(address token)',
'event LinkCreated(bytes32 indexed linkKey, address indexed sender, uint64 id, address token, uint256 amount, uint64 expiresAt, address claimKey)',
'event LinkClaimed(bytes32 indexed linkKey, address indexed recipient, uint256 net, uint256 fee)',
'event LinkUndone(bytes32 indexed linkKey, uint256 amount)',
'event LinkExpired(bytes32 indexed linkKey, uint256 amount)',
'event FeesSwept(address indexed token, uint256 amount)',
'error Paused()', 'error ZeroAmount()', 'error ZeroAddress()', 'error OverCap()', 'error BadExpiry()',
'error Expired()', 'error NotExpired()', 'error NoLink()', 'error LinkExists()', 'error BadSignature()',
'error NotAToken()', 'error TransferFailed()', 'error Reentrancy()',
];
The full ABI, including admin functions, is in contracts/out/Oopsend.sol/Oopsend.json after forge build.
#Create a link
import { ethers } from 'ethers';
const oops = new ethers.Contract(OOPSEND, ABI, signer);
const secret = ethers.randomBytes(32); // the link's secret
const claimWallet = new ethers.Wallet(ethers.hexlify(secret));
const id = ethers.toBigInt(ethers.randomBytes(8)); // any unused uint64
// ETH
await (await oops.createLinkEth(id, claimWallet.address, 7 * 86400,
{ value: ethers.parseEther('1.5') })).wait();
// ERC-20: approve first, then create
await (await token.approve(OOPSEND, amount)).wait();
await (await oops.createLinkToken(id, claimWallet.address, tokenAddress, amount, 7 * 86400)).wait();
Show the link only after the transaction is mined, and build it as described in Link format: https://<your host>/c/<sender>/<id>#<base58 secret>.
Simulate first when you can, so the user sees a clear reason instead of a failed transaction: await oops.createLinkEth.staticCall(...) reverts with the custom error name (OverCap, Paused, BadExpiry...).
#Read a link
const key = await oops.linkKey(sender, id);
const l = await oops.links(key);
const open = l.sender !== ethers.ZeroAddress; // zero address: closed or never created
if (open) {
const fee = (l.amount * BigInt(l.feeBps)) / 10000n; // the fee stored in the link, not the current setting
const net = l.amount - fee;
const secondsLeft = Number(l.expiresAt) - Math.floor(Date.now() / 1000);
}
#Claim: sign, then submit
The claim key signs Claim(linkKey, recipient). Submitting can be done by the recipient or by a relayer. See The claim signature for why this is safe.
const domain = { name: 'Oopsend', version: '1', chainId: 4663, verifyingContract: OOPSEND };
const types = { Claim: [{ name: 'linkKey', type: 'bytes32' }, { name: 'recipient', type: 'address' }] };
const sig = await claimWallet.signTypedData(domain, types, { linkKey: key, recipient });
// the recipient pays gas themselves ...
await (await oops.connect(recipientSigner).claim(sender, id, recipient, sig)).wait();
// ... or anyone relays it, the recipient needs no ETH
await (await oops.connect(relayer).claim(sender, id, recipient, sig)).wait();
Before submitting, await oops.claim.staticCall(sender, id, recipient, sig) tells you if it would revert, and why. A cheap safety check is to compare oops.claimDigest(key, recipient) with ethers.TypedDataEncoder.hash(domain, types, { linkKey: key, recipient }): they must be equal.
#Undo and refund
await (await oops.undo(id)).wait(); // sender only, any time while open, no fee
await (await oops.connect(anyone).expireRefund(sender, id)).wait(); // after expiry, pays the recorded sender
#List a sender's links
LinkCreated carries the id and indexes the sender, so a sender can rebuild the list of their links from any device and undo any that are still open.
const created = await oops.queryFilter(oops.filters.LinkCreated(null, senderAddress), fromBlock);
for (const e of created) {
const { linkKey, id, token, amount, expiresAt } = e.args;
const open = (await oops.links(linkKey)).sender !== ethers.ZeroAddress;
// show it; if open, offer undo(id)
}
Bound fromBlock to the deployment block: public RPCs limit the range of getLogs. A lost secret cannot be recovered from the chain, so a link whose secret is gone can be undone but not re-shared.
To learn how a link ended, filter LinkClaimed, LinkUndone and LinkExpired on the same indexed linkKey.
#Sweep fees
await (await oops.sweepFees(ethers.ZeroAddress)).wait(); // ETH fees to the fee wallet
await (await oops.sweepFees(tokenAddress)).wait(); // token fees; reverts ZeroAmount if nothing accrued
#The app pages
The site ships app.html (send, list and undo with a wallet) and claim.html (the recipient's page). They read the contract address from site/assets/config.js and talk to the contract through the visitor's wallet. Point your own front end at the same contract and the same link format and the two are interchangeable.
#Checklist
- Never send or log the URL fragment.
- Always check
claimDigestagainst your own digest before submitting a claim. - Show the fee stored in the link, and the expiry, before the recipient signs.
- Treat
TransferFailedas "try a different recipient" for claims, see Errors. - Do not offer tokens that can blocklist, freeze, pause or rebase, see Security and limits.