Your bank card has a daily spending limit. Your wallet doesn't. Curb gives it one.
A vault contract on Monad that holds your funds and caps how much can leave per day. Lowering the cap is instant. Raising it is timelocked. So one bad click — or one stolen key — can't take everything.
Live demo: (deploying — see Status below) Contract: (deploying) on Monad Testnet (chain 10143)
Unaudited. This is hackathon-stage code on Monad testnet. It has not been reviewed by an auditor. Do not use it with real funds. See SECURITY.md.
git clone https://github.com/nikolas-sapa/curb && cd curb
# contracts
cd contracts && forge test # 30 tests
# frontend
cd ../web && npm install
echo "NEXT_PUBLIC_CURB_ADDRESS=0x..." > .env.local
npm run dev # localhost:3000I'm not a crypto person. I have about $20 in a wallet I opened once and abandoned, because two things scared me off: the seed phrase, and the send button. Every wallet I opened asked me to paste a long string of gibberish and then hand over an amount with no ceiling and no way back.
My bank account has never scared me like that, and the reason is boring: my card has a daily limit. If someone gets my card, they get one day's limit, not my life savings. I didn't need to understand banking to be protected. The limit did the work.
Crypto wallets don't have that. Your key can always move everything, instantly. That's the whole gap Curb fills. It isn't a new idea — Argent v1 shipped a daily limit years ago (prior art, credited below). It's just a missing default, and this is what it looks like on a chain fast enough that using it doesn't feel like waiting.
One contract, one account per address.
open(cap, raiseDelay)— create your vault, optionally funding it in the same call.send(to, amount)— move funds out, if it fits under your cap. Recipients get plain native MON: no claim step, no integration, works with any address or exchange.lowerCap(newCap)— instant. Also abandons any pending raise.requestRaise(newCap)→ waitraiseDelay→applyRaise().cancelRaise()is your veto.
The contract custodies the funds. That's the load-bearing detail: your key can't route
around your own cap, because your key doesn't hold the money — spending goes through
send() or it doesn't happen. Withdrawing to yourself is just a send(), so there's no
uncapped back door.
Why this needs a contract, not an app setting: a limit your wallet enforces in its own UI is a suggestion — the key still signs anything. A limit the chain enforces is a rule. Nothing here relies on anyone reporting a real-world fact; the cap, the window, and the timelock are all chain-native state.
Curb bounds the damage. It does not make you un-hackable, and it would be dishonest to imply otherwise. An attacker holding your key can still:
- Spend up to your cap, every window, indefinitely.
- Extract up to ~2× your cap in one burst by straddling a window boundary — the full
cap just before the 24h reset, the full cap again just after. Windows are fixed, not a
rolling sum. This is pinned by
test_windowStraddleAllowsTwiceCap. - Request a raise. You have
raiseDelayto notice and cancel it. If you're not watching, it lands. At the unlock moment, whether yourcancelRaiseor theirapplyRaisegets included first is an ordinary mempool race. - Set your cap to 0, freezing your account for a full
raiseDelayeven after you recover the key. Availability is deliberately traded for safety.
Your own exit is capped too. That's the product, not a bug: deposit only what you're content to withdraw at your chosen rate.
Other honest notes:
MIN_RAISE_DELAYis 60s so you can watch the full timelock loop in a demo. It is not a realistic security setting — use hours or a day. At 60s, validator timestamp drift is proportionally significant; at 24h it's noise.raiseDelayis clamped to 365 days. Without a ceiling, a large value overflowsblock.timestamp + raiseDelayand bricks raises permanently.- Ether force-fed via
selfdestructisn't credited to any account and can't be withdrawn by anyone. It's a one-way sink; it only ever helps solvency. - Native MON only. No ERC-20 support — one feature that works beats five that don't.
- Unaudited hackathon code on testnet. Don't put real money in it.
cd contracts
cp ../.env.example ../.env # add a funded testnet key
forge script script/Deploy.s.sol --rpc-url https://testnet-rpc.monad.xyz --broadcastTestnet MON: https://faucet.monad.xyz
Honest answer: a spending limit doesn't require a fast chain. It works anywhere.
What Monad changes is whether anyone would use it. Curb adds a contract hop to every send.
On a 12-second chain that's a tax you'd feel on every transaction, and a "safety feature"
you feel is a safety feature you turn off. At 400ms blocks with eth_sendRawTransactionSync,
the write returns its receipt in the same request — the UI shows you the measured
confirmation time, and it's usually a few hundred milliseconds. The protection stops being
something you notice.
Solidity + Foundry · Next.js 16 + React 19 · wagmi v3 + viem · Tailwind v4 · Geist
useWriteContractSync maps to eth_sendRawTransactionSync: receipt in the same request,
no polling. The ABI is generated from the Foundry build output, so it can't drift from the
deployed bytecode.
Credit where it's due:
- Argent v1 — daily transfer limits with guardian-based recovery. The direct ancestor of this idea.
- Safe modules — allowance modules cover similar ground for multisigs.
- EIP-7702 was the obvious tool here and it does not work for this. A delegated EOA keeps its key: delegated code runs when the account is called, not when the key signs a plain transaction, and the delegation can be cleared at will. A 7702 "spending limit" is a switch the attacker can flip off. Custody is what makes the cap real.
Bug reports, PRs, and questions are welcome. See
CONTRIBUTING.md for dev setup, the real forge/npm
commands, and PR expectations. This project follows the
Contributor Covenant.
Curb is unaudited. Do not use it with real funds. If you find a vulnerability, do not open a public issue — email niksapa150@gmail.com. Full policy and known, accepted risks: SECURITY.md.
MIT, see LICENSE. Copyright (c) 2026 Nikolas Sapalidis.