Skip to content
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

# Reactor Contracts

This directory contains the core RFQ settlement contracts used by the Symbiotic instant redemption flow. The pair is intentionally small:
This directory contains the core RFQ settlement contracts used by the Symbiotic instant redemption flow:

- `Reactor.sol` validates the signed order, pulls the input through Permit2, routes input into the instant redemption adapter, and enforces output delivery.
- `Executor.sol` is an example role-gated execution surface that calls the Reactor, performs adapter swaps, runs any post-swap execution payload, and approves output transfers back to the Reactor.
- `Router.sol` is an ownerless, user-directed execution surface that directly funds registered LiquidLane adapters from the caller, executes their calldata inline, and transfers declared outputs.

> [!NOTE]
> `Executor.sol` is not a protocol requirement. It is an example filler-side executor contract that demonstrates one way to integrate with `Reactor`. Fillers can deploy their own executor implementation as long as it satisfies the expected Reactor callback flow.
Expand All @@ -14,6 +15,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic

- [Reactor.sol](src/Reactor.sol)
- [Executor.sol](src/Executor.sol)
- [Router.sol](src/Router.sol)

## Flow

Expand All @@ -24,20 +26,49 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic
5. `Executor.execute(...)` performs the adapter swaps and any opaque execution payload.
6. `Reactor` enforces the requested outputs and emits the fill event used by the indexer.

## User-directed swap flow

1. The user approves the input ERC-20 to `Router` using an ordinary ERC-20 allowance.
2. The user requests an unsigned transaction from the backend `/api/v1/swap` endpoint.
3. The backend returns one or more solver legs, each containing an adapter, an input amount, and complete signed-swap or discounted-swap calldata.
4. `Router.execute(tokenIn, calls, outputs, deadline)` transfers each leg directly from the user to its adapter, invokes the provided calldata inline, and then transfers the declared outputs to their recipients.

Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`. The Router does not inspect adapter calldata or add an outer authorization layer: signature, nonce, selector, and quote validation remain the adapter's responsibility. This permits both signed-swap and discounted-swap calldata. The calls and output transfers run in caller-supplied order and the entire batch is atomic.

The ABI tuple order is:

```solidity
struct SwapCall {
address adapter;
uint256 amountIn;
bytes data;
}

struct Output {
address token;
address recipient;
uint256 amount;
}
```

The Router intentionally performs no array, amount, token, recipient, selector, input-consumption, output-delta, or surplus validation. ERC-20 transfers and adapter calls define success. Output entries are ordinary transfers from the Router's current balances, and undeclared surplus remains in the Router. Use the deadline overload when the user wants Router-level expiry.

## Test locally

This folder is part of the root Foundry workspace, so run commands from the repository root:

```bash
forge build
forge test --match-path rfq/reactor/test/Reactor.t.sol
forge test --match-path rfq/reactor/test/Router.t.sol
```

## Deploy

The repository includes helper script for deploying the example executor:
The repository includes helper scripts for deploying the example executor and Router:

- `rfq/reactor/script/deploy/DeployExecutor.s.sol`
- `rfq/reactor/script/deploy/DeployRouter.s.sol`

Example `Executor` deployment:

Expand All @@ -52,14 +83,29 @@ forge script rfq/reactor/script/deploy/DeployExecutor.s.sol:DeployExecutorScript
--broadcast
```

Example `Router` deployment:

```bash
cd <repo-root>
LIQUID_LANE_ADAPTER_FACTORY=0x... \
forge script rfq/reactor/script/deploy/DeployRouter.s.sol:DeployRouterScript \
--rpc-url "$RPC_URL" \
--account "$ACCOUNT" \
--sender "$SENDER" \
--broadcast
```

## Files to know

- `src/Reactor.sol`
- `src/Executor.sol`
- `src/Router.sol`
- `src/interfaces`
- `test/Reactor.t.sol`
- `test/Router.t.sol`

## Notes

- `Executor` is role-gated through `CALLER_ROLE`.
- `Reactor` uses Permit2 witness transfers and the instant redemption adapter as its execution primitives.
- `Router` uses ordinary ERC-20 allowance, validates only adapter registration and an optional deadline, and forwards adapter calldata unchanged.
19 changes: 19 additions & 0 deletions script/deploy/DeployRouter.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Script, console2} from "forge-std/Script.sol";

import {Router} from "../../src/Router.sol";

/// @notice Deploys the ownerless user-directed Router.
contract DeployRouterScript is Script {
function run() public returns (Router router) {
address liquidLaneAdapterFactory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY");

vm.startBroadcast();
router = new Router(liquidLaneAdapterFactory);
vm.stopBroadcast();

console2.log("Deployed Router:", address(router));
}
}
53 changes: 53 additions & 0 deletions src/Router.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2026 Symbiotic
pragma solidity 0.8.28;

import {IRegistry} from "./interfaces/IRegistry.sol";
import {IRouter} from "./interfaces/IRouter.sol";

import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// @title Router
/// @notice Atomically funds registered adapters and transfers their outputs to declared recipients.
contract Router is IRouter, ReentrancyGuard {
using Address for address;
using SafeERC20 for IERC20;

/// @notice Factory registry used to validate LiquidLane adapter targets.
address public immutable LIQUID_LANE_ADAPTER_FACTORY;

constructor(address liquidLaneAdapterFactory) {
LIQUID_LANE_ADAPTER_FACTORY = liquidLaneAdapterFactory;
}

/// @inheritdoc IRouter
function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) public nonReentrant {
uint256 callsLength = calls.length;
for (uint256 i; i < callsLength; ++i) {
SwapCall calldata swapCall = calls[i];
if (!IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(swapCall.adapter)) {
revert InvalidAdapter();
}

IERC20(tokenIn).safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn);
swapCall.adapter.functionCall(swapCall.data);
}

uint256 outputsLength = outputs.length;
for (uint256 i; i < outputsLength; ++i) {
Output calldata output = outputs[i];
IERC20(output.token).safeTransfer(output.recipient, output.amount);
}
}

/// @inheritdoc IRouter
function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external {
if (block.timestamp > deadline) {
revert Expired();
}
execute(tokenIn, calls, outputs);
}
}
8 changes: 8 additions & 0 deletions src/interfaces/IRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2026 Symbiotic
pragma solidity ^0.8.0;

/// @notice Minimal LiquidLane adapter-factory registry interface.
interface IRegistry {
function isEntity(address entity) external view returns (bool);
}
30 changes: 30 additions & 0 deletions src/interfaces/IRouter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2026 Symbiotic
pragma solidity ^0.8.0;

/// @notice User-directed batch execution interface for registered LiquidLane adapters.
interface IRouter {
/// @notice One adapter leg.
/// @param adapter Factory-registered LiquidLane adapter that receives the input and executes `data`.
/// @param amountIn Common input-token amount funded directly from the caller.
/// @param data Complete adapter calldata.
struct SwapCall {
address adapter;
uint256 amountIn;
bytes data;
}

/// @notice Output payment made after all adapter calls complete.
struct Output {
address token;
address recipient;
uint256 amount;
}

error Expired();
error InvalidAdapter();

function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address);
function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external;
function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external;
}
Loading