feat(intents): quote and lock a USDC purchase at creation - #810
Open
EmilFattakhov wants to merge 9 commits into
Open
feat(intents): quote and lock a USDC purchase at creation#810EmilFattakhov wants to merge 9 commits into
EmilFattakhov wants to merge 9 commits into
Conversation
EmilFattakhov
force-pushed
the
feat/intent-requested-bytes
branch
from
August 7, 2026 16:05
24bacc1 to
fe61b60
Compare
EmilFattakhov
force-pushed
the
feat/intent-requested-bytes
branch
from
August 12, 2026 14:58
05f564c to
841c71b
Compare
A USDC intent has to convert the payment it receives back into storage bytes,
and the only rate that makes "pay the quote, receive the quote" true is the rate
the user was actually quoted at. No existing column carries it.
usd_rate_at_creation cannot: it is the pool's MARGINAL price, while the user pays
the executable quote, which is that price plus the pool swap fee, plus the price
impact of their own size, plus the quote margin. Converting a received payment at
the marginal rate hands all three back as free storage — 5-8% on a realistic
purchase — and grants more bytes than the pre-payment cap check was run against,
which quietly defeats that check.
quoted_token_amount is half of the rate: what was charged. This adds the other
half — what it was charged FOR:
quoted_ai3_shannons numeric(78,0) NULL
Stored as the pair rather than as a rate, because a rate here has to be a
rounded ratio. USDC carries 6 decimals against byte counts near 1e11, so
USDC-per-byte is ~0.0027 base units at 100 GiB — sub-unit, and only
representable as an integer once scaled, at which point it no longer
round-trips the quote. Two exact integers do:
bytes = token_amount * quoted_ai3_shannons
/ quoted_token_amount / shannons_per_byte
With token_amount = quoted_token_amount the ratio cancels and the result is the
requested size exactly, with no rounding in either direction. That exactness is
the point: rounding here is money.
This is deliberately not the quoted_bytes column removed in the previous commit.
That one was written by one path and read by none, and was shaped like a balance
it could never agree with. This one has a reader — the confirmation path — and is
a conversion factor rather than a balance claim.
Wired through the model, the row mapper, and BOTH statement column lists. The
UPDATE rewrites the full column list, so a column missing from it is silently
nulled on the first status transition — invisible until credits come out wrong,
since the intent still looks complete at creation. The existing update-spread
test now covers this column, and a new test asserts the pair still reproduces
the requested size after a full create/update/read cycle, which is the property
a float anywhere in the numeric mapping would break while both columns still
looked populated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getExecutableQuote returns four distinct error types and none had a status code
assigned, so every one of them would have reached the client as a generic 500.
The oracle draws those distinctions on purpose, and they mean genuinely different
things to whoever is buying. Two are our problem and retryable; two are about the
size that was asked for. Collapsing them sends a user to shrink a purchase that
was never the problem while an Ethereum outage goes unreported as an outage:
OracleUnavailableError 503 PRICE_ORACLE_UNAVAILABLE no trustworthy price
PriceDeviationError 503 PRICE_UNSTABLE pool not quotable now
QuoteTooLargeError 409 QUOTE_TOO_LARGE pool cannot fill it
InvalidQuoteAmountError 400 QUOTE_AMOUNT_INVALID unquotable amount
Adds the 503 the codebase did not have. ServiceUnavailableError is a distinct
class rather than a reused 500 because nothing is wrong with the request and the
condition is usually transient — the caller should retry, not rewrite.
QuoteTooLargeError is 409 rather than 400: the request is well-formed and would
have been valid for a smaller size or a deeper pool, which is a state conflict
rather than malformed input.
QuoteFailedError is one class parameterised by cause rather than four subclasses.
The mapping is a small table and reads far better as a table than as four
near-identical class bodies, and the response shape is identical across all four
regardless. It overrides handleResponse to emit { error: <CODE>, message },
matching what the intents controller already does for GOOGLE_ACCOUNT_REQUIRED and
CREDIT_CAP_EXCEEDED, so a client branches on `error` and can surface `message`
verbatim — and no call site can forget to attach the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intents could only be paid in native AI3. This adds the USDC path end to end: a
fixed price quoted and locked when the intent is created, and a confirmation that
converts the payment back to storage at exactly the rate that was quoted.
Creation. createIntent takes an options object instead of a second positional
parameter — two optional bigint/enum arguments in a row on a money path is the
shape where a transposed call site silently prices the wrong thing, and the
compiler would not catch the swap. paymentMethod defaults to AI3_NATIVE, so the
body-less POST the live frontend sends keeps its current behaviour exactly.
requestedBytes becomes required for USDC_ETH, because there is nothing to quote
without it. The check sits in createIntent next to the existing range checks
rather than in parseRequestedBytes: a caller reaching the use case directly must
be held to the same rule, and the parser's job ends at the wire shape.
The USDC path quotes the AI3 the purchase is worth (requestedBytes *
shannonsPerByte), not the byte count — the pool prices AI3. The margin goes on
the EXECUTABLE quote, not on the marginal value: the executable quote already
covers the swap fee and this size's own price impact, and the margin covers what
it cannot, which is drift over the 10-minute price lock. That reasoning already
lives in pricing.ts and is not re-derived here.
Order matters and is asserted: validate, then cap pre-check, then quote. An
Ethereum round trip is not spent on a purchase that can never be granted, and a
failed quote leaves no priceless PENDING row behind.
Confirmation. getIntentCredits gains a USDC branch that converts token_amount at
quoted_token_amount / quoted_ai3_shannons and then divides by shannons_per_byte
as before, multiplying before dividing so there are no intermediate floors. It
must never use usd_rate_at_creation, which is marginal spot — the two tests
pinning this assert that paying exactly the quoted amount grants exactly the
requested bytes, and that the marginal rate would over-credit by >6%.
Three smaller things the path needed:
- markIntentAsConfirmed can record a token amount. It goes on token_amount, not
paymentAmount: the latter is denominated in shannons, so putting USDC in it
would make every AI3-shaped read of the row silently wrong, starting with the
dust guard. It also now refuses a confirmation carrying neither amount, which
would otherwise surface as a 0-credit FAILED row to diagnose backwards from an
irreversible payment.
- onConfirmedIntent's "has a deposit" guard reads whichever column the asset
uses. It previously read paymentAmount unconditionally and would have rejected
every USDC intent. Its OVER_CAP log did the same and would have thrown on a
null.
- parsePaymentMethod rejects an unrecognised value rather than defaulting. A typo
('usdc', 'USDC_ETH') defaulting to AI3 would quote in the wrong asset and the
caller would only find out at payment time.
An incomplete USDC intent yields 0 credits rather than a guessed rate, which
routes it to the existing FAILED branch for admin review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…payment Review of the USDC path found a reachable state where a user's on-chain payment is kept, no credits are granted, and nothing lands in the admin queue to find it. payIntent(bytes32) on the AI3 receiver accepts ANY intent id with any non-zero msg.value, and the watcher reports every such event as `paymentAmount` regardless of what the intent expects. So an AI3 payment against a usdc_eth intent arrives at markIntentAsConfirmed as a well-formed call, and nothing upstream rejects it. It was then confirmed: status CONFIRMED, payment_amount set, token_amount NULL. onConfirmedIntent looks up the column the asset uses, finds nothing, and returned an error without writing a terminal status — so _checkConfirmedIntents re-ran it every 30 seconds indefinitely. Two consequences, both bad: - the payment is kept with no credits and no FAILED/OVER_CAP row for an admin - the idempotency guard now treats the intent as settled, so the user's REAL USDC payment is silently discarded when it arrives It is also griefable rather than merely accidental: the intent id is visible in the calldata of a pending Ethereum payIntentWithToken tx, and 1 wei of AI3 on Auto EVM is a different chain, so the race against Ethereum confirmations is easy to win. Three fixes, defence in depth: - markIntentAsConfirmed requires the amount to be denominated in the asset the intent was quoted in, and refuses the mismatch before writing. The intent stays PENDING and expires on its own schedule. The mispaid amount still needs manual resolution — the same position a payment to an unknown intent id is already in. Ordered after the idempotency guard, so re-delivery of an event for a settled intent stays a no-op rather than becoming an error the watcher retries. - onConfirmedIntent marks a confirmed-but-amountless intent FAILED instead of returning an error. Nothing writes that column after confirmation, so the state can never resolve itself; FAILED stops the loop and surfaces the row, matching how every other unresolvable confirmation here is handled. This also fixes the pre-existing AI3 version of the same infinite retry. - getIntentCredits returns 0 when shannonsPerByte is 0. BigInt division by zero throws, and that exception escapes onConfirmedIntent to abort the entire polling tick rather than just the one intent — reachable via CREDITS_PRICE_MULTIPLIER=0. Also corrects two comments that misdescribed their own code: the dust guard does not catch a missing tokenAmount (the guard above it fires first), and the migration's claim that column-less rows fall back to the AI3 formula is wrong — credit derivation routes purely on payment_method and does not fall back, which is why the down-migration strands in-flight paid USDC intents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pact The column's rationale still argued against the marginal price of a pool quote — swap fee, this size's own price impact, and the margin, 5-8% on a realistic purchase. #807 replaced that oracle with an average of realized fills, so the fee and impact now arrive inside the rate and the entire wedge is USD_QUOTE_MARGIN. The conclusion is unchanged and the column is still needed; only the size and source of the gap were wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on batch _checkConfirmedIntents checks `result.isErr()`, which only covers what onConfirmedIntent RETURNS. A thrown exception escapes the loop and is swallowed by the safeCallback on the interval, so every intent behind the thrower is abandoned — users who paid correctly, skipped, and skipped again on each tick for as long as the poison row stays in the batch, with nothing terminal written to explain it. The known way in is getIntentCredits dividing by a zero shannonsPerByte, which this branch already guards at the source. This is the other half: the guard fixes the cause we found, the catch bounds the blast radius of the ones we did not. An intent that cannot be processed should cost its own turn, never the queue's. The existing batch tests only ever returned errors, which were always handled; the new test throws mid-batch and asserts the intent AFTER the thrower still runs. It fails without the catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EmilFattakhov
force-pushed
the
feat/intent-usdc-quote-lock
branch
from
August 12, 2026 19:30
2401ce5 to
d081458
Compare
Refusing a mispaid confirmation is correct — confirming one strands the intent in the polling loop and makes the idempotency guard discard the user's real payment when it lands — but it settles nothing on chain. The transfer happened; the only open question is whose the money is, and a log line is not an answer anyone finds. The table takes both refusal cases: an asset mismatch, and a payment naming an intent id that matches no row. The second is the one with the least evidence anywhere else, since no intent row shows that money arrived at all. No foreign key to intents, because that second case has nothing to point at. ON CONFLICT DO NOTHING on (tx_hash, intent_id), because the watcher replays — reorgs re-emit events and the startup sweep re-runs every PENDING intent that carries a tx_hash — and an admin queue that grows on every restart is one nobody reads. Nothing writes to it yet; the next commit does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e refuse Addresses review of #810. Nothing settles a USDC intent yet: the payment manager subscribes to the AI3 receiver's IntentPaymentReceived only, and no watcher reads intentTokenPaymentReceivedAbi. A quote created today is a binding amount the backend cannot observe payment of, and the response does not even carry an address to send it to. So usdc_eth now sits behind payWithUsdc (PAY_WITH_USDC_ACTIVE), off by default, with admins exempt unconditionally — a switch that also locks out the people verifying the path cannot be turned on with any confidence. The gate is in the use case rather than the controller, since POST /intents is not the only door, and it refuses before any account read, balance read or chain round-trip. Also here: - A refused payment is recorded in intent_mispayments rather than only logged, with the tx hash threaded from the watcher: an amount and a sender describe a payment, but only the hash finds it again. Recording never throws — failing to file the paperwork must not change which error actually happened, and on the startup-sweep path a throw would abort the recovery of unrelated transactions. GET /intents/mispayments (admin) reads it, mirroring /intents/over-cap: that queue is for payments we accepted and could not convert, this one for payments we never accepted at all. - An intent at a zero per-byte price is refused at creation. Every payment against one converts to zero credits and lands in FAILED with the money kept, and on the USDC path the quote itself computes to 0 — a binding charge of "nothing" for a purchase that will never be granted. Guarded on both payment methods, since the downstream failure is identical. A 503 rather than a 4xx: the request was fine, the deployment is not. - QuoteFailedError extends ServiceUnavailableError instead of taking a status, so the 503 lives in the type rather than at each construction site. quoteErrorToHttpError narrows to OracleUnavailableError, which is what the Result type says it can receive; its default branch is for reasons added to the union later, not for some other error class arriving. - The API reference still described the retired quoter — quotedTokenAmount as carrying "the price impact of this purchase size", usdRateAtCreation as the pool's "marginal" price. Since #807 the rate is a VWAP of realized fills and the margin is the entire wedge. Same correction for the getIntentCredits docstring and the QuoteErrorCode comment, which still counted four causes of which two were about the requested size. - The stale-rate policy is written down: a last-good rate prices a binding quote deliberately, because the number is a days-long average and refusing through every subgraph blip would shut the path more than the drift justifies. The USDC amount is deliberately still not checked against quotedTokenAmount; see the PR description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6f33aef. Configure here.
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
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prices a USDC intent at creation and locks the rate the payment converts back at. Step #747 of epic #742. Stacked on #808 — review that first; this diff is only the commits on top.
Off by default.
usdc_ethsits behindpayWithUsdc(PAY_WITH_USDC_ACTIVE), with admins exempt. Nothing settles a USDC intent yet — the payment manager subscribes to the AI3 receiver only, and no watcher readsintentTokenPaymentReceivedAbi— so a quote created today would be a binding amount the backend cannot observe payment of, returned without an address to send it to. Merging this lands the pricing half; opening the flag waits on #748.The problem
An AI3 intent stores one number and credits are
payment_amount / shannons_per_byte. That works because the user pays in the asset the price is denominated in.USDC does not. The user pays dollars, and converting back to bytes needs the AI3/USD rate — but the rate they were charged at is not stored anywhere.
usd_rate_at_creationis the raw oracle rate, and the user pays that plusUSD_QUOTE_MARGIN. Convert a received payment at the raw rate and the margin comes back as free storage on every purchase — the whole margin, exactly, since a rate is a scalar so the error scales with the amount. It also grants more bytes than #808's cap pre-check was run against.quoted_ai3_shannons, and why a rate column would not doquoted_token_amountis what the user was charged. This new column is what they were charged for. The pair is the effective rate.It is a pair rather than a single rate column because a rate here cannot be stored exactly. USDC carries 6 decimals against byte counts near 1e11, so USDC-per-byte is ~0.0027 base units — deeply sub-unit, and once scaled to survive as an integer it is a rounded ratio that no longer round-trips the quote. Two exact integers do:
Pay exactly what was quoted and the ratio cancels: the user receives exactly the size they asked for, with no rounding in either direction. Overpay and it scales proportionally at the same effective rate.
Worked example — at 1 USDC = 1 AI3 = 1 GB with a 5% margin, a 1 GB purchase is quoted at 1.05 USDC and grants 1 GB, not 1.05 GB. The margin is part of the price, not a payment for extra storage; it exists to cover FX drift until an operator converts the batch, and handing it back as bytes would make it cover nothing. Both behaviours are pinned by tests — one asserting the exact grant, one asserting that the raw-rate conversion over-credits.
Also in the quoting path
paymentMethodonPOST /intents, defaulting to AI3 for body-less requests. An unrecognised value is rejected rather than defaulted: silently treating'usdc'as AI3 would quote in the wrong asset and the caller would find out at payment time.PRICE_UNSTABLEdistinguished fromPRICE_ORACLE_UNAVAILABLE. There is deliberately no size-related code — see the rebase note.Rebased onto the subgraph-VWAP oracle
This branch was written against the oracle #807 replaced. Three things had to change, and none was mechanical:
getExecutableQuote(ai3)— priced a specific sizegetPrice()— one size-independent rate, applied by multiplication409 QUOTE_TOO_LARGE/400 QUOTE_AMOUNT_INVALIDThe swap fee and price impact did not disappear — they moved inside the rate, which averages realized fills, and those fills paid both. What no longer exists is this purchase's own impact, because the treasury does not swap per intent.
A payment bug found in review
payIntent(bytes32)on the AI3 receiver takes any intent id from anyone — no registry, no allowlist, just a non-zero value check — and the watcher reports it aspaymentAmountregardless of what the intent expects. So an AI3 payment against a USDC intent arrived as a well-formed confirmation.The row went CONFIRMED with
token_amountNULL;onConfirmedIntentlooked for the USDC column, found nothing, and returned an error without writing a terminal status — so the 30-second poller retried it forever. Payment kept, no credits, no admin row. Worse, the idempotency guard then treated the intent as settled, so the user's real USDC payment would be silently discarded on arrival.Griefable rather than merely accidental: the intent id is visible in the calldata of a pending Ethereum tx, and racing Auto EVM against Ethereum confirmations is easy.
Fixes, defence in depth:
markIntentAsConfirmedrefuses an asset mismatch before writing. The intent stays PENDING and expires on its own schedule. Ordered after the idempotency guard, so re-delivery for a settled intent stays a no-op.getIntentCreditsreturns 0 whenshannonsPerByteis 0 — BigInt division by zero throws._checkConfirmedIntentscatches per intent. It checkedresult.isErr(), which only covers whatonConfirmedIntentreturns; a thrown exception escaped the loop and was swallowed by thesafeCallbackon the interval, abandoning every intent queued behind it — from users who paid correctly, on every tick, with nothing written to explain why. The guard fixes the cause we found; the catch bounds the blast radius of the ones we did not. The existing batch tests only ever returned errors, which were always handled; the new one throws mid-batch and asserts the intent after the thrower still runs.Refusing is not resolving:
intent_mispaymentsRefusing a mispayment is correct, but it settles nothing on chain — the transfer happened, and a log line is not something an admin queries. Refused payments are now recorded, with the tx hash threaded from the watcher: an amount and a sender describe a payment, but only the hash finds it again.
GET /intents/mispayments(admin) reads it, mirroring/intents/over-cap— that queue is for payments we accepted and could not convert, this one for payments we never accepted at all.Two cases, and they are not equally new:
ASSET_MISMATCH— the intent exists but is denominated in the other asset. New with USDC.UNKNOWN_INTENT— the id matches no row. This is reachable onmaintoday, by anyone with a wallet, and until now produced only a log line. It is also the case with the least evidence anywhere else, since no intent row shows the money arrived at all.Design notes: no foreign key to
intents, because the unknown-intent case has nothing to point at — a FK would make the table incapable of recording precisely the case that needs it most.ON CONFLICT DO NOTHINGon(tx_hash, intent_id), because the watcher replays: reorgs re-emit events and the startup sweep re-runs every PENDING intent carrying a tx hash, so without it one mispayment becomes a fresh row per restart. Recording never throws — failing to file the paperwork must not change which error actually happened, and on the startup-sweep path a throw would abort the recovery of unrelated transactions.Down-migration is destructive
Credit derivation routes purely on
payment_methodand does not fall back to the AI3 formula, so ausdc_ethrow withoutquoted_ai3_shannonsyields 0 credits and is marked FAILED. Running the down-migration while paid USDC intents are in flight strands them. Drain or settle first.Verification
yarn models build && yarn backend buildyarn backend lintyarn frontend lint+tsc --noEmit🤖 Generated with Claude Code