Skip to content

feat(tempo): add Relay.autoSwap, opt-in balance diffs, and match diffs to state - #5356

Merged
jxom merged 6 commits into
mainfrom
feat/relay-auto-swap
Oct 31, 2026
Merged

jxom merged 6 commits into
mainfrom
feat/relay-auto-swap

Conversation

@jxom

@jxom jxom commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Adds Relay.autoSwap() and fixes three Relay.simulate preview gaps found while building it. Balance diffs and auto swaps now require a per-request opt-in. All three gaps are covered by an oracle-backed vector and fuzz suite.

Request opt-in

  • capabilities.balanceDiffs: true opts into balanceDiffs. Previously diffs were on by default; the fee estimate still is. Insufficient-balance errors include their optimistic diffs only with this opt-in.
  • capabilities.autoSwap: true | { slippage } opts into a swap. A requested slippage can only tighten the plugin's configured bound, never loosen it. Without the opt-in, the original revert is returned unchanged.
  • Formatter fix: Tempo's request formatter sent fill requests without Tempo-specific fields (plain to/data) through the generic formatter, which dropped capabilities, including errors: true. It now keeps them.

Migration: relays and wallets that render previews now send capabilities: { balanceDiffs: true }.

Relay.autoSwap()

When an opted-in fill reverts with InsufficientBalance, the plugin buys the missing amount on the stablecoin DEX, prepends the buy to the user's calls, refills through context.client, and reports the swap as capabilities.autoSwap. Token metadata for the capability is added in afterFill.

  • Placement: last, after Relay.simulate(), so it sees the node's revert before simulate turns it into an error capability.
  • Choosing the input token: the first token whose DEX buy quote fits within the slippage bound and within the sender's balance. The request's fee token and the sender's onchain preference are tried first. Slippage is rounded down to parts per million, so the bound is never exceeded.
  • No approve call: the DEX debits the sender directly. An approve call would only leave a live allowance to the DEX.

Simulate fixes

  1. Approval exposure is now net of consumed allowance. The diff used the last Approval amount, which overstated exposure after a router spent part of it. Now the remaining allowance is read after the calls for every Approval the sender emits, however the approval was made (direct call, router, permit).
  2. Same-token transfers are no longer netted. A token received and passed on in one transaction now shows both directions instead of netting to zero.
  3. Non-TIP-20 tokens no longer drop the whole preview. These tokens, such as ERC-4626 vault shares, now read standard ERC-20 decimals/symbol/name.

Tests

test/src/tempo/balanceDiffs.ts adds an oracle that is independent of how diffs are computed. In one simulated block it reads the sender's balances and allowances immediately before and after the calls, then checks the diffs against those reads and against the emitted Transfer events.

  • 24 localnet precompile vectors: TIP-20, the stablecoin DEX, the fee AMM, and the fee manager.
  • 58 mainnet vectors: real transactions to the most-called contracts, replayed at their parent block. These run with VITE_TEMPO_CREDENTIALS, because the public RPC refuses tempo_simulateV1.
  • Node fuzz suite (fast-check, run under test:fuzz:tempo:node).
  • autoSwap tests: opt-in, slippage tightening, rejection of invalid slippage, balance below the bound, and slippage rounding.
  • Type test for Capabilities.AutoSwap.
  • Formatter test for capabilities kept on plain fill requests.

Against main's simulate.ts: the localnet vectors fail 3, the mainnet vectors fail 4, and the fuzz suite fails with a counterexample shrunk to [buy, sell, liquidity].

Locally, the relay and compat suites have 348 passed and 1 failed. The failure is "does not save key authorizations signed by an admin key" in keyAuthorization.test.ts, which also fails on main and is unrelated.

Unrelated CI failures

These also fail on main's latest run:

  • Check: pnpm audit advisories.
  • Tempo Fuzz: the nonce sponsored-burst test fails on intrinsic gas.
  • Tnext: the access-key admin test (InvalidKeyId) and a feePayer intrinsic-gas test.

Not handled

When a fill goes to an external fee-payer URL and that relay reports InsufficientBalance, the error stays inside feePayer, so autoSwap can't recover it. That relay should run Relay.autoSwap() itself.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp

- Added `Relay.autoSwap()`: on an `InsufficientBalance` revert it buys the
  missing amount on the stablecoin DEX from the first funded token with a
  route within the slippage bound, and reports `capabilities.autoSwap`. The
  DEX debits the sender directly, so the swap adds no allowance.
- `Relay.simulate` reports same-token incoming and outgoing transfers
  separately, and approval exposure as the allowance read after the calls
  for every `Approval` the account emitted, net of what spenders consumed.
- Relay token metadata reads standard ERC-20 fields for non-TIP-20 tokens,
  so one vault share no longer omits the whole preview.
- Added a balance-diff oracle that reads balances and allowances before and
  after the calls in one simulated block, with localnet precompile vectors,
  mainnet vectors from the most-called contracts, and a node fuzz suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
