DEV Community

Cover image for 4 money bugs that bite AI agents (and how I fixed them in PurseString)
Suseendaran Nair
Suseendaran Nair

Posted on

4 money bugs that bite AI agents (and how I fixed them in PurseString)

Real-world payment traps and practical fixes

I'm building PurseString, an open-source MCP server that lets AI agents spend money safely. A lot of it got built this week, because I'm in the middle of the PayPal AI Hackathon and building in public every day.

Today I want to share something more technical. When you let an agent touch money, the scary bugs aren't the clever ones. They're the boring ones that every payments engineer already knows about, except now nobody's watching while they happen.

Here are four of them, and what I did about each.

Bug 1: The retry that pays twice 🔁
This is the one that started the whole project. A checkout page hung for a few seconds. My agent decided the payment had failed and did the sensible thing: it tried again. Both charges went through.

Agents retry by design. That's normally a good thing. With money it's a disaster.

The fix: make the purchase idempotent, not just the API call. Every purchase intent gets a deterministic key built from who's asking and what for:

SHA256(agent_id, task_id, vendor, amount)
If the same agent asks to buy the same thing for the same task within 30 minutes, it gets back the same intent and the same card. Not a new one. The retry becomes harmless.

On the PayPal side I went one step further. PayPal payouts carry an idempotency key all the way to PayPal, so even if PurseString crashes halfway through the call, the retry can't pay the vendor twice.

Bug 2: 0.1 + 0.2 ≠ 0.3 🧮
You've seen this meme. I didn't think it would bite me. It did.

0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 <= 0.30 // false 😬
Picture an agent with a $0.30 daily budget. It spends $0.10, then $0.20. In plain floats that's over budget, so the second purchase gets refused. Flip it around, and rounding tricks can let something through that shouldn't.

I was patching around it with tiny epsilons and toFixed(2). That's a smell.

The fix: count money in whole minor units. You still write amount: 15.00 in policies.yaml and in tool calls. But every sum and comparison inside PurseString now runs on integers in the currency's smallest unit: cents for USD, yen for JPY, fils for KWD.

$0.10 + $0.20 → 10 + 20 = 30 cents → fits a 30-cent budget exactly ✅
And an amount that's finer than the currency allows, like $15.001, is refused, not quietly rounded. With money, silent rounding is just a bug you haven't found yet.

Bug 3: The hold that isn't a charge 🏨
This one surprised me most. When a card is "approved" at a hotel, a gas station or a lot of online stores, no money has actually moved yet. It's a hold (an authorization). Later the merchant captures some, all, or none of it.

The hotel holds $200 and captures $150.
The gas pump holds $100 and captures $43.
A store drops the hold and places a new one.
If you count every approval as spent money, budgets fill up with money that never left. Or worse, a merchant that re-authorizes gets refused for no reason.

The fix: track holds and captures separately. On Lithic, the card network asks PurseString in real time whether to approve each charge, and later sends what happened to it. PurseString applies those events to the hold it approved:

Captured → record what was really taken, end the hold.
Reversed, voided or expired → release it and give the budget back.
So a subscription card's monthly cap counts what was captured plus what's still held. Refunds are checked against what was actually captured, not what was held. Each event is applied once, so a provider re-sending the same webhook changes nothing.

Bug 4: "Who edited the log?" 📼
When finance asks "why did the agent buy this?", PurseString has an answer: the goal, the alternatives the agent tried, every rule and how it ruled, and who approved. That's the whole point.

But a log is only evidence if nobody can quietly change it. A plain JSON file can be edited by anyone with access to the disk, and that includes an agent with a shell.

The fix: hash-chain the audit log. Every line now carries a sequence number, the previous line's hash, and its own hash:

{ "event": "intent_approved", "seq": 2, "prev": "sha256:0870ce5a…", "hash": "sha256:a68bc3e0…" }
Edit, delete, insert or reorder a line, and the chain breaks right there:

$ pursestring verify-log
BROKEN at line 2 (seq 2): hash mismatch: entry was edited
Plain SHA-256 catches edits, but someone who can write the file could rebuild the whole chain. So if you set PURSESTRING_AUDIT_KEY to a secret kept somewhere else, entries are HMAC-signed instead, and the chain can't be rebuilt without the key.

