Skip to content

Expose a RouterApi runtime API for off-chain quoting #1463

@hantoniu-codeberg

Description

@hantoniu-codeberg

Expose a RouterApi runtime API for off-chain quoting

The gap

pallet_route_executor has everything needed to quote a swap without executing it:

  • Pallet::<T>::calculate_expected_amount_out(&route, amount_in) -> Result<Balance, DispatchError> (lib.rs:624)
  • Pallet::<T>::calculate_expected_amount_in(&route, amount_out) -> Result<Balance, DispatchError> (lib.rs:659)
  • <Pallet<T> as RouteSpotPriceProvider>::spot_price_with_fee(&route) -> Option<FixedU128> (lib.rs:896)
  • Pallet::<T>::get_route_or_default(asset_in, asset_out, &route) -> Route<AssetId> (lib.rs:561) for auto-routing

All four are pure functions of pool state. None of them are exposed via a runtime API, so off-chain consumers can't call them.

The workaround dApps use today

Without a runtime API, the only way to get a quote off-chain is to invoke DryRunApi::dry_run_call with Router.sell as the inner call. But DryRunApi simulates from the perspective of a real origin account — and that account needs to already hold the input asset. At quote time (before any bridge or transfer has happened) the user typically holds nothing on Hydration, so dApps substitute a "stand-in" origin chosen from a hardcoded list of pallet/contract accounts. Whichever one happens to hold enough of the input asset returns a usable quote.

The concrete failure mode that motivated this issue: subctl (a Polkadot CLI) was bailing with "The Hydration DEX may be temporarily unavailable" for any --swap-from DOT quote above ~184 DOT, because that's all the Treasury account holds and no other pallet account holds DOT at all. The DEX was fine — the workaround had just run out of stand-in capital. We patched subctl by adding the Aave aDOT atoken contract's substrate account to the stand-in list, but that requires bespoke knowledge of Hydration's EVM account mapping that a chain-side API would obviate.

A proper RouterApi would let any dApp call calculate_sell(asset_in, asset_out, amount_in, route) directly — no origin needed, no asset-specific stand-in lookup, no silently-swallowed errors when the stand-in doesn't fit.

Proposed API

// pallets/route-executor/rpc/runtime-api/src/lib.rs
sp_api::decl_runtime_apis! {
    pub trait RouterApi<AssetId, Balance>
    where
        AssetId: Codec,
        Balance: Codec + MaybeDisplay,
    {
        /// Compute the expected output for a sell. If `route` is empty, the on-chain
        /// route is used (matching the behavior of the `Router.sell` extrinsic).
        fn calculate_sell(
            asset_in: AssetId,
            asset_out: AssetId,
            amount_in: Balance,
            route: Route<AssetId>,
        ) -> Result<Balance, DispatchError>;

        /// Compute the required input for a buy. If `route` is empty, the on-chain
        /// route is used.
        fn calculate_buy(
            asset_in: AssetId,
            asset_out: AssetId,
            amount_out: Balance,
            route: Route<AssetId>,
        ) -> Result<Balance, DispatchError>;

        /// Compute the spot price (including pool fees) for a route. If `route` is
        /// empty, the on-chain route is used. Returns `None` if any hop's spot price
        /// is unavailable.
        fn calculate_spot_price_with_fee(
            asset_in: AssetId,
            asset_out: AssetId,
            route: Route<AssetId>,
        ) -> Option<FixedU128>;
    }
}

Signature mirrors the Router.sell / Router.buy extrinsics: same (asset_in, asset_out, amount, route) shape so callers can build one parameter tuple and use it for both quoting and execution. Empty route triggers get_route_or_default, matching extrinsic semantics. Route<AssetId> = BoundedVec<Trade<AssetId>, ConstU32<MAX_NUMBER_OF_TRADES>> is already SCALE-encodable upstream.

calculate_spot_price_with_fee returns Option rather than Result<_, DispatchError> because the underlying RouteSpotPriceProvider::spot_price_with_fee discards inner errors at the boundary; promoting to Result would require changes deeper in traits/src/router.rs. Happy to do that work in a follow-up if the team prefers consistency.

Runtime impl (sketch)

impl pallet_route_executor_rpc_runtime_api::RouterApi<Block, AssetId, Balance> for Runtime {
    fn calculate_sell(
        asset_in: AssetId,
        asset_out: AssetId,
        amount_in: Balance,
        route: Route<AssetId>,
    ) -> Result<Balance, DispatchError> {
        let resolved = Router::get_route_or_default(asset_in, asset_out, &route);
        Router::calculate_expected_amount_out(&resolved, amount_in)
    }
    fn calculate_buy(
        asset_in: AssetId,
        asset_out: AssetId,
        amount_out: Balance,
        route: Route<AssetId>,
    ) -> Result<Balance, DispatchError> {
        let resolved = Router::get_route_or_default(asset_in, asset_out, &route);
        Router::calculate_expected_amount_in(&resolved, amount_out)
    }
    fn calculate_spot_price_with_fee(
        asset_in: AssetId,
        asset_out: AssetId,
        route: Route<AssetId>,
    ) -> Option<FixedU128> {
        let resolved = Router::get_route_or_default(asset_in, asset_out, &route);
        <Router as RouteSpotPriceProvider<AssetId>>::spot_price_with_fee(&resolved)
    }
}

Read-only, no state mutation, weight bounded by route.len(). The implementations are thin wrappers around existing pub fns: spot_price_with_fee has direct unit coverage in pallets/route-executor/src/tests/spot_price.rs; calculate_expected_amount_out / calculate_expected_amount_in are exercised indirectly through the Router.sell / Router.buy extrinsic tests in sell.rs and buy.rs. Happy to add direct unit tests for the latter two if the team prefers.

Open questions for the team

  1. Error type for spot_price_with_fee — keep Option<FixedU128> (mirrors the underlying), or convert to Result<FixedU128, DispatchError> for consistency with calculate_sell/calculate_buy? The latter would need a small change to RouteSpotPriceProvider.
  2. DispatchError over the runtime API boundary — is the team OK with Result<Balance, DispatchError>? The existing CurrenciesApi returns naked Balance; the existing AaveTradeExecutor API returns Option<Balance>. I'd argue real error names (PoolNotSupported, RouteCalculationFailed, etc.) are more useful than None, but happy to change.
  3. Per-hop breakdowncalculate_sell_breakdown(...) -> Vec<AmountInAndOut<Balance>> would let dApps inspect each hop. Probably out of scope for this PR (and AmountInAndOut doesn't derive Encode/Decode yet), but flagging.

Deployment notes

  • No breaking changes: pure addition; existing pallets and extrinsics are untouched.
  • Runtime upgrade required: new runtime API → spec version bump, deployed via the usual authorize_upgrade flow. Old clients keep working; new clients gain the API only after the upgrade is enacted.
  • Off-chain only: the API is queried via state_call / runtime_apis().call(...); no on-chain state read or write occurs during execution of an extrinsic.
  • Weight: bounded by route.len() (max MAX_NUMBER_OF_TRADES); the same math the extrinsic already runs.

Will submit a PR

Happy to open a PR once the design decisions above are settled. Estimated diff:

  • New crate pallets/route-executor/rpc/runtime-api/ (~50 LOC: Cargo.toml + lib.rs + README)
  • Add crate to workspace members and [workspace.dependencies]
  • New dep in runtime/hydradx/Cargo.toml (plus /std propagation)
  • impl ... for Runtime block in runtime/hydradx/src/lib.rs (~30 LOC) and two trait imports
  • Spec version bump (will leave for whoever cuts the release)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions