Skip to content

feat(execution): add TychoSubsidizingExecutor - #1642

Draft
Troshchk wants to merge 6 commits into
lp/subsidy-payerfrom
kt/ENG-6414-subsidizing-executor
Draft

Troshchk wants to merge 6 commits into
lp/subsidy-payerfrom
kt/ENG-6414-subsidizing-executor

Conversation

@Troshchk

@Troshchk Troshchk commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Adds TychoSubsidizingExecutor, a registered executor that wraps one hop. It runs the hop through an approved inner executor, then calls SubsidyPayer.pay to pay a signed subsidy in the hop's output token. ENG-6414.

Based on the SubsidyPayer PR (lp/subsidy-payer).

What it does

  • Swap data: [boundTo: 20][amount: 16][minAmountIn: 16][nonce: 32][deadline: 6][signature: 65][innerExecutor: 20][innerData]. innerSwapHash is keccak256(innerExecutor ‖ innerData).
  • The inner executor must be approved on the router with its timelock passed, and may not be the wrapper itself. getTransferData and fundsExpectedAddress forward to it and map the wrapper's own address back to the router.
  • Before the inner swap, the wrapper writes the inner executor into _CURRENTLY_SWAPPING_EXECUTOR_SLOT, so pool callbacks reach the inner executor.
  • The payer pays into the Dispatcher's measureAt (outputToRouter ? router : receiver) inside swap(), so the Dispatcher counts the subsidy as hop output.
  • pay runs in try/catch. A payer revert emits SubsidyPaymentFailed(nonce) from the router and the swap fills without the subsidy.
  • The wrapper writes no storage and moves no tokens.

Other changes:

  • Dispatcher._CURRENTLY_SWAPPING_EXECUTOR_SLOT is now internal, so testCallbackSlotMatchesDispatcher can compare it with the wrapper's copy. The router's deployed bytecode is unchanged.
  • The security model gets a TychoSubsidizing variant that forwards to any modelled executor. It reports 0 suspicious outcomes.
  • crates/tycho-execution/CLAUDE.md gets a "Subsidies" section.

Tests

forge test --match-contract TychoSubsidizing: 35 pass. Slither reports no findings.

TychoSubsidizingExecutorTest (24) uses a mock payer that pays and records its arguments, and compares each wrapped swap with the same swap without the wrapper:

  • UniV2, an outputToRouter inner executor, V3 and V4 callbacks, native ETH paid to the router and to the receiver
  • splits (larger leg, smaller leg, leg after an intermediate token) and sequential routes
  • fees and slippage capture with the subsidy in the quote, and slippage capture taking it when the quote leaves it out
  • unwrap at the end, wrap at the end, a split ending in an unwrap leg
  • vault credit, minAmountOut, a reverting payer, rejected inner executors, the single-hop cycle, short data, the slot guard

TychoSubsidizingWithPayerTest (11) uses the real SubsidyPayer with a test signer:

  • tampered innerData, an expired deadline and the unsigned placeholder fill without the subsidy
  • a contract caller pays with boundTo set to the contract or to the sending EOA
  • a relayed call and a front-run copy fill without the subsidy and leave the nonce unused; the user's own transaction then pays
  • a replay, a paused payer and an empty payer fill without the subsidy
  • a receiver that rejects ETH fails the same way as the swap without the wrapper. The pool sends ETH to the receiver directly, so the swap reverts before pay is reached.

Gas: the wrapper adds about 11,900 gas to a UniV2 or UniV3 single swap, measured with a payer that returns false at once. The payer's own cost is not included.

Not included

No runtime bytecode fixture. Protocol testing looks fixtures up by protocol system, and the wrapper is not a protocol. Its fixture is built from an executor_deployments.json entry, which needs the payer's address, so it comes with the deployment.

Replaces the sketch in #1563.

🤖 Generated with Claude Code

Troshchk and others added 6 commits October 19, 2026 15:43
The executor runs a hop through an inner executor the router approved,
then asks the subsidy payer to pay a signed subsidy in the hop's output
token to where the Dispatcher measures the hop's output, so the
Dispatcher counts it. It points the router's callback slot at the inner
executor, so callbacks reach it directly. A payer revert emits
SubsidyPaymentFailed and the swap goes on.

The Dispatcher's callback slot constant becomes internal, so a test can
compare it with the executor's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The caller picks the inner executor, so the model adds a
TychoSubsidizing variant whose transfer data, swap and funds-expected
address forward to any other modeled executor. Its swap writes the
inner executor into the callback slot, so callbacks go to it. The
subsidy payer is not modeled. The model reports no suspicious outcomes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each test runs a swap through the wrapper and compares it with the same
swap without it, against a mock payer that pays and records its
arguments. They cover callback and transfer inner executors, splits,
sequential routes, wrap and unwrap ends, fees and slippage capture,
vault credit, minAmountOut, a reverting payer, rejected inner
executors, and a guard that the wrapper's callback slot matches the
Dispatcher's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pper

The data offsets are used only as calldata slice bounds, and the
payment-failed event cannot be reentered because every router entry
point is nonReentrant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the swap data layout, the callback-slot write, paying into the
Dispatcher's measureAt, and why the payer holds the funds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed subsidies through the router: tampered inner data, an expired
deadline and the unsigned placeholder skip with the payer's reason; a
contract caller pays whether bound to the contract or the sending EOA;
a relayed call and a front-run copy skip without spending the nonce,
and the user's own transaction still pays; a replay, a paused payer and
an empty payer fill without the subsidy; a receiver that rejects ETH
fails the same way as the swap without the wrapper.

Shared helpers move into TychoSubsidizingTestBase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Troshchk
Troshchk force-pushed the kt/ENG-6414-subsidizing-executor branch from a796200 to 15b213f Compare October 19, 2026 14:17
@Troshchk
Troshchk changed the base branch from main to lp/subsidy-payer October 19, 2026 14:17

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant