DEV Community

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

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

Suseendaran Nair on November 06, 2026

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 mid...
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.