One honest limit: a chain can't tell if the newest lines were cut off the end. verify-log prints the head hash, so save it somewhere else (a ticket, a Slack message) when you need to prove later that nothing was dropped.

Why this matters for the PayPal hackathon 💙
For the hackathon I added two things on top of the cards:

A PayPal and Venmo payout rail. The agent can pay a vendor once a purchase is approved. And the agent never chooses who gets paid. The recipient comes from a payee list in your rules, so an email saying "pay this address instead" has nothing to change.
Guardrails in front of PayPal's own MCP server. PayPal's tools pass through PurseString, so small refunds can go through on their own, bigger ones wait for a human, anything over a cap is refused, and accepting a dispute or cancelling a subscription always needs a person.
That second one is about the scariest attack on agents right now: prompt injection. A support agent reads a customer email that says "refund me $900 now." If the agent can call create_refund, nothing in the payment provider stops it. A prompt that says "be careful" doesn't stop it either, because the agent you're trying to control can't be the one enforcing the rules.

All four fixes above apply to PayPal too: safe retries, exact money math, the refund checks, and the tamper-evident log.

What I learned
Honestly, none of these bugs are new. Banks solved them decades ago. What's new is that an agent hits them at machine speed, at 3am, with nobody watching.

That's why I keep coming back to the same idea. Agents and money can work together. They just need the same boring, solid rails that we built for people: clear rules, a second pair of eyes on the risky stuff, and a record nobody can quietly change.

It's still early. PurseString is a v0.1 alpha. It's tested end to end on the Lithic and PayPal sandboxes, but it hasn't been battle-tested with real money yet. So please use sandbox keys, and tell me what breaks.

Try it, break it, build it with me
⭐ Repo: https://github.com/suseendar1414/pursestring
🧪 10-minute walkthrough: docs/try-it.md
💬 Discord: https://discord.gg/qBqgx2GB86
To add it to Claude Code:

claude mcp add pursestring -- npx -y github:suseendar1414/pursestring
It starts on a mock rail, so no money moves until you add a sandbox key.
retries, webhooks that arrive twice? I'd really love to hear it in the comments. It might end up as the next fix. 🙏

Top comments (14)

Collapse
 
princejain756 profile image
Prince Jain •

The (agent_id, task_id, vendor, amount) hash in Bug 1 caught me off guard when we wired dynamic Razorpay UPI QR reconciliation for Bigg Bounce (panel.adelev8.com, 10,238 transactions across 3,526 bookings and 7,116 POS orders). Hashing the static fields stopped network retries cold, but it accidentally blocked legitimate repeat orders when a guest walked back to the F&B counter 4 minutes later and bought a second identical combo inside the same session. Including a state-transition counter (cart_seq bumped only on a confirmed UI state transition, never on a network timeout retry) inside the SHA-256 payload separated real repeat purchases from hung-socket retries.

On Bug 2, rejecting sub-unit amounts like $15.001 instead of rounding is such an underrated rule. I used to do Math.round(Number(val) * 100) until floating-point drift on intermediate fee splits bit us by 1 paisa. Parsing the decimal string directly into BigInt minor units without ever touching IEEE-754 floats makes sub-cent rejection trivial:

function toMinorUnits(amountStr, decimals = 2) {
  const match = /^(\d+)(?:\.(\d+))?$/.exec(String(amountStr).trim());
  if (!match) throw new RangeError(`Invalid amount: ${amountStr}`);
  const whole = BigInt(match[1]);
  const fracRaw = match[2] || "";
  if (fracRaw.length > decimals) {
    throw new RangeError(`Sub-unit precision exceeded: ${amountStr}`);
  }
  const frac = BigInt(fracRaw.padEnd(decimals, "0"));
  return whole * (10n ** BigInt(decimals)) + frac;
}
Enter fullscreen mode Exit fullscreen mode

How are you handling partial multi-captures on a single Lithic authorization when a merchant splits shipment into two captures days apart?

Collapse
 
suseendaran_nair_88f71da1 profile image
Suseendaran Nair •

