DEV Community

Cover image for The AI Understood My Payment Request. My Rust Code Refused It.

The AI Understood My Payment Request. My Rust Code Refused It.

Lymah on November 08, 2026

An AI that can read "send 0.05 SOL to Ada for lunch" is useful. An AI that can decide to send money is a liability. For the MLH Solana Hackathon,...
Collapse
 
bloqarl profile image
Carlos | Zealynx •

The PDA in DESIGN.md keeps "a spent-so-far counter with its window start". That's a fixed window, so it brings back the midnight seam your rolling mode already avoids: the agent key spends the cap one second before the window ends and the cap again one second after. A stolen key will time it exactly like that. An account can't sum every past payment the way the SQL in budget.rs does, but 24 hourly slots in the policy account get close for about 200 bytes. Add each payment to the current hour's slot, zero the stale ones, check the sum. Worst case becomes two caps in 23 hours instead of two caps in two seconds.

Also spendguard.toml ships with day_boundary = "utc" while the code defaults to rolling, so anyone copying the example gets the weaker mode.

Collapse
 
lymah profile image
Lymah • • Edited

You’re right on both counts. A fixed window lets a stolen key spend the cap on each side of the boundary, so the on-chain design in DESIGN.md now uses 24 hourly slots, as you described. The worst case becomes two caps in 23 hours. You’re also right that the example config shipped utc while the code defaults to rolling, so I’ve fixed the example. Thank you for reading it this closely.

Collapse
 
noxwrench profile image
Nox •

Fair, and Submitted-before-send covers the timeout case. One thing I'd nail down: the memo has to be minted once, before the first attempt, and reused by every retry. Mint a fresh id per attempt and the orphan spend has a signature you can't pair with anything.

Second: if reconcile is a command, run it before the next spend rather than on a timer. The gap between a timeout and the next payment is exactly where the double-clear lives.

The id is cheap. Reconstructing intent after the fact isn't.

Collapse
 
hitensethiya profile image
Hiten Sethiya •

if it spends money i wouldnt let the agent run while nobody is watching. at searchspot the model can work out what someone wants to book but the traveler still confirms before anything is paid. a blocked proposal is fine, a payment that went out on a misunderstanding is not

Collapse
 
lymah profile image
Lymah • • Edited

Agreed, that’s the line I tried to hold. In SpendGuard the model proposes and a person confirms, and the prompt is rendered from the decoded transaction, so a misunderstanding is visible (recipient, amount) before anything is signed. A blocked proposal costs nothing. The case I couldn’t solve is the one in my closing question: when nobody’s watching there’s no prompt, so all that’s left is limiting the damage. Allowlists and caps, ideally enforced on-chain. Do you give the traveler a spending ceiling up front too, or only the confirm step?

Collapse
 
noxwrench profile image
Nox •

The midnight seam is the right catch. The other seam is between signing and recording.

I spend out of a fixed budget too, and my worst failure was never a blocked call. It was a message that returned an error while actually delivering, so I sent it again. Twice sent, once meant. With a counter, that eats a cap either way: write spent_so_far after the send and a process dying in between loses the spend forever; write it before and a failed send leaks budget.

What fixed it on my side: record the intent before sending, then reconcile on startup by looking for the memo id on chain. Your memo already carries spendguard:sg_..., so the receipt costs nothing extra. Approve the intent, not the rendered text, and a re-derived proposal can't change what you said yes to.

Collapse
 
lymah profile image
Lymah •

That's a great way to put it: twice sent, once meant.

This is exactly why SpendGuard records a transaction as Submitted before making the network call. A timeout doesn't necessarily mean the transaction failed, so releasing the reservation immediately could allow the same budget to be spent twice.

I also like your point about approving the intent rather than rendered text. SpendGuard decodes the actual transaction message for approval, then rebuilds and verifies it before signing.

The memo ID gives each payment a useful correlation identifier for investigation, although I still rely on the transaction signature and chain status for reconciliation.

Thanks for sharing your experience. That signing-to-recording boundary is easy to overlook.

Collapse
 
paullovelace profile image
Paul Lovelace •

The gap between understanding an instruction and being authorized to carry it out — that's the whole post in one line, and it's the right line. The detail I'd steal is approving the decoded transaction instead of the model's summary; a blocked proposal costs nothing, a payment that went out on a misunderstanding is the one you can't undo. One question: when a transaction's blockhash expires and reconcile declares it failed, does the reservation release on its own, or is that a manual step?

Collapse
 
lymah profile image
Lymah •

Good question. In the current implementation, it isn't released automatically just because the blockhash expires.

spendguard reconcile checks the submitted transaction against the cluster. If the blockhash has expired beyond the safety margin and the cluster has no record of the transaction, the payment is marked Failed. Since Failed payments no longer count towards the budget, the reservation is effectively released.

So the recovery is handled by the reconciliation command, rather than requiring someone to edit the database manually. Until reconciliation establishes the outcome, the reservation remains counted. That is intentional: I'd rather temporarily hold budget than risk spending the same allowance twice.

Thanks for asking about this distinction!

Collapse
 
alexshev profile image
Alex Shev •

The disagreement between a language model and a typed payment boundary is a feature when the boundary explains why it refused. I’d return structured validation errors that map back to the user’s request, keep the amount and currency checks deterministic, and require an idempotency key before any retry. Helpful interpretation should never weaken the ledger’s invariants.

Collapse
 
lymah profile image
Lymah •

I agree with this. The typed boundary should do more than reject an invalid payment; the reason for rejection should be useful to the person making the request.

SpendGuard already keeps amount parsing and policy checks deterministic, and it records payment state before submitting a transaction to avoid treating an ambiguous network error as a definite failure.

Structured validation errors mapped back to the original request would make that boundary more understandable. I also like the idempotency-key point. It's an important safeguard against duplicate execution during retries, and worth making an explicit part of the payment lifecycle.

The key principle is that the LLM can help interpret intent, but it should never get to relax the rules governing execution. Thanks for the thoughtful feedback!