diff --git a/script/DeployUniversalRouterExecutor.s.sol b/script/DeployUniversalRouterExecutor.s.sol
new file mode 100644
index 00000000..a406f8ea
--- /dev/null
+++ b/script/DeployUniversalRouterExecutor.s.sol
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+pragma solidity ^0.8.13;
+
+import "forge-std/console2.sol";
+import "forge-std/Script.sol";
+import {UniversalRouterExecutor} from "../src/sample-executors/UniversalRouterExecutor.sol";
+import {IReactor} from "../src/interfaces/IReactor.sol";
+import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
+
+contract DeployUniversalRouterExecutor is Script {
+    function setUp() public {}
+
+    function run() public returns (UniversalRouterExecutor executor) {
+        uint256 privateKey = vm.envUint("FOUNDRY_PRIVATE_KEY");
+        IReactor reactor = IReactor(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_REACTOR"));
+        // can encode with cast abi-encode "foo(address[])" "[addr1, addr2, ...]"
+        bytes memory encodedAddresses =
+            vm.envBytes("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_WHITELISTED_CALLERS_ENCODED");
+        address owner = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_OWNER");
+        address universalRouter = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_UNIVERSALROUTER");
+        IPermit2 permit2 = IPermit2(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_PERMIT2"));
+
+        address[] memory decodedAddresses = abi.decode(encodedAddresses, (address[]));
+
+        vm.startBroadcast(privateKey);
+        executor = new UniversalRouterExecutor{salt: 0x00}(decodedAddresses, reactor, owner, universalRouter, permit2);
+        vm.stopBroadcast();
+
+        console2.log("UniversalRouterExecutor", address(executor));
+        console2.log("owner", executor.owner());
+    }
+}
diff --git a/src/external/IUniversalRouter.sol b/src/external/IUniversalRouter.sol
new file mode 100644
index 00000000..6c70134e
--- /dev/null
+++ b/src/external/IUniversalRouter.sol
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+pragma solidity ^0.8.24;
+
+interface IUniversalRouter {
+    /// @notice Thrown when a required command has failed
+    error ExecutionFailed(uint256 commandIndex, bytes message);
+
+    /// @notice Thrown when attempting to send ETH directly to the contract
+    error ETHNotAccepted();
+
+    /// @notice Thrown when executing commands with an expired deadline
+    error TransactionDeadlinePassed();
+
+    /// @notice Thrown when attempting to execute commands and an incorrect number of inputs are provided
+    error LengthMismatch();
+
+    // @notice Thrown when an address that isn't WETH tries to send ETH to the router without calldata
+    error InvalidEthSender();
+
+    /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired.
+    /// @param commands A set of concatenated commands, each 1 byte in length
+    /// @param inputs An array of byte strings containing abi encoded inputs for each command
+    /// @param deadline The deadline by which the transaction must be executed
+    function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable;
+}
diff --git a/src/sample-executors/UniversalRouterExecutor.sol b/src/sample-executors/UniversalRouterExecutor.sol
new file mode 100644
index 00000000..39e254b4
--- /dev/null
+++ b/src/sample-executors/UniversalRouterExecutor.sol
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+pragma solidity ^0.8.0;
+
+import {Owned} from "solmate/src/auth/Owned.sol";
+import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
+import {ERC20} from "solmate/src/tokens/ERC20.sol";
+import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
+import {IReactorCallback} from "../interfaces/IReactorCallback.sol";
+import {IReactor} from "../interfaces/IReactor.sol";
+import {CurrencyLibrary} from "../lib/CurrencyLibrary.sol";
+import {ResolvedOrder, SignedOrder} from "../base/ReactorStructs.sol";
+
+/// @notice A fill contract that uses UniversalRouter to execute trades
+contract UniversalRouterExecutor is IReactorCallback, Owned {
+    using SafeTransferLib for ERC20;
+    using CurrencyLibrary for address;
+
+    /// @notice thrown if reactorCallback is called with a non-whitelisted filler
+    error CallerNotWhitelisted();
+    /// @notice thrown if reactorCallback is called by an address other than the reactor
+    error MsgSenderNotReactor();
+
+    address public immutable universalRouter;
+    mapping(address => bool) whitelistedCallers;
+    IReactor public immutable reactor;
+    IPermit2 public immutable permit2;
+
+    modifier onlyWhitelistedCaller() {
+        if (whitelistedCallers[msg.sender] == false) {
+            revert CallerNotWhitelisted();
+        }
+        _;
+    }
+
+    modifier onlyReactor() {
+        if (msg.sender != address(reactor)) {
+            revert MsgSenderNotReactor();
+        }
+        _;
+    }
+
+    constructor(
+        address[] memory _whitelistedCallers,
+        IReactor _reactor,
+        address _owner,
+        address _universalRouter,
+        IPermit2 _permit2
+    ) Owned(_owner) {
+        for (uint256 i = 0; i < _whitelistedCallers.length; i++) {
+            whitelistedCallers[_whitelistedCallers[i]] = true;
+        }
+        reactor = _reactor;
+        universalRouter = _universalRouter;
+        permit2 = _permit2;
+    }
+
+    /// @notice assume that we already have all output tokens
+    function execute(SignedOrder calldata order, bytes calldata callbackData) external onlyWhitelistedCaller {
+        reactor.executeWithCallback(order, callbackData);
+    }
+
+    /// @notice assume that we already have all output tokens
+    function executeBatch(SignedOrder[] calldata orders, bytes calldata callbackData) external onlyWhitelistedCaller {
+        reactor.executeBatchWithCallback(orders, callbackData);
+    }
+
+    /// @notice fill UniswapX orders using UniversalRouter
+    /// @param callbackData It has the below encoded:
+    /// address[] memory tokensToApproveForUniversalRouter: Max approve these tokens to permit2 and universalRouter
+    /// address[] memory tokensToApproveForReactor: Max approve these tokens to reactor
+    /// bytes memory data: execution data
+    function reactorCallback(ResolvedOrder[] calldata, bytes calldata callbackData) external onlyReactor {
+        (
+            address[] memory tokensToApproveForUniversalRouter,
+            address[] memory tokensToApproveForReactor,
+            bytes memory data
+        ) = abi.decode(callbackData, (address[], address[], bytes));
+
+        unchecked {
+            for (uint256 i = 0; i < tokensToApproveForUniversalRouter.length; i++) {
+                // Max approve token to permit2
+                ERC20(tokensToApproveForUniversalRouter[i]).safeApprove(address(permit2), type(uint256).max);
+                // Max approve token to universalRouter via permit2
+                permit2.approve(
+                    tokensToApproveForUniversalRouter[i], address(universalRouter), type(uint160).max, type(uint48).max
+                );
+            }
+
+            for (uint256 i = 0; i < tokensToApproveForReactor.length; i++) {
+                ERC20(tokensToApproveForReactor[i]).safeApprove(address(reactor), type(uint256).max);
+            }
+        }
+
+        (bool success, bytes memory returnData) = universalRouter.call(data);
+        if (!success) {
+            assembly {
+                revert(add(returnData, 32), mload(returnData))
+            }
+        }
+
+        // transfer any native balance to the reactor
+        // it will refund any excess
+        if (address(this).balance > 0) {
+            CurrencyLibrary.transferNative(address(reactor), address(this).balance);
+        }
+    }
+
+    /// @notice Transfer all ETH in this contract to the recipient. Can only be called by owner.
+    /// @param recipient The recipient of the ETH
+    function withdrawETH(address recipient) external onlyOwner {
+        SafeTransferLib.safeTransferETH(recipient, address(this).balance);
+    }
+
+    /// @notice Transfer the entire balance of an ERC20 token in this contract to a recipient. Can only be called by owner.
+    /// @param token The ERC20 token to withdraw
+    /// @param to The recipient of the tokens
+    function withdrawERC20(ERC20 token, address to) external onlyOwner {
+        token.safeTransfer(to, token.balanceOf(address(this)));
+    }
+
+    /// @notice Necessary for this contract to receive ETH
+    receive() external payable {}
+}
diff --git a/test/integration/UniversalRouterExecutorIntegration.t.sol b/test/integration/UniversalRouterExecutorIntegration.t.sol
new file mode 100644
index 00000000..3011c583
--- /dev/null
+++ b/test/integration/UniversalRouterExecutorIntegration.t.sol
@@ -0,0 +1,156 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+pragma solidity ^0.8.0;
+
+import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
+import {Test} from "forge-std/Test.sol";
+import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
+import {ERC20} from "solmate/src/tokens/ERC20.sol";
+import {UniversalRouterExecutor} from "../../src/sample-executors/UniversalRouterExecutor.sol";
+import {InputToken, OrderInfo, SignedOrder} from "../../src/base/ReactorStructs.sol";
+import {OrderInfoBuilder} from "../util/OrderInfoBuilder.sol";
+import {DutchOrderReactor, DutchOrder, DutchInput, DutchOutput} from "../../src/reactors/DutchOrderReactor.sol";
+import {OutputsBuilder} from "../util/OutputsBuilder.sol";
+import {PermitSignature} from "../util/PermitSignature.sol";
+import {IReactor} from "../../src/interfaces/IReactor.sol";
+import {IUniversalRouter} from "../../src/external/IUniversalRouter.sol";
+
+contract UniversalRouterExecutorIntegrationTest is Test, PermitSignature {
+    using OrderInfoBuilder for OrderInfo;
+    using SafeTransferLib for ERC20;
+
+    ERC20 constant USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
+    ERC20 constant USDT = ERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);
+
+    uint256 constant USDC_ONE = 1e6;
+
+    // UniversalRouter with V4 support
+    IUniversalRouter universalRouter = IUniversalRouter(0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af);
+    IPermit2 permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
+
+    address swapper;
+    uint256 swapperPrivateKey;
+    address whitelistedCaller;
+    address owner;
+    UniversalRouterExecutor universalRouterExecutor;
+    DutchOrderReactor reactor;
+
+    // UniversalRouter commands
+    uint256 constant V3_SWAP_EXACT_IN = 0x00;
+
+    function setUp() public {
+        swapperPrivateKey = 0xbeef;
+        swapper = vm.addr(swapperPrivateKey);
+        vm.label(swapper, "swapper");
+        whitelistedCaller = makeAddr("whitelistedCaller");
+        owner = makeAddr("owner");
+        // 02-10-2025
+        vm.createSelectFork(vm.envString("FOUNDRY_RPC_URL"), 21818802);
+        reactor = new DutchOrderReactor(permit2, address(0));
+        address[] memory whitelistedCallers = new address[](1);
+        whitelistedCallers[0] = whitelistedCaller;
+        universalRouterExecutor = new UniversalRouterExecutor(
+            whitelistedCallers, IReactor(address(reactor)), owner, address(universalRouter), permit2
+        );
+
+        vm.prank(swapper);
+        USDC.approve(address(permit2), type(uint256).max);
+
+        deal(address(USDC), swapper, 100 * 1e6);
+    }
+
+    function baseTest(DutchOrder memory order) internal {
+        _baseTest(order, false, "");
+    }
+
+    function _baseTest(DutchOrder memory order, bool expectRevert, bytes memory revertData) internal {
+        address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
+        tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);
+
+        address[] memory tokensToApproveForReactor = new address[](1);
+        tokensToApproveForReactor[0] = address(USDT);
+
+        bytes memory commands = hex"00";
+        bytes[] memory inputs = new bytes[](1);
+        // V3 swap USDC -> USDT, with recipient as universalRouterExecutor
+        inputs[0] =
+            hex"0000000000000000000000002e234DAe75C793f67A35089C9d99245E1C58470b0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000090972200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000064dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000";
+
+        bytes memory data =
+            abi.encodeWithSelector(IUniversalRouter.execute.selector, commands, inputs, uint256(block.timestamp + 1000));
+
+        vm.prank(whitelistedCaller);
+        if (expectRevert) {
+            vm.expectRevert(revertData);
+        }
+        universalRouterExecutor.execute(
+            SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(permit2), order)),
+            abi.encode(tokensToApproveForPermit2AndUniversalRouter, tokensToApproveForReactor, data)
+        );
+    }
+
+    function test_universalRouterExecutor() public {
+        DutchOrder memory order = DutchOrder({
+            info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
+            decayStartTime: block.timestamp - 100,
+            decayEndTime: block.timestamp + 100,
+            input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
+            outputs: OutputsBuilder.singleDutch(address(USDT), 9 * USDC_ONE, 9 * USDC_ONE, address(swapper))
+        });
+
+        address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
+        tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);
+
+        address[] memory tokensToApproveForReactor = new address[](1);
+        tokensToApproveForReactor[0] = address(USDT);
+
+        uint256 swapperInputBalanceBefore = USDC.balanceOf(swapper);
+        uint256 swapperOutputBalanceBefore = USDT.balanceOf(swapper);
+
+        baseTest(order);
+
+        assertEq(USDC.balanceOf(swapper), swapperInputBalanceBefore - 10 * USDC_ONE);
+        assertEq(USDT.balanceOf(swapper), swapperOutputBalanceBefore + 9 * USDC_ONE);
+        // Expect some USDT to be left in the executor from the swap
+        assertGe(USDT.balanceOf(address(universalRouterExecutor)), 0);
+    }
+
+    function test_universalRouterExecutor_TooLittleReceived() public {
+        DutchOrder memory order = DutchOrder({
+            info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
+            decayStartTime: block.timestamp - 100,
+            decayEndTime: block.timestamp + 100,
+            input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
+            // Too much output
+            outputs: OutputsBuilder.singleDutch(address(USDT), 11 * USDC_ONE, 11 * USDC_ONE, address(swapper))
+        });
+
+        _baseTest(order, true, bytes("TRANSFER_FROM_FAILED"));
+    }
+
+    function test_universalRouterExecutor_onlyOwner() public {
+        address nonOwner = makeAddr("nonOwner");
+        address recipient = makeAddr("recipient");
+        uint256 recipientBalanceBefore = recipient.balance;
+        uint256 recipientUSDCBalanceBefore = USDC.balanceOf(recipient);
+
+        vm.deal(address(universalRouterExecutor), 1 ether);
+        deal(address(USDC), address(universalRouterExecutor), 100 * USDC_ONE);
+
+        vm.prank(nonOwner);
+        vm.expectRevert("UNAUTHORIZED");
+        universalRouterExecutor.withdrawETH(recipient);
+
+        vm.prank(nonOwner);
+        vm.expectRevert("UNAUTHORIZED");
+        universalRouterExecutor.withdrawERC20(USDC, recipient);
+
+        vm.prank(owner);
+        universalRouterExecutor.withdrawETH(recipient);
+        assertEq(address(recipient).balance, recipientBalanceBefore + 1 ether);
+
+        vm.prank(owner);
+        universalRouterExecutor.withdrawERC20(USDC, recipient);
+        assertEq(USDC.balanceOf(recipient), recipientUSDCBalanceBefore + 100 * USDC_ONE);
+        assertEq(USDC.balanceOf(address(universalRouterExecutor)), 0);
+    }
+}