Skip to main content
In Railnet smart contracts, a Yield Source is connected via a Vehicle adapter. See Glossary for all terminology.
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 path instead. You can check the 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]--> WAITING --[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

1

Inherit from SingleAssetBaseVehicle

Your async adapter inherits from the same base as sync adapters. The difference is in how you implement the lifecycle methods.
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
}
2

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.
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;
}
3

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.
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;
}
4

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.
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);
}
5

Handle recovery

If a withdrawal fails, the query transitions to RECOVERING. Implement _recover() to return assets to the user.
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);
}

Monitoring async queries

To track the progress of an async redemption, poll the state() view function:
State currentState = vehicle.state(query);
// PROCESSING — still waiting for protocol conditions
// UNLOCKING — ready to settle, call unlock()
// RECOVERING — failed, call recover()

Protocol-specific patterns

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.
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.

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 for the full setup guide.

Next steps

Set up keeper automation

Automate state transitions for your async adapter.

Write adapter tests

Test both sync and async paths with the STEAM testing framework.