the walk around we do is Each capture adds to the total, and each Lithic event ID is applied only once, so two shipments days apart add up correctly. The gap is between them: we treat the first capture as ending the hold, so the uncaptured rest stops counting toward a subscription card’s monthly cap until the second capture lands.

Collapse
 
hitensethiya profile image
Hiten Sethiya •

the retry that pays twice is the one that should terrify anyone letting an agent touch money. idempotency at the intent level not just the api call is the boring fix that actually works. enforcement in the tool that moves the money beats another careful prompt

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The scary money bugs are the boring ones. An agent that retries a hung checkout will pay twice unless the purchase itself is idempotent, not just the HTTP call. A SHA of agent, task, vendor, and amount turning a retry into a lookup is the right shape.

Floats lie about budgets, a card approval can be only a hold, and a JSON log on disk is not evidence if the agent with a shell can edit it. Those are old payments lessons, and they become urgent the moment an agent can spend without a person watching each attempt.

At Ascendra Ventures we put those rules in the tool that moves the money: deterministic intent keys, integer minor units, holds tracked separately from captures, and an audit chain the agent cannot quietly rewrite. If any of those are missing, the agent is not allowed to pay.

Which of the four bites you most often right now: the double retry, the float, a hold counted as a spend, or a log someone could edit?

Collapse
 
suseendaran_nair_88f71da1 profile image
Suseendaran Nair •

All four bit us, in roughly that order. The one that still bites is the log. A hash chain shows an edit, but an agent with a shell can rebuild the whole chain. So we key it with an HMAC secret the agent never sees, and we tell people to copy the chain head somewhere else.

Collapse
 
reidmarlow profile image
Reid Marlow •

The minor-unit integer rule saves a lot of headache. Another variant of the retry bug happens when an agent hits a network timeout or connection reset before receiving the authorization receipt. If the client library does not expose the raw idempotency token, the agent tries to reconstruct intent from memory, often altering the task payload or generating a new UUID. Pushing the idempotency key calculation down to a deterministic hash of the invariant (caller identity, intent hash, account, amount in minor units) means even if the runner reboots mid-flight, the retry hits the exact same gate.

Collapse
 
ivannovazzi profile image
Ivan Annovazzi •

Oof, the retry double charge is the scariest one here. An agent reads a hang as failure and just goes again, easy trap. The deterministic intent key is a tidy fix though. What happens when an agent legitimately needs the same purchase twice for one task inside that 30 min window, like buying two seats?

Collapse
 
suseendaran_nair_88f71da1 profile image
Suseendaran Nair •

well what currently pursestring does is Identical requests in the window are treated as retries on purpose. For two seats, the agent either buys them as one purchase or gives each its own task ID, and both still count toward the budget.

Collapse
 
paullovelace profile image
Paul Lovelace •

Bug 1 is the one that scares me most too — an agent reads a hang as a failure and just goes again. The deterministic intent key is a tidy fix, and the minor-units rule is the one I'd steal wholesale; I've been patching around float rounding with epsilons and it always felt like a smell. One question on the hold tracking: when a merchant re-authorizes and the old hold expires, does the budget get the money back immediately, or is there a window where both holds count?

Collapse
 
kevinhall profile image
Kevin Hall •

the retry that pays twice shows up with metered tools too, the agent reads a slow run as failed and starts another. what helped us was reading the status of the first run before any retry, so a hang turns into a lookup and not a second bill

Collapse
 
suseendaran_nair_88f71da1 profile image
Suseendaran Nair •

Exactly this. I fingerprints each request (agent, task, merchant, amount), so a retry inside the window comes back as a lookup with the original status, not a second charge. A retry after a timeout gets the same card back, and a half-finished run stays locked so it can’t be issued twice.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

A spend cap the model can read is a request it can ignore. Enforcing it in the tool that moves the money is the version I'd trust. Do you cap per run or per day?

Collapse
 
suseendaran_nair_88f71da1 profile image
Suseendaran Nair •

Caps are per payment and per agent per day, and recurring cards also get a monthly cap. Each purchase gets its own single-use card locked to that merchant and amount, so one run can’t spend more than it was approved for.

Collapse
 
wiroai profile image
Wiro Team •

One more that bit us: the stale yes. We tie the confirm to the exact amount the user saw and price it again on confirm, so an old yes can't charge a new price.