Agent-First Pay v0.11.0: Not Knowing Is Not Failing

by Agent-First Kit Contributors

Every send that errored was treated as a send that never happened: the plan released, the idempotency key cleared, the next retry free to pay again. A lost reply is not a failed payment. afpay now has a third outcome — unknown — and keeps the key held until someone checks. Plus endpoint failover that could pay twice, fees nobody quoted, a 200 that was not a receipt, and backup passwords in the process table.

A payment has three outcomes, not two. It went through, it did not, or afpay does not know. Most of this release is about that third one. afpay used to fold it into “did not”. For a tool that moves money, that is the one direction of error that costs twice.

A lost reply is not a failed payment

Every send goes through the spend ledger. A plan reserves budget, an idempotency key goes Pending, and the provider is called. Before this release, any error from that call was treated as nothing having happened. The reservations were cancelled, the plan was released, and the key was cleared.

That holds while the error means not submitted. It does not hold when the provider paid and only the answer was lost: a timeout after the request left, a 200 whose body would not parse, a detail lookup that failed after the payment settled. Then the cleared key is the bug. The caller retries with the same plan and the same key, which is exactly what idempotency is for. Nothing stops the retry, so it pays a second time.

There is an unknown outcome now, and it is the default for any error once a submission may have begun. Before calling the provider, the ledger marks the reservations and the Pending key as never expiring. If the outcome is unknown, the plan is consumed, the reservations stay held, and the key stays Pending. The same key then answers idempotency_in_progress. It does not replay a result, and it does not pay again.

Only a failure afpay can prove happened before signing still releases everything for a retry: a chain sync, a fee estimate, building the transaction. That failure is a retryable network_error. Everything after that point is unknown until a person or an agent checks the provider or the chain.

This reached every network, and each one had its own way of turning “unknown” into “failed”:

An unknown submission surfaces as accounting_inconsistent, and its transaction_id is now optional, because sometimes there is none to give. The skill keeps it apart from the other case that code covers, a known payment whose ledger confirmation failed. Agents are told to stop polling a Pending key and go look at provider or chain history. limit reconcile repairs the budget only after someone has checked whether funds moved. It never clears the key.

A payment can also succeed while its replay receipt fails to save. That is now sent with idempotency_finalize_failed: true, not an error. The payment is real, and a retry must not repeat it.

Failover that could pay twice

Solana and EVM sends try several RPC endpoints. When sendTransaction failed on one, afpay moved to the next and signed again. A fresh signature means a fresh blockhash or nonce, and so a second, distinct payment. If the first endpoint had in fact relayed the transaction, both landed.

Now a send signs once, then broadcasts the same bytes to each endpoint in order. Identical bytes cannot pay twice, so failover is safe again. The result is unknown only if no endpoint confirms. An unknown EVM result carries the transaction hash computed before broadcast, so there is something to look up.

Fees nobody quoted

Quotes exist so that the confirmation and the spend limit see the real fee. Three paths quietly replaced the real number with a made-up one:

Now each chain source is asked for a six-block estimate at plan time and again at confirm: Esplora, Core’s estimate_smart_fee, or Electrum’s estimate_fee. If there is no estimate, the payment fails before signing. An estimate above 1000 sat/vB is refused the same way. That ceiling is not a fee policy. It is a guard against a faulty or hijacked source naming whatever rate it likes. EVM quotes now fail with network_error and write no plan, rather than price gas at nothing.

Exchange rates got the same treatment. A price that is zero, negative or not finite is rejected and never cached, because the USD limit is computed from it.

A 200 is not a receipt

phoenixd could answer a payment with 200, a reason field and no preimage. The response struct ignored reason, so afpay reported sent at the invoice amount. LNbits and phoenixd could also return 200 with neither a payment hash nor a preimage. Both cases are unknown now. A success status whose body does not identify the payment proves nothing about the payment.

The wallet that started over

If the on-chain wallet’s bdk_changeset.json could not be read, afpay quietly started a fresh wallet. A later persist could then write that empty state over the original file. Now an unreadable changeset is an error that says to use restore, and the file is written atomically.

Signing moved too. bdk 3.2 deprecated using the Wallet as a key store, so afpay derives its own signers from the same mnemonic and hands them over at sign time. Behaviour is the same by construction. What changed is coverage: a new test funds a wallet in memory and runs build → sign → finalize → extract for taproot and segwit. The real on-chain tests need a funded signet wallet and are ignored, so before this, nothing would have noticed a signature that stopped finalizing.

Passwords in the process table

With the PostgreSQL backend, backup and restore started pg_dump and psql with the full connection URL as an argument. The password — whether in the userinfo or a ?password= parameter — was then readable by anyone running ps on the machine. Now it is taken out of the URL and passed in PGPASSWORD. A URL carrying sslpassword is refused, since there is no way to pass that one off the command line.

Restore also runs with ON_ERROR_STOP. Before, a SQL error partway through could still exit 0, so a partial restore reported success.

Smaller things

Breaking changes

No command or flag was added or removed.

Getting it

$ brew install agentfirstkit/tap/afpay
$ cargo install agent-first-pay