Atomic Two-Hop Swaps on Uniswap V4: Inside THRYX's NEW-Pair Facet

9 min read

The two-step problem

Until today, buying a NEW-paired token on THRYX required two transactions, server-orchestrated. Leg 1: ETH wrapped to WETH, then swapped to NEW THRYX through the v3 bridge pool that carries our V6.4 sandwich-trap hook. Leg 2: NEW swapped into the target token via that token's V4-native pool, which carries either the global anti-sniper hook or a per-token override.

Each leg was a separate Diamond entry point. buyNewWithEth landed first, the relay waited for the receipt, then a second swap(NEW, TOKEN, amount, minOut) call landed. Two signatures, two relays, two fee-router pings, and a fragile pause in between where the NEW intermediate sat on the user's account waiting for the second leg to fire.

The headline cost is gas. Two transactions means two base-fee payments, two L1 calldata blobs, two relay overhead rounds. On Base the absolute numbers are tiny, but for an autotrader making hundreds of cycles a day the doubled overhead is real.

The subtler cost is what the on-chain surface looked like to indexers.

Why the visibility helper went blind

Every V4-native token launched through THRYX gets a per-token ThryxVisibilityHelper stamped at launch by LaunchV4FacetV3._recordVisibilityHelper. The helper is a tiny contract that emits a V2-style Uniswap Swap(sender, amount0In, amount1In, amount0Out, amount1Out, to) event. The point is to give aggregators (Dexscreener, Geckoterminal, Defined) a familiar V2 surface to scrape, since V4 PoolManager swaps don't emit the V2-shaped event those tools historically index against.

SwapFacet.swap() calls _pingVisibility after every successful trade. The problem: in the legacy 2-step flow, the second leg's swap() entry saw tokenIn = NEW THRYX = the token's configured pair. That triggered the pair-direct buy branch, which intentionally skipped the visibility ping because the swap was denominated in NEW, not in WETH. From the helper's perspective, the trade looked like an internal pair-side movement, not a user-facing ETH-denominated buy.

Even when we forced the ping through, the only WETH amount on hand was an after-the-fact estimate. The actual ETH the user paid had already been consumed in leg 1. The helper would have emitted amounts that didn't match the on-chain ETH cost, which is worse than not pinging at all.

The atomic design

The fix is a new Diamond facet, ThryxNewPairAtomicFacet, that knows msg.value directly because the entire user trade is one call. Two external functions, two selectors:

function buyTokenWithEth(address token, uint256 minTokenOut)
    external payable returns (uint256 tokenOut);
// selector 0x67811054

function sellTokenForEth(address token, uint256 tokenAmount, uint256 minEthOut)
    external returns (uint256 ethOut);
// selector 0xdc8227ba

The buy path: wrap ETH to WETH, V4 unlock to swap WETH->NEW through the bridge pool (BRIDGE_FEE=10000, BRIDGE_TICK_SPACING=200, hook 0x2ef7EF5c...C8), V4 unlock again to swap NEW->TOKEN through the per-token V4 pool, transfer the token out, ping the visibility helper with msg.value as the WETH side.

The sell path is the mirror image. transferFrom the user's tokens into the Diamond, V4 unlock TOKEN->NEW, V4 unlock NEW->WETH, withdraw the WETH to ETH, send ETH back to msg.sender, ping the helper with the actual ethOut as the WETH side.

Pool config is resolved per-token, with fallback to the global launch params:

function _resolvePoolConfig(LibThryxStorage.ThryxState storage s, address token)
    internal view returns (uint24 fee, int24 ts, address hook)
{
    hook = s.v4HookOf[token];
    if (hook == address(0)) hook = s.v4NativeHook;
    fee = s.v4FeeOf[token];
    if (fee == 0) fee = s.v4LaunchPoolFee;
    ts = s.v4TickSpacingOf[token];
    if (ts == 0) ts = s.v4LaunchPoolTickSpacing;
}

The V4 unlock callback pattern, in 90 seconds

Uniswap V4 swaps don't follow the V2/V3 router pattern of 'send tokens, get tokens back in one call.' Instead, the PoolManager uses a flash-accounting model: you call unlock(bytes data), the PoolManager calls unlockCallback(data) back into your contract, you do whatever swap/modifyLiquidity operations you want, and then you must settle the deltas (pay what you owe, take what's owed to you) before the callback returns. If your net balance with the PoolManager isn't zero at the end of the callback, the whole thing reverts.

Our atomic facet doesn't implement unlockCallback itself. Instead, _v4Unlock encodes a V4SwapCallbackParams payload and calls IV4PM_Atomic(s.v4PoolManager).unlock(data). The PoolManager invokes unlockCallback on whichever facet has that selector wired in the Diamond, which in our setup is SwapFacet. SwapFacet's existing handler reads the payload, executes the swap via poolManager.swap(), takes the output for the recipient address encoded in the payload (the Diamond itself, so we can stage the next leg), and settles the input by transferring from the Diamond to the PoolManager.

