The task
Circle, the issuer of USDC, opened a brief on DoraHacks: 20 microgrants of 500 USDC each for live projects on its new Arc network. Two conditions: the project is deployed on Arc mainnet and the code is open.
We wanted to test whether we can take a smart contract to production in a day. Not a mockup, but a contract with tests, verified source code and real money that has passed through it. And get a small but finished product for the portfolio: a site, a contract, a bot and proof on the blockchain.
The problem we solved is simple. A designer drew a logo for a cafe in Berlin and wants to receive 25 USDC. The usual path in crypto: send the client a wallet address, explain the network, and the client still has to find a coin for the fee. A client paying for the first time often gives up at this step. On Arc the fee is paid in USDC. If the client has 25 USDC, they can pay a 25 USDC invoice, and that is it.
Why, and what the idea was
The idea was chosen by a council of five AI advisors: a skeptic, an architect, a visionary, an outsider, a pragmatist. Verdict: a payment link service with a neutral name, not “Arc Pay”, because that sounds like a product of Circle itself. Throw out everything unnecessary: a currency exchange, x402, AI, swaps. The skeptic warned then that there would be many payment links among the applications. He was right, more on that below.
Three parts. The home page: amount, currency (USDC or EURC), payment description, a “connect wallet and create” button. The invoice page at the link ?i=number: visible to everyone, even without a wallet, with status, amount, who paid and when, and a link to the transaction. The Telegram bot: the seller sends /watch <address>, and the bot reports when an invoice is created for that address and when it is paid. The bot only reads the network, it holds no keys.
Every action on Dmytro’s behalf on DoraHacks (posting a question, submitting the application, accepting the terms) was done only after his direct “yes”.
How it went
| When | What happened |
|---|---|
| Morning | Council on the brief. Verdict: payment links, a neutral name, nothing extra |
| Morning | The contract and 13 tests, the site, the bot. A clean wallet for deployment, waiting for funds |
| 08:50 | Registration on DoraHacks, a Q&A question about payouts to Ukraine |
| 09:50 | Wallet funded with 2.99 USDC. Deployment for 0.034 USDC, invoice #1 created and paid, code verified on Sourcify. Site on Vercel, repository made public |
| 09:51 | Bot started on the server |
| 10:00 | An automated security review found an unescaped payment description in the bot’s message. Fixed |
| 10:05 | End-to-end check: invoice #2 created and paid. The bot was silent at first, fixed it, both notifications arrived |
| 10:20 | Application on DoraHacks: profile, description, a 10-question form. Submitted on Dmytro’s word |
| 12:40 | Research: 334 applications for 20 places. Follow-up work: a live payment in EURC, a 61-second video |
| 12:55 | The page sped up from 5+ to 1 second, the video and EURC added to the application |
5 commits in total, about 900 lines of code including tests and scripts. Working days: one.
Tools and method
The contract TallyInvoices.sol, 124 lines of Solidity. An invoice has three states: open, paid, cancelled. Only the seller can cancel. The money does not linger on the contract: it goes to the seller in the same transaction. The invoice state changes before the transfer, so a reentrant call finds the invoice already paid. A token address with no code is rejected, otherwise an invoice could be “paid” by a call that transfers nothing. A wrong amount, a repeat payment and an overlong description revert.
Tests in Foundry, 13 of them: among them a reentrancy attack, a seller who refuses to accept the money, and a fuzz test of the amount over 256 runs. The source code is verified on Sourcify with an exact bytecode match.
The site: one static page on Vercel, viem bundled into one file. Network reads go through our own proxy /api/rpc, which allows only 10 read methods and no more than 20 calls per batch. Writing through it is impossible. The bot is in Python with web3 and SQLite for subscriptions, polls the network every 3 seconds, runs as a service on our server.
Keys: the contract was deployed from a separate clean wallet created for this project. Transactions are signed locally, keys sit in owner-only files and never reach the repository. The same for the bot token. The service holds nobody’s money.
Result
Three real payments on Arc mainnet. Invoice #1 for 0.50 USDC was created and paid from another wallet 6 seconds apart, the seller got exactly +0.50, the contract holds 0. Invoice #2 for 0.25 USDC: the bot sent both notifications, on creation and on payment. Invoice #3 for 0.50 EURC was paid with two signatures (an approval and the payment), because EURC on Arc is a regular ERC-20 token. The seller got exactly +0.50 EURC.
The buyer’s fee for one payment is about 0.0014 USDC, deploying the contract cost 0.034 USDC. The hashes of all transactions are in the repository README. A paid invoice opens without a wallet: tally-invoices.vercel.app/?i=1.
Speed: the invoice page in a clean browser took over 5 seconds to open; after bundling viem locally, 0.9-1.1 seconds as measured by Playwright.
The weak spots, said plainly. There are no real users, this is a demo with our own payments: the counter on the site showed 3 invoices created, all ours. There is no feature lead over other applications either, the product is deliberately small.
Mistakes and what we took from them
"anonymous": false in the contract's event descriptions, without it it crashes with a KeyError. It did not surface in tests, only in the live run. We added the field, the bot caught up on the missed blocks itself and sent both notifications. Lesson: test the bot with a live payment before submitting, not by reading the code..vercel.app, we had to switch to markdown and check all 8 links in the preview. Dropdowns stayed invisible and overlapped neighbouring fields, the video field did not accept normal input. Lesson: after a form like this, verify what was saved through the API, not by eye. The description and video were verified with a request to their API.




