> ## Documentation Index
> Fetch the complete documentation index at: https://docs.railnet.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a custom adapter

> Build an adapter for yield sources with non-standard interfaces or async operations

<Info>In Railnet smart contracts, a Yield Source is connected via a **Vehicle** adapter. See [Glossary](/developers/glossary) for all terminology.</Info>

Some DeFi protocols cannot complete withdrawals immediately. Cooldown periods, withdrawal queues, and per-address restrictions all require your adapter to handle operations across multiple transactions. This guide explains when you need a custom async adapter, how the state machine works differently, and how to implement the async pattern.

## When you need a custom adapter

You must build a custom async adapter if your protocol has any of the following:

* **Cooldown periods** — the protocol requires a waiting period before assets can be claimed (e.g., Ethena's `sUSDe` unstaking cooldown)
* **Withdrawal queues** — the protocol processes withdrawals in batches or FIFO order (e.g., Syrup/Maple Finance)
* **Per-address restrictions** — the protocol limits active requests to one per wallet address

If your protocol allows immediate deposits and withdrawals, use the [wrap an existing vault](/developers/vehicles/wrap-existing-vault) path instead. You can check the [Supported protocols](/developers/vehicles/supported-protocols) table to see which protocols already have native async Vehicles.

## Async vs sync state flow

The key difference is the `PROCESSING` state. In a sync adapter, `create()` transitions directly to `UNLOCKING`. In an async adapter, `create()` transitions to `PROCESSING`, where the query remains until the external condition is met.

**Synchronous flow:**

```
EMPTY --[create()]--> UNLOCKING --[unlock()]--> SETTLED
```

**Asynchronous flow:**

```
EMPTY --[create()]--> PROCESSING --[external]--> UNLOCKING --[unlock()]--> SETTLED
```

**With waiting (cooldown/oracle):**

```
EMPTY --[create()]--> PROCESSING --[external]--> PAUSED --[resume()]--> PROCESSING --[external]--> UNLOCKING --[unlock()]--> SETTLED
```

The transition from `PROCESSING` to `UNLOCKING` is protocol-driven — it happens when the underlying protocol's conditions are met (e.g., `block.timestamp > cooldownEnd`). The adapter's `state()` view function checks these conditions and reports the updated state.

## The Account Clone pattern

Protocols with per-address restrictions create a problem: if all redemptions flow through a single adapter address, only one withdrawal can be active at a time.

Railnet solves this with the **Account Clone pattern**. When an async redemption starts:

1. The adapter deploys a lightweight `Account` contract clone.
2. Assets (e.g., pool tokens) transfer to this specific Account.
3. The Account initiates the protocol interaction (e.g., `requestRedeem` or `cooldownShares`).
4. The query stores the Account address for later retrieval.

Each redemption gets its own identity on the underlying protocol, enabling hundreds of concurrent async operations without bottlenecking.

## Implementation guide

<Steps>
  <Step title="Inherit from SingleAssetBaseVehicle">
    Your async adapter inherits from the same base as sync adapters. The difference is in how you implement the lifecycle methods.

    <CodeGroup>
      ```solidity Solidity theme={null}
      import {SingleAssetBaseVehicle} from "src/vehicles/base/SingleAssetBaseVehicle.sol";
      import {Account} from "src/common/Account.sol";

      contract MyAsyncVehicle is SingleAssetBaseVehicle {
          // Per-query Account clones for isolation
          mapping(bytes32 => address) internal _queryAccounts;

          // ... initialization logic
      }
      ```

      ```typescript TypeScript theme={null}
      // Coming soon
      ```
    </CodeGroup>
  </Step>

  <Step title="Implement create with PROCESSING state">
    For deposits, your `_create()` may still return `UNLOCKING` if the protocol accepts deposits synchronously. For redemptions, return `PROCESSING` to signal that the operation requires time.

    <CodeGroup>
      ```solidity Solidity theme={null}
      function _create(Query calldata query) internal override returns (State) {
          if (query.mode == Mode.DEPOSIT) {
              // Deposits are synchronous — deposit into protocol immediately
              _depositToProtocol(query.input[0].value);
              return State.UNLOCKING;
          }

          // Redeems are asynchronous — deploy Account clone and initiate withdrawal
          Account account = Account(_deployAccountClone());
          _queryAccounts[QueryLib.id(query)] = address(account);

          // Transfer shares to the Account and initiate protocol withdrawal
          _transferToAccount(account, query.input[0].value);
          account.performCall(
              address(protocol),
              0,
              abi.encodeCall(protocol.requestRedeem, (query.input[0].value))
          );

          return State.PROCESSING;
      }
      ```

      ```typescript TypeScript theme={null}
      // Coming soon
      ```
    </CodeGroup>
  </Step>

  <Step title="Implement state checking">
    Override the `_state()` function to check whether the external condition is met. When it is, return `UNLOCKING` to signal that the query is ready for settlement.

    <CodeGroup>
      ```solidity Solidity theme={null}
      function _state(Query calldata query, State currentState)
          internal
          view
          override
          returns (State)
      {
          if (currentState != State.PROCESSING) return currentState;

          address account = _queryAccounts[QueryLib.id(query)];

          // Check if the protocol has processed the withdrawal
          if (_isWithdrawalReady(account)) {
              return State.UNLOCKING;
          }

          return State.PROCESSING;
      }
      ```

      ```typescript TypeScript theme={null}
      // Coming soon
      ```
    </CodeGroup>
  </Step>

  <Step title="Implement unlock for async redeems">
    When the query reaches `UNLOCKING`, the `_unlock()` function claims assets from the Account clone and transfers them to the receiver.

    <CodeGroup>
      ```solidity Solidity theme={null}
      function _unlock(Query calldata query) internal override returns (State, Asset[] memory) {
          if (query.mode == Mode.DEPOSIT) {
              // Standard sync deposit unlock — mint shares to receiver
              return _unlockDeposit(query);
          }

          // Async redeem unlock — claim assets from Account
          address account = _queryAccounts[QueryLib.id(query)];
          uint256 assets = _claimFromAccount(account);

          Asset[] memory output = new Asset[](1);
          output[0] = Asset({asset: address(_asset), value: assets});

          // Transfer assets to receiver
          IERC20(_asset).safeTransfer(query.receiver, assets);

          return (State.SETTLED, output);
      }
      ```

      ```typescript TypeScript theme={null}
      // Coming soon
      ```
    </CodeGroup>
  </Step>

  <Step title="Handle recovery">
    If a withdrawal fails, the query transitions to `RECOVERING`. Implement `_recover()` to return assets to the user.

    <CodeGroup>
      ```solidity Solidity theme={null}
      function _recover(Query calldata query)
          internal
          override
          returns (State, Asset[] memory)
      {
          address account = _queryAccounts[QueryLib.id(query)];
          uint256 recovered = _recoverFromAccount(account);

          Asset[] memory output = new Asset[](1);
          output[0] = Asset({asset: address(this), value: recovered});

          // Return shares to receiver
          _transfer(address(this), query.receiver, recovered);

          return (State.REJECTED, output);
      }
      ```

      ```typescript TypeScript theme={null}
      // Coming soon
      ```
    </CodeGroup>
  </Step>
</Steps>

## Monitoring async queries

To track the progress of an async redemption, poll the `state()` view function:

<CodeGroup>
  ```solidity Solidity theme={null}
  State currentState = vehicle.state(query);
  // PROCESSING — still waiting for protocol conditions
  // UNLOCKING — ready to settle, call unlock()
  // RECOVERING — failed, call recover()
  ```

  ```typescript TypeScript theme={null}
  // Coming soon
  ```
</CodeGroup>

## Protocol-specific patterns

<AccordionGroup>
  <Accordion title="Ethena (cooldown pattern)">
    Ethena's `sUSDe` requires a cooldown period before unstaking. The adapter:

    1. Deploys an Account clone and calls `cooldownShares()` on the Ethena contract.
    2. The query stays in `PROCESSING` until `block.timestamp > cooldownEnd`.
    3. The `state()` function checks the timestamp and transitions to `UNLOCKING` when ready.
    4. On `unlock()`, the Account claims the unstaked USDe and transfers it to the receiver.
  </Accordion>

  <Accordion title="Syrup (queue pattern)">
    Syrup (Maple Finance) processes withdrawals in batches through a withdrawal queue. The adapter:

    1. Deploys an Account clone and calls `requestRedeem()` on the Syrup pool.
    2. The query stays in `PROCESSING` until the pool manager processes the batch.
    3. The `state()` function checks the withdrawal manager for completion.
    4. On `unlock()`, the Account claims the assets and transfers them to the receiver.

    The Account Clone pattern is critical here because Syrup enforces a one-request-per-account limitation.
  </Accordion>
</AccordionGroup>

## Keeper integration

In production, you do not expect users to manually poll `state()` and call `unlock()`. Instead, a **keeper bot** automates these transitions:

1. The keeper monitors all active queries by polling `state()`.
2. When a query transitions to `UNLOCKING`, the keeper calls `unlock()`.
3. When a query transitions to `RECOVERING`, the keeper calls `recover()`.

See [Set up keeper automation](/developers/contracts/keeper) for the full setup guide.

## Next steps

<CardGroup cols={2}>
  <Card title="Set up keeper automation" icon="robot" href="/developers/contracts/keeper">
    Automate state transitions for your async adapter.
  </Card>

  <Card title="Write adapter tests" icon="flask-vial" href="/developers/contracts/testing">
    Test both sync and async paths with the STEAM testing framework.
  </Card>
</CardGroup>