Because the Diamond is the recipient of both legs, the intermediate NEW THRYX never leaves the contract between legs. The user sees one transaction, pays one base-fee multiplier, and walks away with TOKEN (or ETH on sells). Internally there are two PoolManager.unlock() round-trips with two callback invocations.

Encoding the callback payload

function _v4Unlock(
    LibThryxStorage.ThryxState storage s,
    address tokenIn, address tokenOut, uint256 amountIn,
    uint24 feeTier, int24 tickSpacing, address hook
) internal {
    (address t0, address t1) = tokenIn < tokenOut ? (tokenIn, tokenOut) : (tokenOut, tokenIn);
    bool zeroForOne = tokenIn == t0;
    bytes memory data = abi.encode(
        t0, t1, feeTier, tickSpacing, zeroForOne,
        amountIn,
        uint256(0),     // minOut enforced at outer scope
        address(this),  // recipient = Diamond (output stays for next leg)
        tokenIn,
        hook
    );
    IV4PM_Atomic(s.v4PoolManager).unlock(data);
}

Two things worth calling out. First, the V4 PoolKey requires token0 < token1 ordered by address, so we sort and derive zeroForOne from whether the user's tokenIn is the lower address. Second, we enforce slippage in the outer scope (after both legs complete), not per-leg, by checking the final tokenOut delta against minTokenOut. Per-leg slippage would have rejected trades for spurious intermediate price movement that nets out across the two hops.

The visibility ping, done right this time

After both legs settle, the facet looks up the per-token helper from a deterministic storage slot:

bytes32 private constant VIS_HELPER_SEED =
    0x4255a58c8cdd156631973b15b20c8c21a36124fd14c4747d88a7fc5d756a619e;

// Slot = keccak256(abi.encode(token, VIS_HELPER_SEED))
address helper;
assembly {
    let m := mload(0x40)
    mstore(m, token)
    mstore(add(m, 0x20), VIS_HELPER_SEED)
    helper := sload(keccak256(m, 0x40))
}

Then sorts the (token, weth) pair to figure out which side gets which amounts and calls helper.recordTrade(...). On a buy, the helper emits Swap with amount0In or amount1In equal to msg.value (the real WETH paid), and the opposite Out slot equal to tokenOut. On a sell, the helper emits with the token amount on the input side and the actual ethOut on the output side.

The call is wrapped in a best-effort pattern. If the helper reverts or has been removed, the trade still settles. Visibility is observability, not consensus.

Deploy and cut

The facet was deployed and cut today on Base mainnet:

StepTx hash
Facet deploy0x29ffa53ec7c8491816dae45a3d360d25e3c3a3993f88b8df88715091a5e8a1eb
DiamondCut Add (2 selectors)0x434ca11a8644b36a27c1b04205ca8ff3167173fe33a9d12bc36b1703865a427d
Test launch (ATM538)0x62191799d511c381201756f36f11ea32cfc3bc6404636e1add483566d89a42bd
Atomic buy #10x9dd75cdee7aa8713bb452ff2c21a15bda9118e774f763804cf4c5825aff16879
Atomic buy #20x1ecc02537fbc55d5c2d0bcf81d4af51122ffcd67f79e44e142f7e06d220dbe35
Atomic sell0xd4882e74b22b2fd44aab7451870b190633a14b60060e0d9ad253495879a4e75b

Total ops cost (deploy + cut + three test trades + a throwaway launch) was about $0.10 of mainnet gas. Base is cheap. The diamondCut Add of two selectors took under 100k gas, including the unchanged-facet introspection guard our cut helper runs before mutating selector routing.

EIP-2535 in practice: why this shipped in an afternoon

THRYX is a Diamond proxy (EIP-2535). The user-facing address never changes (0x2F77b40c124645d25782CfBdfB1f54C1d76f2cCe), but the function selectors behind it route to whatever facet a previous diamondCut said they should. Adding a new entry point is two steps: deploy a fresh facet contract, then call diamondCut to register its selectors.

We didn't need a migration. We didn't need to redeploy unrelated code. We didn't need to coordinate frontends to point at a new contract. The existing SwapFacet (v3.13) and ThryxSwapNewFacet (v4) stayed in place untouched, and the new selectors live alongside them. Server code can continue using the 2-step flow while we test the atomic path, and if anything goes sideways we cut the two selectors back out in a single tx. Reversibility is the under-appreciated half of the Diamond pattern.

The constraint: shared storage. All facets read and write the same LibThryxStorage.ThryxState struct, accessed via a fixed slot. New facets must use the existing slot layout. Our ThryxState already had s.weth, s.v4PoolManager, s.v4HookOf, s.v4FeeOf, s.v4TickSpacingOf, s.v4NativeHook, s.v4LaunchPoolFee, s.v4LaunchPoolTickSpacing, so the facet pulls everything it needs without touching storage layout at all.

Honest reckoning: this does not fix DexScreener

We tested the atomic path on a freshly-launched throwaway token (ATM538 at 0x0a9b0d320A83c5Cc120E3B96e266FaFE5eed6236). Two buys and a sell, all atomic. The visibility helper emitted V2 Swap events with the correct WETH-equivalent amounts on every trade. We then queried DexScreener's API:

GET api.dexscreener.com/latest/dex/tokens/0x0a9b...
  pairs: [V4 ATM538/THRYX]
    priceNative: 38.1657 THRYX
    priceUsd:    null
    liquidity:   null
    txns24h:     { buys: 2, sells: 0 }

GET api.dexscreener.com/latest/dex/pairs/base/0xE055cbc6...
  { pair: null, pairs: null }

DexScreener indexed the underlying V4 pool natively, but it could not price the trade in USD because NEW THRYX is not a recognized quote currency on its router map. And the visibility helper itself was not indexed at all. The V2 Swap events we worked so hard to emit correctly are being ignored.

The root cause: DexScreener verifies V2-style pairs by calling factory().getPair(token0, token1) on the helper's declared factory and confirming the returned address matches the helper. Our helper hardcodes Aerodrome's V2 factory as its factory(), but Aerodrome's factory has no record of our synthetic pair. The verification fails silently and the helper's events get dropped on the floor.

The atomic facet is still strictly valuable. It saves a tx per trade, the gas is roughly halved, the semantics are cleaner, autotrader cycles get faster, and the visibility ping carries correct amounts for any indexer that doesn't do the getPair() check. But the USD-pricing fix for NEW-paired tokens is architecturally separate from the visibility ping. The real options:

Option B is the path we're most likely to take. It's cheap, atomic with launch, and it preserves the NEW adoption arc because TOKEN/NEW remains the primary pool and TOKEN/WETH is just a thin indexer-bait twin.

What ships in production next

Server-side, the buy/sell routes still run the 2-step flow. The atomic selectors are live on the Diamond but un-routed at the application layer. The wiring decision is gated on a small drlor call (gas accounting, autotrader behavior under the new path, edge cases on stuck-NEW recovery). We expect to switch the server route in the next pass.

Frontend impact is zero. The user sees the same buy modal. Slippage protection moves from server-side guard rails to on-chain minOut enforcement, which is strictly stronger than the prior model where leg 1 could partially fill at an unexpected price and leg 2 would chase it.

Frequently asked

Why not use a Universal Router or 1inch?
Aggregators don't know about our visibility-helper ping or our per-token V4 hooks. Going through a Diamond facet keeps the helper coupling, lets us enforce per-token routing rules, and means the trade pays platform fees through our own SwapFacet path rather than an external router's fee model.
What stops a malicious user from passing a non-NEW-paired token?
The facet itself is permissive — it just routes whatever pool config s.v4HookOf/s.v4FeeOf returns for the token. If a token is WETH-paired, leg 2 would try to swap NEW->TOKEN on a NEW pool that doesn't exist and revert at the V4 unlock. The server-side routing layer is responsible for sending atomic calls only for NEW-paired tokens.
Can the visibility helper be replaced without re-cutting the facet?
Yes. The helper address is read from storage via the deterministic slot derived from VIS_HELPER_SEED. LaunchV4FacetV3._recordVisibilityHelper writes that slot at launch; an admin facet can overwrite it. The atomic facet only does a best-effort .call() on the helper address, so replacing the helper changes which contract receives recordTrade pings with no facet redeploy needed.
What's the gas savings on a real trade?
On Base, the prior 2-tx flow ran around 480-540k gas total (two relay calls, two V4 unlock callbacks, two fee-router pings). The atomic call lands in roughly 280-320k gas. Roughly 40% reduction in gas units, plus elimination of the second base-fee payment.
Does this work for tokens still on the legacy bonding curve?
No. The atomic facet routes through V4 pools on both legs. Bonding-curve tokens still go through the curve path in LaunchFacet, which is a separate flow. The atomic facet only applies to V4-native NEW-paired tokens (the default for new launches).
What's the reentrancy story?
Both entry points wrap their bodies in LibDiamondGuard.reentrancyEnter() / reentrancyExit(), which uses a transient lock in shared Diamond storage. The unlock callback into SwapFacet is on the same Diamond, so it shares that guard. External token transfers happen after both V4 legs settle, so an ERC-777 hook on the token side cannot re-enter mid-swap.

Files and selectors

ArtifactAddress / selector
Diamond0x2F77b40c124645d25782CfBdfB1f54C1d76f2cCe
ThryxNewPairAtomicFacet0xecca331AD5AE96BB82e2729e96088c2EF4903b2D
buyTokenWithEth(address,uint256)0x67811054
sellTokenForEth(address,uint256,uint256)0xdc8227ba
NEW THRYX0x49e4cf7097C497008800eDC80Dc76906eDD189DD
V6.4 sandwich-trap hook0x2ef7EF5ced564A8eF6549271dBcD8401C6F300C8
Bridge pool fee / tick spacing10000 / 200
Source filecontracts/patches/ThryxNewPairAtomicFacet.sol

The full source is 214 lines, no external dependencies beyond OpenZeppelin's SafeERC20 + IERC20 and the existing LibThryxStorage/LibThryxErrors/LibDiamondGuard libs the rest of the Diamond shares. Worth a read if you're building anything that needs to compose multiple V4 swaps atomically while preserving aggregator visibility on Base.

Create Your Token