viem Ready Ready Preview Oct 31, 2026 8:53am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 02d3826

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
viem Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T08:56:06.487842Z 02d3826 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02cb97350c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tempo/internal/relay/autoSwap.ts Resolved
Comment thread src/tempo/internal/relay/autoSwap.ts Resolved
Comment thread src/tempo/internal/relay/autoSwap.ts Resolved
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Published viem@0.0.0-pr-5356-02d3826 under the pr-5356 dist-tag, which follows this pull request until it closes.

pnpm add viem@pr-5356
pnpm add viem@0.0.0-pr-5356-02d3826

…ippage

- Candidates are quoted first and accepted when the quote fits both the
  slippage bound and the balance; the DEX spends only the quoted input.
- Slippage is floored to parts per million, so the maximum input never
  exceeds the configured bound.
- Added `Capabilities.AutoSwap` to the fill capability schema with a type test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2dfb410d8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tempo/Capabilities.ts
Comment on lines +73 to +77
decimals: number
formatted: string
name: string
symbol: string
token: Address

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add TSDoc to AutoSwapAmount fields

The newly exported AutoSwapAmount properties have no TSDoc, leaving consumers without generated descriptions for fields such as formatted, token, and value. Add property-level documentation to this public type as required for exported API shapes.

AGENTS.md reference: AGENTS.md:L120-L121

Useful? React with 👍 / 👎.

Comment on lines +102 to +106
#### options.store

- **Type:** `Store.Store`

Store for cached token metadata. Omit to read metadata for each request.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Demonstrate store in an auto-swap recipe

The page documents options.store, but none of its recipes demonstrate configuring a store or explain when cached metadata is useful. Add a recipe that exercises this option rather than leaving it only in the reference section.

AGENTS.md reference: AGENTS.md:L300-L304

Useful? React with 👍 / 👎.

