Skip to content

feat(intents): quote and lock a USDC purchase at creation - #810

Merged
EmilFattakhov merged 22 commits into
mainfrom
feat/intent-usdc-quote-lock
Sep 8, 2026
Merged

EmilFattakhov merged 22 commits into
mainfrom
feat/intent-usdc-quote-lock

Conversation

@EmilFattakhov

@EmilFattakhov EmilFattakhov commented Aug 7, 2026

Copy link
Copy Markdown
Member

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_eth sits behind payWithUsdc (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 reads intentTokenPaymentReceivedAbi — 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_creation is the raw oracle rate, and the user pays that plus USD_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 do

quoted_token_amount is 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:

bytes = token_amount * quoted_ai3_shannons / quoted_token_amount / shannons_per_byte

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

  • paymentMethod on POST /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.
  • Ordering: flag, then validation, then cap pre-check, then the rate. A purchase that cannot be granted never costs a subgraph round-trip, and a failed quote writes no row.
  • Oracle failures are 503s, PRICE_UNSTABLE distinguished from PRICE_ORACLE_UNAVAILABLE. There is deliberately no size-related code — see the rebase note.
  • A last-good (stale) rate still prices a binding quote. Deliberate: the number is a days-long average, and refusing through every subgraph blip would shut the path further than the drift justifies.
  • An intent at a zero per-byte price is refused at creation, on both payment methods. Every payment against one converts to zero credits and lands in FAILED with the money kept; on the USDC path the quote itself computes to 0 — a binding charge of "nothing" for a purchase that will never be granted. A 503, not a 4xx: the request was fine, the deployment is not.

Rebased onto the subgraph-VWAP oracle

This branch was written against the oracle #807 replaced. Three things had to change, and none was mechanical:

Written against Now
getExecutableQuote(ai3) — priced a specific size getPrice() — one size-independent rate, applied by multiplication
409 QUOTE_TOO_LARGE / 400 QUOTE_AMOUNT_INVALID deleted — no oracle failure is about the requested size, so no 4xx can tell the user to ask for less
over-credit test asserting >600bps (fee + impact + margin) asserts against the configured margin, now the entire wedge

The 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 as paymentAmount regardless 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_amount NULL; onConfirmedIntent looked 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:

  1. markIntentAsConfirmed refuses 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.
  2. A confirmed-but-amountless intent is marked FAILED rather than retried. Nothing writes that column after confirmation, so the state can never resolve itself.
  3. getIntentCredits returns 0 when shannonsPerByte is 0 — BigInt division by zero throws.
  4. _checkConfirmedIntents catches per intent. It checked result.isErr(), which only covers what onConfirmedIntent returns; a thrown exception escaped the loop and was swallowed by the safeCallback on 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_mispayments

Refusing 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 on main today, 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 NOTHING on (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_method and does not fall back to the AI3 formula, so a usdc_eth row without quoted_ai3_shannons yields 0 credits and is marked FAILED. Running the down-migration while paid USDC intents are in flight strands them. Drain or settle first.

Verification

Check Result
yarn models build && yarn backend build clean
yarn backend lint clean
intents + PaymentManager + intentMispayments suites 135 passed
yarn frontend lint + tsc --noEmit clean

🤖 Generated with Claude Code

EmilFattakhov and others added 6 commits August 12, 2026 12:40
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>
@EmilFattakhov
EmilFattakhov force-pushed the feat/intent-usdc-quote-lock branch from 2401ce5 to d081458 Compare August 12, 2026 19:30
@EmilFattakhov EmilFattakhov changed the title Feat/intent usdc quote lock feat(intents): quote and lock a USDC purchase at creation Aug 12, 2026
EmilFattakhov and others added 2 commits August 12, 2026 18:43
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>
@EmilFattakhov

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@EmilFattakhov
EmilFattakhov marked this pull request as ready for review August 13, 2026 16:07
…e-lock

# Conflicts:
#	apps/backend/__tests__/unit/useCases/intents.spec.ts
@EmilFattakhov

Copy link
Copy Markdown
Member Author

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.

Base automatically changed from feat/intent-requested-bytes to main August 14, 2026 14:29

@jim-counter jim-counter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Convert storage amount to AI3
  2. Convert AI3 amount to USDC
  3. Accept USDC payment if it is at or above the amount quoted
  4. 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:

  1. quoted_ai3_shannons is strictly derivable. It is computed as requestedBytes * shannonsPerByte (intents.ts:461), and shannonsPerByte is already on the row. It is a denormalisation of requested_bytes, not independent information.
  2. The "no reader" argument is circular. intents.ts:438 declines to persist requestedBytes on 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_bytes is 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_MARGIN to 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 zero shannonsPerByte guard.
  • 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.

Comment thread apps/backend/src/core/users/intents.ts Outdated
Comment thread apps/backend/migrations/sqls/20260812000000-intent-mispayments-up.sql Outdated
EmilFattakhov and others added 6 commits August 20, 2026 10:48
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>
@netlify

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for auto-drive-storage ready!

Name Link
🔨 Latest commit 4ef25bb
🔍 Latest deploy log https://app.netlify.com/projects/auto-drive-storage/deploys/6aa0139199a52f0008a67fbf
😎 Deploy Preview https://deploy-preview-810--auto-drive-storage.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@EmilFattakhov

EmilFattakhov commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

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:

  • af615050 — dedup key moved to (tx_hash, log_index)
  • d47fc4e8 — expiry grace, so a stale tx_hash can't exempt a row forever
  • 177cabab — a second payment against a settled intent gets recorded
  • 9b836bb1 — off-quote payments get recorded
  • ec7e23f0 — dust payments get recorded

Two of those need a word. Expiring tx_hash rows on its own would have made
things worse: the idempotency guard treated EXPIRED as "already processed", so a
payment arriving after expiry just vanished. That's reachable on main today, so
it's fixed in the same commit.

And your detectability point is the one that landed. Silent was the problem, not
proportional. An off-quote payment now files a row and still gets credited, which
is ~20 lines instead of a new settlement model.

On the column swap: not doing it since quoted_ai3_shannonsisrequestedBytes * shannonsPerByteandshannons_per_byteis on the row, sorequested_bytes` comes back exactly.
Switching later is code only, no backfill. You're right that it's derivable — it
just goes both ways.

Why I'd rather keep the rate model: you can't bounce a transfer, so every amount
needs an answer that doesn't involve a person, and intent_mispayments has no
grant path out today (reprocessOverCapIntent only takes OVER_CAP).
Band-or-refuse also splits settlement per asset. And I couldn't find a tolerance
I liked — 0.01 USDC is ~3.5MB at realistic prices, and a few base units makes it
a cliff.

Happy to revisit at #748. If the payment amount ends up as immutable calldata
built from the quote, none of this fires anyway.

…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>
EmilFattakhov and others added 3 commits August 20, 2026 12:10
…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>
@EmilFattakhov

Copy link
Copy Markdown
Member Author

bugbot run

Comment thread apps/backend/src/core/users/intents.ts Outdated
Comment thread apps/backend/src/core/users/intents.ts Outdated
EmilFattakhov and others added 2 commits August 21, 2026 10:14
… 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>
EmilFattakhov added a commit that referenced this pull request Aug 21, 2026
… 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>
EmilFattakhov added a commit that referenced this pull request Aug 21, 2026
…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>
EmilFattakhov added a commit that referenced this pull request Aug 21, 2026
… 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>
EmilFattakhov added a commit that referenced this pull request Aug 21, 2026
…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>
@EmilFattakhov

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 jim-counter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small bug to address and I think we're good here.

Comment thread apps/backend/src/core/users/intents.ts
EmilFattakhov added a commit that referenced this pull request Sep 8, 2026
…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>
@EmilFattakhov
EmilFattakhov force-pushed the feat/intent-usdc-quote-lock branch from aa83588 to 4ef25bb Compare September 8, 2026 13:54

@jim-counter jim-counter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@EmilFattakhov
EmilFattakhov merged commit caee683 into main Sep 8, 2026
8 checks passed
@EmilFattakhov
EmilFattakhov deleted the feat/intent-usdc-quote-lock branch September 8, 2026 16:51
EmilFattakhov added a commit that referenced this pull request Sep 8, 2026
… 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>
EmilFattakhov added a commit that referenced this pull request Sep 8, 2026
…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>
EmilFattakhov added a commit that referenced this pull request Sep 9, 2026
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>
pull Bot pushed a commit to vexr/auto-drive that referenced this pull request Sep 9, 2026
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>
EmilFattakhov added a commit that referenced this pull request Sep 10, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants