Agent-First Pay v0.11.0: Not Knowing Is Not Failing
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”:
- Cashu. If the balance lookup after minting the token failed, the send
became a
network_error, and a retry minted a second token. The fee is optional now and the token is kept. - Lightning. A retryable backend error mid-payment is unknown and carries the invoice’s payment hash. LNbits and NWC keep the receipt when the follow-up lookup fails after a payment succeeded; they used to fail the send.
- On-chain Bitcoin. A broadcast or wallet-persist failure after signing is unknown and carries the txid.
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:
- EVM. A failed gas RPC priced the fee at 0. A gas value that was not valid hex became 21000 gas at price 0. Both under-counted the spend limit.
- Bitcoin quote. When the fee calculation failed, the quoted fee became 0.
- Bitcoin send. The transaction builder was never given a fee rate at all.
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
- Rejected invocations always go to stderr and never create the
--stdout-filesink. An output setup failure exits 1, not 2. - In pipe mode, shutdown aborts any dispatch still in flight once its deadline passes, instead of leaving it running.
- Plan files are written atomically with mode 0600, including when the store is PostgreSQL.
protocis no longer required anywhere. It came with gRPC, which left in v0.10.0.- The Cashu wallet store moves to cdk 0.18. Existing stores are migrated forward when first opened, and the migration is one-way: v0.10.0 cannot open a store that v0.11.0 has touched.
Breaking changes
- An unknown submission keeps its key Pending. Retrying it answers
idempotency_in_progress, not a second payment. Check provider or chain history;limit reconcilerepairs the budget, not the key. - Only pre-signing
network_errorreleases a plan for retry. Any other retryable error during a send is unknown. accounting_inconsistentalso means “unknown submission”, and itstransaction_idis optional, in CLI output and in RESTerror.details.- Lightning and Bitcoin can return unknown where they used to return
sentornetwork_error. - Bitcoin fees come from the chain source, capped at 1000 sat/vB. If there is no estimate, the send fails.
- EVM quotes fail instead of pricing gas at 0.
- An unreadable BDK changeset is an error, not a fresh wallet.
- PostgreSQL backup and restore accept only
postgres://andpostgresql://URLs, refusesslpassword, and stop at the first SQL error. - An output setup failure exits 1 instead of 2.
- The Cashu store migration is one-way.
- New optional field
idempotency_finalize_failedonsentandcashu_sent.
No command or flag was added or removed.
Getting it
$ brew install agentfirstkit/tap/afpay
$ cargo install agent-first-pay