Comment on lines +54 to +55
.catch(() => {
throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Try another swap source after refill failure

When the first ranked input token is also needed by a later original call or for fees, its balance can cover the quote even though prepending the buy makes the refill revert. This catch immediately restores the original error, so a second independently funded and routable candidate is never attempted; for example, a missing-token transfer followed by a transfer of the preferred input token fails even when another candidate could fund the swap. Continue with the remaining candidates after this refill failure, or reserve the original transaction's spend when selecting the source.

Useful? React with 👍 / 👎.

Comment on lines +412 to +413
// Previews over the token budget are omitted, so their reads are skipped too.
if (pairs.size === 0 || pairs.size > 100) return undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Omit previews above the allowance-read cap

When one transaction emits more than 100 approval pairs, this returns undefined, but buildBalanceDiffs interprets that exactly like a failed read and falls back to each pair's last emitted approval amount. If any of those allowances was subsequently consumed, the returned preview overstates exposure instead of being omitted; this is especially possible when many spenders share one token, because the later 100-token limit does not reject the result. Propagate an explicit over-budget result and omit the preview rather than using event amounts.

Useful? React with 👍 / 👎.

Comment on lines +90 to +92
// Without both tokens' metadata the swap still executes, but cannot be described.
if (!metadata_in || !metadata_out)
return { capabilities: { autoSwap: undefined } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve swap disclosure when metadata reads fail

When either post-fill metadata lookup fails, this patch removes capabilities.autoSwap even though the returned transaction still contains and will execute the prepended DEX call. A transient RPC failure or a token whose metadata cannot be decoded therefore leaves callers with no structured indication that the relay modified the transaction to spend another token. Retain a capability that discloses the known token addresses and bounds, or fail the recovery before returning a silently modified fill.

Useful? React with 👍 / 👎.

Comment on lines +122 to +125
const allowances =
account && account !== zeroAddress
? await readAllowances(client, { account, blockNumber, calls, logs })
: undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pin allowance replay to the first simulation state

When blockNumber is omitted, the transfer/log simulation and readAllowances each resolve latest independently. If a block is produced between those RPCs, the second invocation replays the calls from a different base state, so its final allowances can disagree with the execution whose logs and transfers are used to build the same preview. Capture a common base block before both simulations, or otherwise replay the allowance reads against the exact state used by the first simulation.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 451d306802

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +54 to +55
.catch(() => {
throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Continue recovery when a later call exposes another deficit

When a batch contains multiple calls spending the missing token, the first revert reports only the first failing call's shortfall. For example, with zero balance and two one-token transfers, the plugin buys one token, the first transfer succeeds, the second then reverts, and this catch discards the retry error and restores the original one. Retry recovery for subsequent deficits, or determine the batch's full deficit before constructing the swap.

Useful? React with 👍 / 👎.

Comment on lines +100 to +102
Largest accepted price slippage over the missing amount, between 0 and 1.

#### options.store

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add focused examples for every documented option

Both new parameter sections stop after their type and prose, so neither options.slippage nor options.store has the required focused ts twoslash example showing realistic use. Add a minimal focused snippet under each option heading.

AGENTS.md reference: AGENTS.md:L336-L343

Useful? React with 👍 / 👎.

- `Relay.simulate` returns `balanceDiffs` only for `capabilities.balanceDiffs:
  true`; the fee estimate stays default. Insufficient-balance errors follow
  the same opt-in for their optimistic diffs.
- `Relay.autoSwap` swaps only for `capabilities.autoSwap: true` or
  `{ slippage }`, which can tighten but never loosen the configured bound.
- Tempo request formatting keeps `capabilities` on fill requests without
  Tempo-specific fields, which previously dropped `errors: true` too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp
@jxom jxom changed the title feat(tempo): add Relay.autoSwap and match balance diffs to state feat(tempo): add Relay.autoSwap, opt-in balance diffs, and match diffs to state Oct 31, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 553d4d5bc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

): { slippage?: number } | undefined {
const value = (capabilities as Record<string, unknown> | undefined)?.autoSwap
if (value === true) return {}
if (!value || typeof value !== 'object') return undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject arrays as auto-swap opt-ins

For raw JSON-RPC requests with capabilities.autoSwap: [], this check treats the array as an options object and returns {}, so the relay may prepend a swap even though the caller did not provide either supported opt-in form (true or an object). Because this capability changes the transaction the sender signs, exclude arrays here and reject them as invalid parameters or treat them as no opt-in.

Useful? React with 👍 / 👎.

Comment on lines +66 to +70
const optimisticCalls =
normalized &&
(parameters.capabilities as Record<string, unknown> | undefined)
?.balanceDiffs === true
? extractCalls(normalized)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve virtual addresses independently of balance diffs

When an insufficient-balance fill contains a virtual-address call but does not opt into balanceDiffs, optimisticCalls becomes undefined, so the shared ternary also skips resolveVirtualAddresses. The error result bypasses normal post-fill enrichment, causing the otherwise unrelated virtualAddresses capability to disappear unless callers request a balance preview; keep virtual-address resolution based on the normalized calls while gating only the simulation.

Useful? React with 👍 / 👎.

- Reserved 5,000 gas for a fee payer's signature and fee token on sponsored
  requests: `eth_fillTransaction` estimates without them, and T14 charges
  them as intrinsic gas.
- Gated admin access-key tests on hardforks before T14, which removes admin
  key creation (TIP-1137); test relays mirror `Relay.feePayer`'s gas reserve.
- Overrode follow-redirects to 1.16.2 and ignored three dev-only moderate
  advisories without usable patches, so `pnpm audit` passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp
Owner approvals cover the gas limit, so re-preparing an approved request
for broadcast must not add the fee payer's gas reserve again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3nmWess9BFbYNyQxrugjp

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02d382600d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tempo/chainConfig.ts
// The fill omits the fee payer's signature and fee token (85 bytes), which count toward intrinsic gas.
request.gas = (request.gas ?? 0n) + feePayerGas
if (request.keyAuthorization?.signature.type === 'webAuthn')
request.gas = (request.gas ?? 0n) + 20_000n

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid adding the fee-payer reserve on every preparation

When an unsigned sponsored request is passed through prepareTransactionRequest more than once, each afterFillParameters pass adds another 5,000 gas because the returned request still has feePayer, no feePayerSignature, and no owner approvals. For example, re-preparing the { gas: 38_000n, feePayer: true } output of the new ordinary-account test produces 43_000n; repeated preparation can therefore inflate the signed gas limit and make fee-balance checks reject an otherwise valid transaction. Track whether the reserve is already included or derive the gas from an unadjusted estimate.

Useful? React with 👍 / 👎.

tokenOut: deficit.token,
})
const swap = [{ data: buy.data, to: buy.to, value: '0x0' }]
const { calls: _, data: __, to: ___, value: ____, ...rest } = parameters

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-estimate gas after prepending the swap

When the original fill request already supplies gas, this retry keeps that value in rest even though it prepends a DEX call. Since eth_fillTransaction fills missing fields rather than replacing the caller's explicit gas limit, the returned transaction can retain a limit sized only for the original calls and then run out of gas when executing the added swap. Omit gas from the retry so the modified call list is estimated again.

Useful? React with 👍 / 👎.

Comment on lines +80 to +83
tx:
Array.isArray(tx.calls) && tx.calls.length > 0
? tx
: { ...tx, calls },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate swapped calls through transaction preparation

When auto-swap is reached through prepareTransactionRequest (including the documented recipe and normal send preparation), fillTransaction receives this modified tx.calls, but prepareTransactionRequest only copies selected filled fields such as gas, fees, and nonce back onto the original request and therefore retains the unswapped calls. The returned request can advertise capabilities.autoSwap while the transaction that is subsequently signed omits the buy and still fails for insufficient balance; make the preparation path adopt the filled call list.

Useful? React with 👍 / 👎.

@jxom
jxom merged commit 51b4392 into main Oct 31, 2026
41 checks passed
@jxom
jxom deleted the feat/relay-auto-swap branch October 31, 2026 09:00
This was referenced Oct 31, 2026

This branch was successfully deployed

1 active deployment
Preview — 02d38260 Deployed Oct 31, 2026 by vercel[bot]
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