Haltercard
A card for your AI agent. You set the limits, it spends inside them.
What it is
Haltercard is one contract on Robinhood Chain (chain id 4663). Anyone can issue a card: send ETH into it and name an agent address. The ETH stays in the contract, counted for that card only. The agent can pay from the card with pay, and only inside the rules you set. You, the holder, keep every other control.
- Holder (the wallet that called
issue): top up, withdraw, freeze, unfreeze, change limits, expiry, recipients or agent, close. - Agent:
pay(id, to, amount, memo). Nothing else. It cannot change a rule or withdraw. - Anyone can top up a card and read every card and its statement.
There is no owner, no admin key, no upgrade path and no fee in the contract.
Contract
Demo card and team wallets
Card #1 is the live demo on the home page. It is a normal card, with no special rights.
- Team deployer
0xD51AFdEea7127206e51f0626FC23A89205a7D6f8: deployed the contract (it has no admin rights in it) and is the holder of card #1. Card #1's allowlist holds only this address, so the demo payments go back to the team deployer wallet. - Demo agent
0xD4B927e508344250Aed5D8c1671c90AbaF984ee3: the agent of card #1. It makes the small demo payments you see in the statement.
Wire an agent
Give the agent its own private key (a fresh wallet, holding only a little ETH for gas). Issue a card with that address as the agent. Then the agent pays like this, using viem:
Simulate first, as above: if a rule would fail, simulateContract throws with the custom error name and nothing is sent. The memo is a bytes32: up to 32 bytes of text, stored in the statement. Do not put secrets in it.
Limits
- Per-payment cap: the most a single
paycan move. Must be above zero and not above the daily cap. - Daily cap: per 24-hour window, not per calendar day. A window lasts 24 hours from its start (
dayStart). Once block time passesdayStart+ 24 hours, the next payment opens a new window and the spent amount starts again from zero.card(id)returnsremainingToday. The daily limit runs in 24-hour windows. Across the edge of two windows an agent can spend up to twice the daily limit in a short time. Set the limit with that in mind. - Recipients: either an allowlist (up to 20 addresses at issue; add or remove more later with
setAllowed) or any address. - Expiry: optional, must be in the future when set (0 = none). After it,
payreverts. The holder can still withdraw or close. - Freeze: the holder can freeze at once, for example if the agent key leaks. Payments revert until the holder unfreezes. The agent cannot unfreeze.
- Close: sends the whole balance back to the holder. Permanent.
Freeze first
When you change the agent or lower the limits, freeze the card first, make the change, then unfreeze. If the old agent key has leaked, whoever holds it could still spend inside the old limits until your change lands on chain. A frozen card cannot pay at all, so nothing slips through in between. The issue page offers this as one step (three transactions: freeze, change, unfreeze).
Order of checks in pay
Not closed → not frozen → not expired → recipient allowed → amount above zero → amount within per-payment cap → amount fits the daily window → amount within balance. Then the payment is written to the statement and the ETH is sent. If the recipient refuses the ETH, the whole call reverts.
Errors
| Error | Meaning |
|---|
Statement
Every payment is stored on chain as { at, to, amount, memo } and also emitted as a Paid event. Read it with spendCount(id) and statement(id, offset, limit): newest first (offset 0 is the latest payment), at most 100 per call. cardsOf(holder) lists a holder's cards. cardsForAgent(agent) lists the cards an address was made agent of; after setAgent the old agent's list still holds the id, so always check card(id).agent.
Functions
| Function | Who |
|---|---|
| issue(agent, perTxLimit, dailyLimit, expiresAt, anyRecipient, allow[], label) | Anyone; becomes holder. Payable: msg.value funds the card. |
| topUp(id) | Anyone. Payable. |
| withdraw(id, amount, to) | Holder |
| freeze(id) · unfreeze(id) | Holder |
| setLimits(id, perTx, daily) | Holder |
| setExpiry(id, t) | Holder. 0 = no expiry. Unix seconds, UTC. |
| setAllowed(id, addr, bool) | Holder |
| setAnyRecipient(id, bool) | Holder |
| setAgent(id, agent) | Holder |
| close(id) | Holder. Refunds the balance, permanent. |
| pay(id, to, amount, memo) | Agent |
| card(id) · allowed(id, addr) · spendCount(id) · statement(id, offset, limit) · cardsOf(a) · cardsForAgent(a) · nextId() | Views. A card id that does not exist returns holder 0x0. |
Plain ETH sent straight to the contract is rejected; use topUp(id). So every wei the contract holds belongs to a card.
What it does not do
- ETH only. No tokens, no stablecoins, no NFTs.
- No contract calls.
paysends plain ETH with no calldata. An agent cannot swap, approve or call any contract with card funds. - No custody of your wallet. The agent never gets your key. But it does get its own key: anyone holding that key can spend inside the card's limits until you freeze it.
- Not audited by a third party. It was reviewed internally only. Keep balances small.
- Holder keys matter. There is no recovery, admin or support override. Lose the holder wallet and nobody can freeze, withdraw or close the card.
- No reversals. A payment that passed the checks is final on chain.
- No yield. ETH on a card earns nothing.
Times
All times on this site are UTC.