Skip to content

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

Open
EmilFattakhov wants to merge 9 commits into
mainfrom
feat/intent-usdc-quote-lock
Open

feat(intents): quote and lock a USDC purchase at creation#810
EmilFattakhov wants to merge 9 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
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.

1 participant