feat(intents): quote and lock a USDC purchase at creation - #810
Conversation
24bacc1 to
fe61b60
Compare
05f564c to
841c71b
Compare
A USDC intent has to convert the payment it receives back into storage bytes,
and the only rate that makes "pay the quote, receive the quote" true is the rate
the user was actually quoted at. No existing column carries it.
usd_rate_at_creation cannot: it is the pool's MARGINAL price, while the user pays
the executable quote, which is that price plus the pool swap fee, plus the price
impact of their own size, plus the quote margin. Converting a received payment at
the marginal rate hands all three back as free storage — 5-8% on a realistic
purchase — and grants more bytes than the pre-payment cap check was run against,
which quietly defeats that check.
quoted_token_amount is half of the rate: what was charged. This adds the other
half — what it was charged FOR:
quoted_ai3_shannons numeric(78,0) NULL
Stored as the pair rather than as a rate, because a rate here has to be a
rounded ratio. USDC carries 6 decimals against byte counts near 1e11, so
USDC-per-byte is ~0.0027 base units at 100 GiB — sub-unit, and only
representable as an integer once scaled, at which point it no longer
round-trips the quote. Two exact integers do:
bytes = token_amount * quoted_ai3_shannons
/ quoted_token_amount / shannons_per_byte
With token_amount = quoted_token_amount the ratio cancels and the result is the
requested size exactly, with no rounding in either direction. That exactness is
the point: rounding here is money.
This is deliberately not the quoted_bytes column removed in the previous commit.
That one was written by one path and read by none, and was shaped like a balance
it could never agree with. This one has a reader — the confirmation path — and is
a conversion factor rather than a balance claim.
Wired through the model, the row mapper, and BOTH statement column lists. The
UPDATE rewrites the full column list, so a column missing from it is silently
nulled on the first status transition — invisible until credits come out wrong,
since the intent still looks complete at creation. The existing update-spread
test now covers this column, and a new test asserts the pair still reproduces
the requested size after a full create/update/read cycle, which is the property
a float anywhere in the numeric mapping would break while both columns still
looked populated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getExecutableQuote returns four distinct error types and none had a status code
assigned, so every one of them would have reached the client as a generic 500.
The oracle draws those distinctions on purpose, and they mean genuinely different
things to whoever is buying. Two are our problem and retryable; two are about the
size that was asked for. Collapsing them sends a user to shrink a purchase that
was never the problem while an Ethereum outage goes unreported as an outage:
OracleUnavailableError 503 PRICE_ORACLE_UNAVAILABLE no trustworthy price
PriceDeviationError 503 PRICE_UNSTABLE pool not quotable now
QuoteTooLargeError 409 QUOTE_TOO_LARGE pool cannot fill it
InvalidQuoteAmountError 400 QUOTE_AMOUNT_INVALID unquotable amount
Adds the 503 the codebase did not have. ServiceUnavailableError is a distinct
class rather than a reused 500 because nothing is wrong with the request and the
condition is usually transient — the caller should retry, not rewrite.
QuoteTooLargeError is 409 rather than 400: the request is well-formed and would
have been valid for a smaller size or a deeper pool, which is a state conflict
rather than malformed input.
QuoteFailedError is one class parameterised by cause rather than four subclasses.
The mapping is a small table and reads far better as a table than as four
near-identical class bodies, and the response shape is identical across all four
regardless. It overrides handleResponse to emit { error: <CODE>, message },
matching what the intents controller already does for GOOGLE_ACCOUNT_REQUIRED and
CREDIT_CAP_EXCEEDED, so a client branches on `error` and can surface `message`
verbatim — and no call site can forget to attach the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intents could only be paid in native AI3. This adds the USDC path end to end: a
fixed price quoted and locked when the intent is created, and a confirmation that
converts the payment back to storage at exactly the rate that was quoted.
Creation. createIntent takes an options object instead of a second positional
parameter — two optional bigint/enum arguments in a row on a money path is the
shape where a transposed call site silently prices the wrong thing, and the
compiler would not catch the swap. paymentMethod defaults to AI3_NATIVE, so the
body-less POST the live frontend sends keeps its current behaviour exactly.
requestedBytes becomes required for USDC_ETH, because there is nothing to quote
without it. The check sits in createIntent next to the existing range checks
rather than in parseRequestedBytes: a caller reaching the use case directly must
be held to the same rule, and the parser's job ends at the wire shape.
The USDC path quotes the AI3 the purchase is worth (requestedBytes *
shannonsPerByte), not the byte count — the pool prices AI3. The margin goes on
the EXECUTABLE quote, not on the marginal value: the executable quote already
covers the swap fee and this size's own price impact, and the margin covers what
it cannot, which is drift over the 10-minute price lock. That reasoning already
lives in pricing.ts and is not re-derived here.
Order matters and is asserted: validate, then cap pre-check, then quote. An
Ethereum round trip is not spent on a purchase that can never be granted, and a
failed quote leaves no priceless PENDING row behind.
Confirmation. getIntentCredits gains a USDC branch that converts token_amount at
quoted_token_amount / quoted_ai3_shannons and then divides by shannons_per_byte
as before, multiplying before dividing so there are no intermediate floors. It
must never use usd_rate_at_creation, which is marginal spot — the two tests
pinning this assert that paying exactly the quoted amount grants exactly the
requested bytes, and that the marginal rate would over-credit by >6%.
Three smaller things the path needed:
- markIntentAsConfirmed can record a token amount. It goes on token_amount, not
paymentAmount: the latter is denominated in shannons, so putting USDC in it
would make every AI3-shaped read of the row silently wrong, starting with the
dust guard. It also now refuses a confirmation carrying neither amount, which
would otherwise surface as a 0-credit FAILED row to diagnose backwards from an
irreversible payment.
- onConfirmedIntent's "has a deposit" guard reads whichever column the asset
uses. It previously read paymentAmount unconditionally and would have rejected
every USDC intent. Its OVER_CAP log did the same and would have thrown on a
null.
- parsePaymentMethod rejects an unrecognised value rather than defaulting. A typo
('usdc', 'USDC_ETH') defaulting to AI3 would quote in the wrong asset and the
caller would only find out at payment time.
An incomplete USDC intent yields 0 credits rather than a guessed rate, which
routes it to the existing FAILED branch for admin review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…payment Review of the USDC path found a reachable state where a user's on-chain payment is kept, no credits are granted, and nothing lands in the admin queue to find it. payIntent(bytes32) on the AI3 receiver accepts ANY intent id with any non-zero msg.value, and the watcher reports every such event as `paymentAmount` regardless of what the intent expects. So an AI3 payment against a usdc_eth intent arrives at markIntentAsConfirmed as a well-formed call, and nothing upstream rejects it. It was then confirmed: status CONFIRMED, payment_amount set, token_amount NULL. onConfirmedIntent looks up the column the asset uses, finds nothing, and returned an error without writing a terminal status — so _checkConfirmedIntents re-ran it every 30 seconds indefinitely. Two consequences, both bad: - the payment is kept with no credits and no FAILED/OVER_CAP row for an admin - the idempotency guard now treats the intent as settled, so the user's REAL USDC payment is silently discarded when it arrives It is also griefable rather than merely accidental: the intent id is visible in the calldata of a pending Ethereum payIntentWithToken tx, and 1 wei of AI3 on Auto EVM is a different chain, so the race against Ethereum confirmations is easy to win. Three fixes, defence in depth: - markIntentAsConfirmed requires the amount to be denominated in the asset the intent was quoted in, and refuses the mismatch before writing. The intent stays PENDING and expires on its own schedule. The mispaid amount still needs manual resolution — the same position a payment to an unknown intent id is already in. Ordered after the idempotency guard, so re-delivery of an event for a settled intent stays a no-op rather than becoming an error the watcher retries. - onConfirmedIntent marks a confirmed-but-amountless intent FAILED instead of returning an error. Nothing writes that column after confirmation, so the state can never resolve itself; FAILED stops the loop and surfaces the row, matching how every other unresolvable confirmation here is handled. This also fixes the pre-existing AI3 version of the same infinite retry. - getIntentCredits returns 0 when shannonsPerByte is 0. BigInt division by zero throws, and that exception escapes onConfirmedIntent to abort the entire polling tick rather than just the one intent — reachable via CREDITS_PRICE_MULTIPLIER=0. Also corrects two comments that misdescribed their own code: the dust guard does not catch a missing tokenAmount (the guard above it fires first), and the migration's claim that column-less rows fall back to the AI3 formula is wrong — credit derivation routes purely on payment_method and does not fall back, which is why the down-migration strands in-flight paid USDC intents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pact The column's rationale still argued against the marginal price of a pool quote — swap fee, this size's own price impact, and the margin, 5-8% on a realistic purchase. #807 replaced that oracle with an average of realized fills, so the fee and impact now arrive inside the rate and the entire wedge is USD_QUOTE_MARGIN. The conclusion is unchanged and the column is still needed; only the size and source of the gap were wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on batch _checkConfirmedIntents checks `result.isErr()`, which only covers what onConfirmedIntent RETURNS. A thrown exception escapes the loop and is swallowed by the safeCallback on the interval, so every intent behind the thrower is abandoned — users who paid correctly, skipped, and skipped again on each tick for as long as the poison row stays in the batch, with nothing terminal written to explain it. The known way in is getIntentCredits dividing by a zero shannonsPerByte, which this branch already guards at the source. This is the other half: the guard fixes the cause we found, the catch bounds the blast radius of the ones we did not. An intent that cannot be processed should cost its own turn, never the queue's. The existing batch tests only ever returned errors, which were always handled; the new test throws mid-batch and asserts the intent AFTER the thrower still runs. It fails without the catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2401ce5 to
d081458
Compare
Refusing a mispaid confirmation is correct — confirming one strands the intent in the polling loop and makes the idempotency guard discard the user's real payment when it lands — but it settles nothing on chain. The transfer happened; the only open question is whose the money is, and a log line is not an answer anyone finds. The table takes both refusal cases: an asset mismatch, and a payment naming an intent id that matches no row. The second is the one with the least evidence anywhere else, since no intent row shows that money arrived at all. No foreign key to intents, because that second case has nothing to point at. ON CONFLICT DO NOTHING on (tx_hash, intent_id), because the watcher replays — reorgs re-emit events and the startup sweep re-runs every PENDING intent that carries a tx_hash — and an admin queue that grows on every restart is one nobody reads. Nothing writes to it yet; the next commit does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e refuse Addresses review of #810. Nothing settles a USDC intent yet: the payment manager subscribes to the AI3 receiver's IntentPaymentReceived only, and no watcher reads intentTokenPaymentReceivedAbi. A quote created today is a binding amount the backend cannot observe payment of, and the response does not even carry an address to send it to. So usdc_eth now sits behind payWithUsdc (PAY_WITH_USDC_ACTIVE), off by default, with admins exempt unconditionally — a switch that also locks out the people verifying the path cannot be turned on with any confidence. The gate is in the use case rather than the controller, since POST /intents is not the only door, and it refuses before any account read, balance read or chain round-trip. Also here: - A refused payment is recorded in intent_mispayments rather than only logged, with the tx hash threaded from the watcher: an amount and a sender describe a payment, but only the hash finds it again. Recording never throws — failing to file the paperwork must not change which error actually happened, and on the startup-sweep path a throw would abort the recovery of unrelated transactions. GET /intents/mispayments (admin) reads it, mirroring /intents/over-cap: that queue is for payments we accepted and could not convert, this one for payments we never accepted at all. - An intent at a zero per-byte price is refused at creation. Every payment against one converts to zero credits and lands in FAILED with the money kept, and on the USDC path the quote itself computes to 0 — a binding charge of "nothing" for a purchase that will never be granted. Guarded on both payment methods, since the downstream failure is identical. A 503 rather than a 4xx: the request was fine, the deployment is not. - QuoteFailedError extends ServiceUnavailableError instead of taking a status, so the 503 lives in the type rather than at each construction site. quoteErrorToHttpError narrows to OracleUnavailableError, which is what the Result type says it can receive; its default branch is for reasons added to the union later, not for some other error class arriving. - The API reference still described the retired quoter — quotedTokenAmount as carrying "the price impact of this purchase size", usdRateAtCreation as the pool's "marginal" price. Since #807 the rate is a VWAP of realized fills and the margin is the entire wedge. Same correction for the getIntentCredits docstring and the QuoteErrorCode comment, which still counted four causes of which two were about the requested size. - The stale-rate policy is written down: a last-good rate prices a binding quote deliberately, because the number is a days-long average and refusing through every subgraph blip would shut the path more than the drift justifies. The USDC amount is deliberately still not checked against quotedTokenAmount; see the PR description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6f33aef. Configure here.
…e-lock # Conflicts: # apps/backend/__tests__/unit/useCases/intents.spec.ts
|
The idea behind that PR was rather simple and it since has grown significantly, and now cover some of the edge-cases especially around payments with no intents. I've chatted with multiple agents about it and tried simplifying the design, but all highlighted that this bit is well worth handling. Curious to hear if you think differently, or prefer me to split them up in a few different PRs. |
jim-counter
left a comment
There was a problem hiding this comment.
After reading this through I was asking myself if there's a possibility we're over-complicating things a little here. The user asks to purchase a certain amount of storage when they create the intent. We could simplify the onward process to be:
- Convert storage amount to AI3
- Convert AI3 amount to USDC
- Accept USDC payment if it is at or above the amount quoted
- Reject USDC payment if it is below the amount quoted
This feels more natural to the way the user commits to the purchase. More detailed breakdown below. What do you think?
Design proposal: commit to the purchase, not to the rate
The observation
The USDC intent stores an effective rate and reconstructs the grant from it at settlement: getIntentCredits computes tokenAmount * quotedAi3Shannons / quotedTokenAmount / shannonsPerByte (intents.ts:822). The "pay the quote, receive the quote" property is not a stored fact, it is an algebraic accident of that expression: with tokenAmount == quotedTokenAmount the ratio cancels and you land back on requestedBytes. Any other amount is extrapolated linearly.
The product commitment is not a rate, though. It is a fixed price: the API contract advertises quotedTokenAmount as "the exact USDC amount to pay, locked until expiresAt". So the settlement model and the promise are different shapes.
Two things suggest the storage decision was made one level too low:
quoted_ai3_shannonsis strictly derivable. It is computed asrequestedBytes * shannonsPerByte(intents.ts:461), andshannonsPerByteis already on the row. It is a denormalisation ofrequested_bytes, not independent information.- The "no reader" argument is circular. intents.ts:438 declines to persist
requestedByteson the grounds that nothing downstream reads it. That is only true because the grant is derived from a rate. Under a fixed-price commitment,requested_bytesis the thing you grant, and it becomes the primary reader.
Proposal: persist requested_bytes instead of quoted_ai3_shannons. At settlement, if tokenAmount is within a configured tolerance of quotedTokenAmount, grant the stored requested_bytes. Outside the tolerance, refuse and file an intent_mispayments row.
Comparison
| Concern | As implemented | Proposed |
|---|---|---|
| What the intent commits to | An effective rate, held as the quotedTokenAmount : quotedAi3Shannons pair |
The purchase: requested_bytes for quotedTokenAmount |
| Column added by the migration | quoted_ai3_shannons numeric(78,0) |
requested_bytes numeric(78,0) |
| Settlement calculation | tokenAmount * quotedAi3Shannons / quotedTokenAmount / shannonsPerByte |
Tolerance check, then return stored requested_bytes |
| Exact payment | Exactly requestedBytes, by cancellation, pinned by a regression test |
Exactly requested_bytes, by construction |
| Underpayment | Silent proportional grant, intent COMPLETED, no signal the quote was missed | Outside tolerance: refused, filed for admin, intent stays PENDING |
| Overpayment | Proportional extra storage, past the amount the cap pre-check ran against | Within tolerance: excess kept as margin. Beyond: policy required |
| Precision reasoning required | 6 decimals against byte counts near 1e11, ceiling direction, division order, no intermediate floors | None at settlement. Only the creation-side quote rounds |
| "Wrong rate at settlement" bug class | Representable, and the reason this PR exists (usdRateAtCreation vs the effective rate) |
Not representable. No rate is applied at settlement |
| Cap pre-check | Indicative. The grant can exceed what was checked (intents.ts:199) | Exact for this path |
| USDC dust guard | Required. A conversion can floor to 0 credits, routing to FAILED | Not applicable. A stored positive byte count cannot convert to 0 |
| Corrupt-row surface | Three columns must all be present, or 0 credits and FAILED | One column |
| New tuneables | None | Payment tolerance in USDC base units, plus a minimum quote floor derived from it |
| AI3 path | Rate-based, any payment settles | Unchanged |
The trade, stated plainly
This is not a free simplification. The rate model's real virtue is that every payment converts to something, so no payment needs human resolution. The proposal gives that up: an out-of-band payment becomes an admin row and a user with no storage until someone acts.
So the trade is silent-wrong-amount for loud-no-grant. I think that is the right way round. A silent partial grant is neither detectable nor recoverable unless the user notices their balance looks short, whereas a recorded refusal is both, and intent_mispayments already exists for exactly the "money arrived, we would not attach it" case. But it should be an explicit decision, not a side effect.
It also moves complexity from arithmetic into policy. Policy is reviewable in a way that implicit arithmetic is not, which is part of the appeal, but the far-above-band case genuinely needs writing down, and does under the current design too.
Tolerance shape
An absolute amount in USDC base units, not a percentage, and asymmetric.
- USDC is not fee-on-transfer, and
payIntentWithToken(bytes32, uint256)records the amount actually transferred, so nothing mechanically shaves value in transit. The below-quote side of the band exists only to absorb user and UI error, which is an absolute-size problem rather than a proportional one. - A symmetric 1% band would also quietly reduce the 5%
USD_QUOTE_MARGINto about 4% on every payment landing under the quote. That may be acceptable, but it should be chosen rather than inherited from the band's shape. - Suggested rule: accept
tokenAmount >= quotedTokenAmount - tolerance, generous above up to whatever the refund threshold is.
Minimum quote floor
An absolute tolerance assumes the quote is large relative to it, and small purchases break that assumption. A 1-byte purchase quotes at roughly 2 base units after ai3ShannonsToUsdcBaseUnits ceilings to 1 and the margin rounds up to 2. A tolerance of 0.01 USDC is then five thousand times the price, and effectively any payment settles the intent for the full grant.
Refuse a usdc_eth intent at creation whose quotedTokenAmount falls below some multiple of the tolerance, and derive the floor from the tolerance rather than configuring both independently so the two cannot be tuned into contradiction. This subsumes the separate zero-quotedTokenAmount hole as a special case rather than adding a second guard for it.
Consequences for this PR
- The migration becomes a column swap, not an addition, which is why this is worth settling before it lands rather than after.
- Retired: the pair-of-integers reasoning, the exactness commentary, the over-credit regression test, the USDC branch of
getIntentCredits(intents.ts:810 to :826), and the separate zero-quote guard. - Unaffected: the feature flag and admin exemption, the oracle error mapping and its 503s, the asset-mismatch refusal and
intent_mispayments, the per-intent catch in_checkConfirmedIntents, and the zeroshannonsPerByteguard. - Still worth fixing independently, since neither depends on the settlement model: the stranded PENDING intent at intents.ts:727, and the mispayment dedup key.
- Adjacent hole, present under either model: a second payment against an already-settled intent is swallowed by the idempotency guard at intents.ts:690, which returns
ok(intent)before reaching the refusal and recording path. Pay-in-two-transfers today confirms on the first and files nothing for the second. The proposed model makes this more visible, since each below-band transfer is refused and recorded rather than the first silently confirming a partial grant.
The unique index was (tx_hash, intent_id), which assumes one payment per transaction per intent. Both receivers are callable from a contract, so one transaction can emit the payment event twice for a single intent id with different values, and watchTransaction calls markIntentAsConfirmed for every log it parses. The second refusal then collapsed into the first: the queue recorded one payment — whichever amount won the race — when two arrived. That understates the refund owed, in the one table whose whole purpose is that an irreversible transfer is never left with nothing but a log line pointing at it. Key on (tx_hash, log_index) instead. A log index is unique within a transaction, so intent_id is not needed in the key: a re-emitted event carries the same pair and still de-duplicates, while two distinct payments never share one. Doing this now is free and doing it later is a data migration, over rows that are by construction the only durable evidence of unresolved on-chain payments. The column is also added by an explicit ALTER, because CREATE TABLE IF NOT EXISTS no-ops on a database that applied an earlier version of this migration and would otherwise leave the column behind. Reported in review by @jim-counter on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proportional conversion handles an off-quote payment — the user receives storage worth exactly what they sent, at the rate they were quoted at — and that rule needs no human, which is why it stays. But handled is not the same as unremarked. The API advertises quotedTokenAmount as the exact amount to pay, locked until expiresAt, so a payment that differs from it means the quote was missed: a stale UI, a hand-built contract call, a wallet the user edited. Afterwards the intent holds both numbers and no reader compares them, so the only remaining signal was a balance the user has to notice looks short — neither detectable nor recoverable by us. A settlement whose amount is not the quoted amount now warns and files an AMOUNT_OFF_QUOTE row, then credits as before. USDC only: an AI3 intent is quoted no amount at all, so there is no promise for a payment to deviate from. Recorded rather than refused, deliberately. Refusing turns a self-resolving payment into an admin row and leaves a paying user with no storage, and the queue it would land in has no grant path out. reason is therefore also the field that says whether a row is a work item — the two refusal cases are, this one is not — and the table's docs, the migration comment and the admin endpoint all say so. Closes the detectability gap @jim-counter identified on #810 while keeping the rate-lock settlement model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry forever A tx_hash used to exempt a PENDING intent from expiry unconditionally, on the assumption that it means "actively being watched and will resolve". isIntentExpired returned false for any such row and getExpiredPendingIntents filtered them out, so cleanup never saw them. A refused payment is a standing counterexample, and so is a transaction that never confirms. Either leaves a row that can reach neither EXPIRED nor CONFIRMED: getIntent keeps serving it as live and payable indefinitely past the price lock it was quoted under, and the startup sweep hands it to watchTransaction again on every restart. Reachable today by paying a usdc_eth intent in AI3 and then calling POST /intents/:id/watch with that hash. The exemption is now time-bounded by INTENT_TX_GRACE_MINUTES (24h). Inside the window nothing changes and slow confirmations settle as before; past it the row is reclaimed. Chose the general form over clearing tx_hash on refusal, because it also covers a hash that never confirms at all, including an RPC outage that outlives the window. Expiring those rows is only safe alongside the second half of this change. The idempotency guard treated EXPIRED as "already processed" and returned ok, so a payment landing after expiry was silently discarded — a hole that already existed for any intent whose window passed before its payment confirmed, and one this sweep would have widened. A payment for an EXPIRED intent is now recorded as INTENT_EXPIRED. It still cannot be granted, since the quoted rate is gone, but it no longer vanishes. Reported in review by @jim-counter on #810, who also suggested preferring the general fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard returned ok() for any intent in a post-PENDING state, on the assumption
that such a call is always re-delivery of the payment that settled it. Paying an
intent twice broke that: the first transfer was credited and the second vanished
into "already processed — skipping". No credits, no admin row, no line saying
money had arrived. Two transfers is not exotic — a user who does not see the first
confirm pays the same quote again.
Re-delivery is now told apart from a distinct payment by three signals, since
none is sufficient alone:
• a differing tx hash proves a different transaction, but only for intents that
carry one;
• a differing amount proves a different payment outright and is available on
every row, but says nothing when the same amount is paid twice;
• an amount in the other asset cannot be the payment that settled the intent,
which had to be in the quoted asset to settle it.
Anything distinct is recorded as ALREADY_SETTLED. The intent is left exactly as it
was and the return stays ok() either way — it is settled and correct, and nothing
here is a failure the watcher should retry.
markIntentAsConfirmed also now writes the tx_hash that settled the intent. Only
POST /intents/:id/watch wrote that column, so an intent confirmed by the
contract-event watcher had no record of which transaction paid it, and the first
signal above had nothing to compare against.
Two logs of the same value inside one transaction still read as a replay. That
needs a contract-mediated double-pay in a single call, and both halves are
recorded while the intent is not yet settled.
Adjacent hole identified in review by @jim-counter on #810.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A confirmed payment converting to zero bytes — dust, or an intent missing one of its conversion inputs — is marked FAILED with the money kept. That is the right terminal state, but FAILED has no listing of its own, unlike OVER_CAP which has both an endpoint and a reprocess path. So the payment existed only as a log line, in the one area where a log line is explicitly not considered enough. It is now recorded as UNCONVERTIBLE_PAYMENT before the status write, so a failed update leaves the money on record rather than losing both. No log index is available on that path — it runs from the stored row, not the event — so the row cannot de-duplicate. It does not need to: reaching FAILED takes the intent out of getConfirmedIntents and nothing puts it back. Deliberately not extended to the amountless FAILED branch above it: there is no amount there to point at, markIntentAsConfirmed now refuses that case upstream, and the branch remains defence-in-depth for rows written before it did. This is the case @jim-counter noted the rate model has no answer for. Recording it is not that answer — a dust payment still buys nothing — but it puts the payment somewhere an admin can act on, which is the part that was missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for auto-drive-storage ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Thanks for your review Jim! I had a rather interesting conversion with two agents, and actually had two of them defend their own case for my and other agent judgement. Whats changes (below is a simplified version taken from Claude notes): Most of this is on the branch now:
Two of those need a word. Expiring And your detectability point is the one that landed. Silent was the problem, not On the column swap: not doing it since quoted_ai3_shannons Why I'd rather keep the rate model: you can't bounce a transfer, so every amount Happy to revisit at #748. If the payment amount ends up as immutable calldata |
…mn is a rate Two review comments on #810 that the code changes around them did not answer. The asset-mismatch refusal claimed the intent "expires on its own schedule", which was the comment @jim-counter flagged as untrue: a refused payment leaves tx_hash set, and a tx_hash used to exempt the row from expiry permanently. The behaviour is fixed, but the comment still described the wrong mechanism — it now says which sweep reclaims the row in each case, and that the grace is what bounds the tx_hash one. The quoted_ai3_shannons migration explained why the pair is stored without ever saying that the requested size is known at creation and deliberately not stored in its place, which is what made the fixed-price reading look like the obvious one. It now states that the two columns recover each other exactly, so the choice is about what settlement reads rather than what can be reconstructed, and points at the PR for the discussion. Comments only. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent is lost watchTransaction calls markIntentAsConfirmed once per parsed log inside a Promise.all, and markIntentAsConfirmed read the intent, decided, then wrote with an unconditional UPDATE by id. Two payments for the same intent in one transaction therefore both read PENDING before either wrote: the second overwrote the first, one amount was credited, the other was lost, and neither call could tell it had raced so nothing was filed. Reproduced before fixing — two writes land, the smaller amount disappears, zero mispayments recorded. The PENDING to CONFIRMED move is now a conditional UPDATE, the same shape expireIntentIfPending already uses against the same class of TOCTOU. Whichever call loses records the payment as ALREADY_SETTLED. That also closes the residual the previous commit documented: two logs of identical value in one transaction were indistinguishable to the tx-hash/amount/asset check, because they arrive while the intent is still PENDING and never reach it. The discriminator is no longer what the payment looks like, it is who won the transition — so no log index needs comparing and no column needs adding. The statement sets only the confirmation columns instead of rewriting the row from a snapshot. updateIntent names every column, so a stale read could null the quote columns that credits are derived from; this one cannot. The off-quote check moved after the transition, so a payment that lost the race is filed once as ALREADY_SETTLED rather than also as an off-quote settlement it never made. Found by Cursor Bugbot on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bsent A CONFIRMED intent with no received amount was marked FAILED and returned as success without writing anything to intent_mispayments. The zero-credit branch directly below it files for a reason that applies here identically: FAILED is terminal and has no listing of its own, so an intent that lands there is not something an admin finds. The previous commit argued this branch had no amount to point at and left it alone. That was the wrong call — there is less to go on, but the intent id and the transaction are enough to look up what arrived, and that beats a log line. The check is also now against undefined rather than falsy. A zero amount is a different thing from a missing one and belongs to the zero-credit branch, which records it with the amount attached instead of reporting it as absent. Not a reachable case — both receivers revert on a zero amount — but the two branches disagreed about what a zero meant, and a payment must not fall between them. Found by Cursor Bugbot on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
triggerWatchIntent read the intent through getIntent, then wrote the whole row back from that snapshot to record the hash. A confirmation landing in between was undone by it: status reverted to PENDING and payment_amount nulled, so a payment that had already been credited became uncredited and stayed that way until a restart's recovery sweep re-watched the row. Reproduced before fixing — the confirmation writes 500, the watch call puts the row back to PENDING with no amount. Same class as the confirmation race in 949ae6c, and the same shape of fix: a conditional UPDATE that touches only tx_hash, so there is no stale snapshot to write back and nothing to revert. The queue task is published only once the row is claimed, rather than for an intent that turned out to be settled already. Behaviour change worth noting: calling watch on an already-confirmed intent no longer records the caller's hash. That write only ever overwrote the hash of the transaction that actually paid, which the confirmation path now records itself. Found while verifying the Bugbot findings on #810, not reported by it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
bugbot run |
… be recorded 74d5657 made the hash write conditional and returned early when the claim failed, which also skipped publishing watch-intent-tx. Recording the hash and watching the transaction are separate concerns, and only the write was ever unsafe. Watching matters most in exactly the case the early return dropped it. A caller submitting a hash for an intent that is already settled is describing a second payment; one submitting it for an intent that just expired is describing a payment that arrived too late. markIntentAsConfirmed files both — ALREADY_SETTLED and INTENT_EXPIRED — and the publish was the only path by which either was ever seen. Skipping it lost the recording to avoid a stale write, which is a worse trade than the bug being fixed. The task is now published whether or not the row took the hash. Found by Cursor Bugbot on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed one 949ae6c filed every lost confirmation race as ALREADY_SETTLED. Another payment winning is the expected case but not the only one: expireIntentIfPending competes for the same PENDING status, so an expiry sweep firing in that window also makes the conditional UPDATE miss, with nothing credited. Labelling that ALREADY_SETTLED tells whoever works the queue there is a double payment to reconcile, when the money simply arrived too late. Different situation, different resolution, and the row was the only description of either. The reason now comes from reading the row back — which that path already did for its return value, and ignored when choosing what to file. EXPIRED files INTENT_EXPIRED; anything else stays ALREADY_SETTLED. Found by Cursor Bugbot on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… EVM Step #748 of epic #742. #810 quotes and locks a USDC purchase; nothing observed payment of one. This is the other half: a second watcher on Ethereum parsing IntentTokenPaymentReceived into the confirmation call #810 already accepts. The watcher is now a factory over a chain definition (RPC, receiver, confirmations, event ABI, and the mapping from one log to a payment) rather than a module bound to config.paymentManager. The AI3 chain is one instantiation of it, unchanged in behaviour. Three things did not port mechanically: * The confirmed-intent poller moved out of the watcher. It reads every CONFIRMED intent whatever asset paid for it, and onConfirmedIntent checks for COMPLETED and then writes with no lock between the two, so one loop per chain would have had both granting the same credits. One poller now, guarded against a second start. * The startup sweep is scoped to its own payment method. Handed a hash from the other chain, a viem client does not fail — it waits out its receipt timeout — so an unfiltered sweep would spend its startup window on rows it cannot resolve. payment_method is NOT NULL with an 'ai3_native' default, so legacy rows stay in the AI3 sweep. * watch-intent-tx carries the payment method. The task held only a hash, and a hash is the same 32 bytes on either chain, so the handler could not route it: a USDC intent's watch request went to Auto EVM. The field is optional on the wire and defaults to AI3, so tasks queued before this deployment still parse. A payment in an ERC20 the receiver was not deployed against is refused rather than credited: every conversion downstream assumes 6 decimals, so an 18-decimal token would read as a payment 10^12 times the one that arrived, against a quote denominated in dollars. fromAddress comes from the event's payer, not receipt.from — an ERC20 transfer can be relayed, and refunding the relayer pays back someone who sent nothing. ETH_USDC_RECEIVER_ADDRESS is the switch for the whole path, and makes ETH_CHAIN_ENDPOINT and USDC_TOKEN_ADDRESS required when set. An endpoint on its own stays harmless, because the treasury balance check (#811) reads through the same variable. The watcher is keyed on configuration and not on the payWithUsdc flag: turning quoting off must not turn observation off, or a payment in flight is stranded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rds it Rebasing onto #810 brought in 949ae6c, which fixed the two-payments-in-one- transaction race at the database layer: the PENDING -> CONFIRMED move is a conditional UPDATE, and whichever call loses it is filed as ALREADY_SETTLED. That makes the sequential loop in the previous commit not merely redundant but wrong. The new discriminator is who wins the transition, which only works while both calls are looking at a PENDING row. Serialised, the second payment arrives after the first has settled the intent and lands in the idempotency guard instead — where a matching hash, a matching amount and a matching asset read as re-delivery, and a real second transfer is absorbed with nothing filed. The base branch's comment says exactly this: that case "does not reach here" because both arrive while the intent is still PENDING. So the watcher maps over the logs concurrently again, with a comment saying why the concurrency is load-bearing rather than incidental, and the test asserts both calls are in flight before either is written rather than asserting the ordering it used to. Also adapts the USDC watch-task test to the claim-first write path from 74d5657: triggerWatchIntent now records the hash through setTxHashIfPending instead of updateIntent, and publishes only once the row is claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… EVM Step #748 of epic #742. #810 quotes and locks a USDC purchase; nothing observed payment of one. This is the other half: a second watcher on Ethereum parsing IntentTokenPaymentReceived into the confirmation call #810 already accepts. The watcher is now a factory over a chain definition (RPC, receiver, confirmations, event ABI, and the mapping from one log to a payment) rather than a module bound to config.paymentManager. The AI3 chain is one instantiation of it, unchanged in behaviour. Three things did not port mechanically: * The confirmed-intent poller moved out of the watcher. It reads every CONFIRMED intent whatever asset paid for it, and onConfirmedIntent checks for COMPLETED and then writes with no lock between the two, so one loop per chain would have had both granting the same credits. One poller now, guarded against a second start. * The startup sweep is scoped to its own payment method. Handed a hash from the other chain, a viem client does not fail — it waits out its receipt timeout — so an unfiltered sweep would spend its startup window on rows it cannot resolve. payment_method is NOT NULL with an 'ai3_native' default, so legacy rows stay in the AI3 sweep. * watch-intent-tx carries the payment method. The task held only a hash, and a hash is the same 32 bytes on either chain, so the handler could not route it: a USDC intent's watch request went to Auto EVM. The field is optional on the wire and defaults to AI3, so tasks queued before this deployment still parse. A payment in an ERC20 the receiver was not deployed against is refused rather than credited: every conversion downstream assumes 6 decimals, so an 18-decimal token would read as a payment 10^12 times the one that arrived, against a quote denominated in dollars. fromAddress comes from the event's payer, not receipt.from — an ERC20 transfer can be relayed, and refunding the relayer pays back someone who sent nothing. ETH_USDC_RECEIVER_ADDRESS is the switch for the whole path, and makes ETH_CHAIN_ENDPOINT and USDC_TOKEN_ADDRESS required when set. An endpoint on its own stays harmless, because the treasury balance check (#811) reads through the same variable. The watcher is keyed on configuration and not on the payWithUsdc flag: turning quoting off must not turn observation off, or a payment in flight is stranded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rds it Rebasing onto #810 brought in 949ae6c, which fixed the two-payments-in-one- transaction race at the database layer: the PENDING -> CONFIRMED move is a conditional UPDATE, and whichever call loses it is filed as ALREADY_SETTLED. That makes the sequential loop in the previous commit not merely redundant but wrong. The new discriminator is who wins the transition, which only works while both calls are looking at a PENDING row. Serialised, the second payment arrives after the first has settled the intent and lands in the idempotency guard instead — where a matching hash, a matching amount and a matching asset read as re-delivery, and a real second transfer is absorbed with nothing filed. The base branch's comment says exactly this: that case "does not reach here" because both arrive while the intent is still PENDING. So the watcher maps over the logs concurrently again, with a comment saying why the concurrency is load-bearing rather than incidental, and the test asserts both calls are in flight before either is written rather than asserting the ordering it used to. Also adapts the USDC watch-task test to the claim-first write path from 74d5657: triggerWatchIntent now records the hash through setTxHashIfPending instead of updateIntent, and publishes only once the row is claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 8f63b99. Configure here.
jim-counter
left a comment
There was a problem hiding this comment.
One small bug to address and I think we're good here.
…payment Every payment log reaches markIntentAsConfirmed several times over. paymentManager.start() runs in both the frontend API and the frontend worker, each with its own watchContractEvent over the same contract, and the queued watch-intent-tx task calls watchTransaction a third time. All of them arrive with the same intent id, hash, log index and amount, unserialised across processes. One wins the conditional transition to CONFIRMED; the rest lose it. Losing it was read as proof that a second payment had arrived, so each duplicate filed an already_settled mispayment — a reconciliation task naming money that arrived exactly once, raised on the most travelled path there is. The (tx_hash, log_index) dedup could not suppress it, because the winner files nothing for it to collide with. The loser now reads the row back and compares before naming a reason, which is the comparison the idempotency guard already made for the sequential case. Whether a duplicate meets the guard or races the transition is a matter of scheduling rather than of what happened on chain, so both paths now ask isRedeliveryOfSettlingPayment and get the same answer. Comparing hash and amount alone would have been the wrong answer for two payments of the same value inside one transaction — a shape a contract can produce, since both receivers are callable from one — and the transition was the only thing separating those. So the settling log index is now stored alongside the settling hash, and the comparison uses the pair: a differing index always means a different log, and so a different payment. An intent settled before the column existed compares on hash and amount, as it did before. Reported by @jim-counter on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…payment Every payment log reaches markIntentAsConfirmed several times over. paymentManager.start() runs in both the frontend API and the frontend worker, each with its own watchContractEvent over the same contract, and the queued watch-intent-tx task calls watchTransaction a third time. All of them arrive with the same intent id, hash and amount, unserialised across processes. One wins the conditional transition to CONFIRMED; the rest lose it. Losing it was read as proof that a second payment had arrived, so each duplicate filed an already_settled mispayment — a reconciliation task naming money that arrived exactly once, raised on the most travelled path there is. The (tx_hash, log_index) dedup could not suppress it, because the winner files nothing for it to collide with. The loser now reads the row back and compares before naming a reason, which is the comparison the idempotency guard already made for the sequential case. Whether a duplicate meets the guard or races the transition is a matter of scheduling rather than of what happened on chain, so both paths call isRedeliveryOfSettlingPayment and get the same answer. One case is knowingly given up: two payments of the same value inside a single transaction match on asset, amount and hash, so the second is now read as re-delivery and absorbed. Separating it would mean persisting the settling log index on the intent, and the shape needs a caller invoking the receiver twice for one intent id at one amount — constructible, since the receivers are callable from a contract, but produced by no ordinary flow. The one-transaction test now uses differing amounts, which is what makes a second payment provable without it. Reported by @jim-counter on #810. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aa83588 to
4ef25bb
Compare
… EVM Step #748 of epic #742. #810 quotes and locks a USDC purchase; nothing observed payment of one. This is the other half: a second watcher on Ethereum parsing IntentTokenPaymentReceived into the confirmation call #810 already accepts. The watcher is now a factory over a chain definition (RPC, receiver, confirmations, event ABI, and the mapping from one log to a payment) rather than a module bound to config.paymentManager. The AI3 chain is one instantiation of it, unchanged in behaviour. Three things did not port mechanically: * The confirmed-intent poller moved out of the watcher. It reads every CONFIRMED intent whatever asset paid for it, and onConfirmedIntent checks for COMPLETED and then writes with no lock between the two, so one loop per chain would have had both granting the same credits. One poller now, guarded against a second start. * The startup sweep is scoped to its own payment method. Handed a hash from the other chain, a viem client does not fail — it waits out its receipt timeout — so an unfiltered sweep would spend its startup window on rows it cannot resolve. payment_method is NOT NULL with an 'ai3_native' default, so legacy rows stay in the AI3 sweep. * watch-intent-tx carries the payment method. The task held only a hash, and a hash is the same 32 bytes on either chain, so the handler could not route it: a USDC intent's watch request went to Auto EVM. The field is optional on the wire and defaults to AI3, so tasks queued before this deployment still parse. A payment in an ERC20 the receiver was not deployed against is refused rather than credited: every conversion downstream assumes 6 decimals, so an 18-decimal token would read as a payment 10^12 times the one that arrived, against a quote denominated in dollars. fromAddress comes from the event's payer, not receipt.from — an ERC20 transfer can be relayed, and refunding the relayer pays back someone who sent nothing. ETH_USDC_RECEIVER_ADDRESS is the switch for the whole path, and makes ETH_CHAIN_ENDPOINT and USDC_TOKEN_ADDRESS required when set. An endpoint on its own stays harmless, because the treasury balance check (#811) reads through the same variable. The watcher is keyed on configuration and not on the payWithUsdc flag: turning quoting off must not turn observation off, or a payment in flight is stranded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rds it Rebasing onto #810 brought in 949ae6c, which fixed the two-payments-in-one- transaction race at the database layer: the PENDING -> CONFIRMED move is a conditional UPDATE, and whichever call loses it is filed as ALREADY_SETTLED. That makes the sequential loop in the previous commit not merely redundant but wrong. The new discriminator is who wins the transition, which only works while both calls are looking at a PENDING row. Serialised, the second payment arrives after the first has settled the intent and lands in the idempotency guard instead — where a matching hash, a matching amount and a matching asset read as re-delivery, and a real second transfer is absorbed with nothing filed. The base branch's comment says exactly this: that case "does not reach here" because both arrive while the intent is still PENDING. So the watcher maps over the logs concurrently again, with a comment saying why the concurrency is load-bearing rather than incidental, and the test asserts both calls are in flight before either is written rather than asserting the ordering it used to. Also adapts the USDC watch-task test to the claim-first write path from 74d5657: triggerWatchIntent now records the hash through setTxHashIfPending instead of updateIntent, and publishes only once the row is claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 410 from the intent endpoints was treated as final, and it is not. The backend returns it from `isIntentExpired`, which for a PENDING row with no recorded tx_hash is nothing more than `expires_at < now`. Credits are withheld by a different check entirely — `markIntentAsConfirmed` reading the status COLUMN as EXPIRED, which only `cleanupExpiredIntents` writes, on CREDIT_EXPIRY_CHECK_INTERVAL: one hour by default. Six confirmations take ~72s. So a payment sent just after the lock lapsed is normally credited in full, and the hash registration that just failed is not needed for it: the receiver's event subscription finds the intent in the deposit event itself. Two consequences, both of them user-visible on a purchase that succeeded. The registration 410 raised a red "credits will not be applied" the moment the payment went out — with certainty, in every late-signature case, including the ones credited a minute later. And `isExpired` in useTransactionConfirmation was a one-way latch that also gated its own poll effect, so if the first backend read landed before the watcher's write, the client stopped polling forever on a row that settled seconds afterwards: Continue disabled, "contact support", on granted credits. The 410 is now a caution that the loop polls through, and terminal only once it has persisted past a grace — two minutes, four turns of the credit-grant poller (EVM_CHAIN_CHECK_INTERVAL, 30s), measured from the first 410 rather than reset by each one. A successful read withdraws it, which also settles the contradiction of an amber notice standing next to an enabled Continue. The AI3 step gets the same correction from the shared hook; the window is reachable there too. The decision moves to utils/intentPolling so it can be tested against the code that runs. The old spec re-declared these functions locally and asserted the copy, which is why it stayed green while the behaviour it documented was wrong. Not changed: the backend's post-lock refusal policy, and the 410 that collapses "lapsed, still settling" with "swept, mispayment filed" into one status. The honest fix is a discriminating code from `isIntentExpired`, which knows which branch it took — #810's territory, not this PR's. Reported by Cursor Bugbot on #821. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
USDC is quoted (autonomys#810) and watched (autonomys#816), and nothing bounds how much un-hedged USDC the treasury can accumulate or lets anyone stop selling it without a redeploy. Step autonomys#811 of epic autonomys#742. Two gates, both DB-backed, plus the switch's audit trail: - a manual kill switch an admin flips from the dashboard, latching (no automatic process ever reopens it), attributed and timestamped - a balance gate that closes itself at USDC_TREASURY_PAUSE_THRESHOLD and reopens below USDC_TREASURY_RESUME_THRESHOLD, driven by a poller that runs beside the payment watchers Persisted rather than cached in memory because the process that polls the balance (the payment worker) is never the process that quotes (the API replicas): an in-memory gate reads "unknown" in every process that matters, fails closed, and USDC never sells in the topology production runs. One writer, N readers, one durable answer. Availability is a different question from audience: the payWithUsdc flag decides who may pay in USDC and exempts admins, these gates decide whether the deployment sells it at all and exempt nobody — a kill switch an admin walks through is not a kill switch. Gates creation only. Confirming, crediting and re-processing a paid intent are untouched, and intents quoted while the gates were open stay payable for the rest of their lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #750. The last step of the #742 epic on the buyer's side: a credit purchase can now be paid in USDC on Ethereum instead of AI3 on Auto EVM. ## What a buyer sees Step 2 grows a payment-method selector, but only when the deployment is actually selling USDC. `useUsdcAvailability` reads `payWithUsdc` from `/features` — which the backend has already narrowed to audience AND live availability — so a user sitting on the page is not offered a method that closed twenty minutes ago. When USDC is off the selector renders nothing rather than a disabled second button: a dead button is a promise the deployment cannot keep. Step 3 dispatches by asset into one of two panels rather than branching inside one. Paying in AI3 is a single native-value call on the connected chain; paying in USDC is a chain switch, an ERC20 approval and a contract call on another chain with a price lock ticking through all three. Interleaving them would route every AI3 purchase — which is every purchase today — through code written for the other one. The USDC panel runs quote → confirm → switch → approve → pay as a visible checklist, then reuses the existing confirmation/polling UX so both flows end identically. ## Three things that protect the money **The payment target is served, not compiled in.** `GET /payments/usdc/target` returns `{ chainId, receiverAddress, tokenAddress, tokenDecimals, confirmations, settleGraceMs }`. A deliberate deviation from #750, which asked for the receiver as a `contracts.ts` constant: the AI3 path can hard-code its receiver because the Auto EVM chain IS the Auto Drive network, while USDC has no such coupling — the chain is whatever `ETH_CHAIN_ENDPOINT` points at and the receiver is whatever `ETH_USDC_RECEIVER_ADDRESS` names. A build constant would have to agree with a runtime variable by convention, on a money path, with no mechanism to detect disagreement. The argument lives once, in `docs/payments.md`. The endpoint is not gated on the kill switch or the treasury cap — `/features` answers "is it open", this answers "where does it go", and a client mid-flow holding a quoted intent still needs somewhere to pay it. **`ETH_CHAIN_ID` is checked and acted on.** Every process that can quote or serve a target reads the endpoint's own chain id at startup (`usdcChainGuard`) and, on a verified mismatch, shuts the USDC path: `/features` reports the new `chain_mismatch`, `createIntent` 403s, and the target endpoint stops answering. Detecting a misdirection and continuing to sell into it is not a fix. A read that FAILS changes nothing — an endpoint down at boot is an outage, not a misconfiguration. **Nothing is paid against a lapsed quote.** `useUsdcPurchase` checks the lock on entry to `pay()` and again immediately before the payment call, with a 45s margin, because the approval can take minutes and a payment landing after expiry is refused and filed as a mispayment. The panel also watches the countdown it already renders and turns Pay into "Get a fresh price" the second the figure stops being payable, so that refusal is not discovered by way of a wallet prompt. One intent per attempt, and `approve` is skipped when the standing allowance already covers the amount. ## Smaller decisions - A review gate before any signature. `quote()` stops at a live quote and touches no wallet; `pay()` is a second click, its button labelled with the exact amount. One click that quoted AND signed showed the figure and the wallet prompt in the same instant, which is not a review. - Quoting obeys the availability gates; paying an intent already granted does not. Nothing on the settlement path reads those gates — the receiver's event subscription credits from the deposit event and `markIntentAsConfirmed` refuses only on the status column — so a PENDING intent is payable for the rest of its lock however the gates move. Requiring availability on both disabled Pay on a live lock whenever `/features` refetched on window focus, which the wallet's own popup causes. The decision is in `utils/usdcActions`, tested, alongside the chain-support condition it must not smuggle in. - A reload no longer loses a payment in flight. Intent id and tx hash live in `sessionStorage` for the life of the attempt, keyed to the purchase size, and are dropped once the purchase has an answer. Previously a refresh during the ~72s of Ethereum confirmations left a buyer with a debited wallet and a wizard back at step one. - The intent's tx hash is registered as soon as it exists, not after it confirms, so the intent falls under `INTENT_TX_GRACE_MINUTES`. - A 410 from the intent endpoints is a caution, not a verdict: it means `expires_at` has passed on a row that may still be settling, while credits are withheld only once `cleanupExpiredIntents` writes the EXPIRED column — hourly by default. The polling loop warns and keeps going, terminal only once the 410 persists past a served grace (`settleGraceMs`, four turns of the credit-granting poller). The decision table is in `utils/intentPolling`. - `useTransactionConfirmation` takes an optional `chainId` and pins receipt and block watching to it. Its `stopRef` latch — which froze the confirmation counter permanently on any dependency change — is cleared per run; that was reachable on the AI3 path too. - `ApiError` carries the backend's `code`, parsed by one shared `toApiError` so every endpoint surfaces it the same way. `UsdcUnavailableError` (503) exists because a bare `ServiceUnavailableError` serialises without a `message` key; the four identical `handleResponse` overrides that shape had grown into are now one decision on `HttpError`. - Approvals are for the exact amount owed, never unlimited. `erc20ApprovalAbi` carries only `approve`/`allowance`/`balanceOf`. - Wallet rejections are unwrapped out of wagmi's nested `ContractFunctionExecutionError` and rendered as a choice, not a crash — and they keep the quote, so the next click is Pay rather than a fresh price. - Closed-gate reasons stay on the admin dashboard and in the logs; a buyer gets "pay in AI3". - Sepolia is opt-in (`NEXT_PUBLIC_USDC_TESTNET_CHAINS`). Every chain a build lists is registered with every user's wallet, so a production build should not advertise a testnet. - `packages/ui` now declares the `viem` it imports at runtime, and takes chain definitions from `viem/chains` rather than `wagmi/chains` (a re-export of it). - `make test` runs the frontend suite. It did not, which meant every frontend spec in the repo — including these — passed or failed without gating a merge. ## Verified - backend jest — 64 suites, 1163 tests pass - frontend jest — 14 suites, 250 tests pass - `next build` exit 0; `tsc --noEmit` clean on frontend and backend - `yarn backend lint` / `yarn auth lint` / `next lint` clean in new code ## Known gaps, deliberately left - The 410 from `isIntentExpired` still collapses "lapsed, still settling" with "swept, mispayment filed" into one status. The client polls through it for a served grace instead; the honest fix is a discriminating code from the backend, which is #810's territory. - `NEXT_PUBLIC_ETH_RPC_URL` / `NEXT_PUBLIC_ETH_SEPOLIA_RPC_URL` are optional but inlined at build time. Unset, the flow reads balances and allowances through viem's shared public RPCs, where a rate-limited read surfaces as a failed purchase for a funded wallet. Set them in the deploy environment before the build. - The chain-guard verdict is per-process and asked once at startup. The first purchase after a boot can be quoted before the check resolves — one RPC round-trip wide, and it closes permanently. Failing closed until the check completes would make a slow endpoint indistinguishable from a wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prices a USDC intent at creation and locks the rate the payment converts back at. Step #747 of epic #742. Stacked on #808 — review that first; this diff is only the commits on top.
Off by default.
usdc_ethsits behindpayWithUsdc(PAY_WITH_USDC_ACTIVE), with admins exempt. Nothing settles a USDC intent yet — the payment manager subscribes to the AI3 receiver only, and no watcher readsintentTokenPaymentReceivedAbi— so a quote created today would be a binding amount the backend cannot observe payment of, returned without an address to send it to. Merging this lands the pricing half; opening the flag waits on #748.The problem
An AI3 intent stores one number and credits are
payment_amount / shannons_per_byte. That works because the user pays in the asset the price is denominated in.USDC does not. The user pays dollars, and converting back to bytes needs the AI3/USD rate — but the rate they were charged at is not stored anywhere.
usd_rate_at_creationis the raw oracle rate, and the user pays that plusUSD_QUOTE_MARGIN. Convert a received payment at the raw rate and the margin comes back as free storage on every purchase — the whole margin, exactly, since a rate is a scalar so the error scales with the amount. It also grants more bytes than #808's cap pre-check was run against.quoted_ai3_shannons, and why a rate column would not doquoted_token_amountis what the user was charged. This new column is what they were charged for. The pair is the effective rate.It is a pair rather than a single rate column because a rate here cannot be stored exactly. USDC carries 6 decimals against byte counts near 1e11, so USDC-per-byte is ~0.0027 base units — deeply sub-unit, and once scaled to survive as an integer it is a rounded ratio that no longer round-trips the quote. Two exact integers do:
Pay exactly what was quoted and the ratio cancels: the user receives exactly the size they asked for, with no rounding in either direction. Overpay and it scales proportionally at the same effective rate.
Worked example — at 1 USDC = 1 AI3 = 1 GB with a 5% margin, a 1 GB purchase is quoted at 1.05 USDC and grants 1 GB, not 1.05 GB. The margin is part of the price, not a payment for extra storage; it exists to cover FX drift until an operator converts the batch, and handing it back as bytes would make it cover nothing. Both behaviours are pinned by tests — one asserting the exact grant, one asserting that the raw-rate conversion over-credits.
Also in the quoting path
paymentMethodonPOST /intents, defaulting to AI3 for body-less requests. An unrecognised value is rejected rather than defaulted: silently treating'usdc'as AI3 would quote in the wrong asset and the caller would find out at payment time.PRICE_UNSTABLEdistinguished fromPRICE_ORACLE_UNAVAILABLE. There is deliberately no size-related code — see the rebase note.Rebased onto the subgraph-VWAP oracle
This branch was written against the oracle #807 replaced. Three things had to change, and none was mechanical:
getExecutableQuote(ai3)— priced a specific sizegetPrice()— one size-independent rate, applied by multiplication409 QUOTE_TOO_LARGE/400 QUOTE_AMOUNT_INVALIDThe swap fee and price impact did not disappear — they moved inside the rate, which averages realized fills, and those fills paid both. What no longer exists is this purchase's own impact, because the treasury does not swap per intent.
A payment bug found in review
payIntent(bytes32)on the AI3 receiver takes any intent id from anyone — no registry, no allowlist, just a non-zero value check — and the watcher reports it aspaymentAmountregardless of what the intent expects. So an AI3 payment against a USDC intent arrived as a well-formed confirmation.The row went CONFIRMED with
token_amountNULL;onConfirmedIntentlooked for the USDC column, found nothing, and returned an error without writing a terminal status — so the 30-second poller retried it forever. Payment kept, no credits, no admin row. Worse, the idempotency guard then treated the intent as settled, so the user's real USDC payment would be silently discarded on arrival.Griefable rather than merely accidental: the intent id is visible in the calldata of a pending Ethereum tx, and racing Auto EVM against Ethereum confirmations is easy.
Fixes, defence in depth:
markIntentAsConfirmedrefuses an asset mismatch before writing. The intent stays PENDING and expires on its own schedule. Ordered after the idempotency guard, so re-delivery for a settled intent stays a no-op.getIntentCreditsreturns 0 whenshannonsPerByteis 0 — BigInt division by zero throws._checkConfirmedIntentscatches per intent. It checkedresult.isErr(), which only covers whatonConfirmedIntentreturns; a thrown exception escaped the loop and was swallowed by thesafeCallbackon the interval, abandoning every intent queued behind it — from users who paid correctly, on every tick, with nothing written to explain why. The guard fixes the cause we found; the catch bounds the blast radius of the ones we did not. The existing batch tests only ever returned errors, which were always handled; the new one throws mid-batch and asserts the intent after the thrower still runs.Refusing is not resolving:
intent_mispaymentsRefusing a mispayment is correct, but it settles nothing on chain — the transfer happened, and a log line is not something an admin queries. Refused payments are now recorded, with the tx hash threaded from the watcher: an amount and a sender describe a payment, but only the hash finds it again.
GET /intents/mispayments(admin) reads it, mirroring/intents/over-cap— that queue is for payments we accepted and could not convert, this one for payments we never accepted at all.Two cases, and they are not equally new:
ASSET_MISMATCH— the intent exists but is denominated in the other asset. New with USDC.UNKNOWN_INTENT— the id matches no row. This is reachable onmaintoday, by anyone with a wallet, and until now produced only a log line. It is also the case with the least evidence anywhere else, since no intent row shows the money arrived at all.Design notes: no foreign key to
intents, because the unknown-intent case has nothing to point at — a FK would make the table incapable of recording precisely the case that needs it most.ON CONFLICT DO NOTHINGon(tx_hash, intent_id), because the watcher replays: reorgs re-emit events and the startup sweep re-runs every PENDING intent carrying a tx hash, so without it one mispayment becomes a fresh row per restart. Recording never throws — failing to file the paperwork must not change which error actually happened, and on the startup-sweep path a throw would abort the recovery of unrelated transactions.Down-migration is destructive
Credit derivation routes purely on
payment_methodand does not fall back to the AI3 formula, so ausdc_ethrow withoutquoted_ai3_shannonsyields 0 credits and is marked FAILED. Running the down-migration while paid USDC intents are in flight strands them. Drain or settle first.Verification
yarn models build && yarn backend buildyarn backend lintyarn frontend lint+tsc --noEmit🤖 Generated with Claude Code