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
- 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.
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.
- Per-hop breakdown —
calculate_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)
Expose a
RouterApiruntime API for off-chain quotingThe gap
pallet_route_executorhas 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-routingAll 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_callwithRouter.sellas the inner call. ButDryRunApisimulates 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 DOTquote 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
RouterApiwould let any dApp callcalculate_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
Signature mirrors the
Router.sell/Router.buyextrinsics: same(asset_in, asset_out, amount, route)shape so callers can build one parameter tuple and use it for both quoting and execution. Emptyroutetriggersget_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_feereturnsOptionrather thanResult<_, DispatchError>because the underlyingRouteSpotPriceProvider::spot_price_with_feediscards inner errors at the boundary; promoting toResultwould require changes deeper intraits/src/router.rs. Happy to do that work in a follow-up if the team prefers consistency.Runtime impl (sketch)
Read-only, no state mutation, weight bounded by
route.len(). The implementations are thin wrappers around existingpub fns:spot_price_with_feehas direct unit coverage inpallets/route-executor/src/tests/spot_price.rs;calculate_expected_amount_out/calculate_expected_amount_inare exercised indirectly through theRouter.sell/Router.buyextrinsic tests insell.rsandbuy.rs. Happy to add direct unit tests for the latter two if the team prefers.Open questions for the team
spot_price_with_fee— keepOption<FixedU128>(mirrors the underlying), or convert toResult<FixedU128, DispatchError>for consistency withcalculate_sell/calculate_buy? The latter would need a small change toRouteSpotPriceProvider.DispatchErrorover the runtime API boundary — is the team OK withResult<Balance, DispatchError>? The existingCurrenciesApireturns nakedBalance; the existingAaveTradeExecutorAPI returnsOption<Balance>. I'd argue real error names (PoolNotSupported,RouteCalculationFailed, etc.) are more useful thanNone, but happy to change.calculate_sell_breakdown(...) -> Vec<AmountInAndOut<Balance>>would let dApps inspect each hop. Probably out of scope for this PR (andAmountInAndOutdoesn't deriveEncode/Decodeyet), but flagging.Deployment notes
authorize_upgradeflow. Old clients keep working; new clients gain the API only after the upgrade is enacted.state_call/runtime_apis().call(...); no on-chain state read or write occurs during execution of an extrinsic.route.len()(maxMAX_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:
pallets/route-executor/rpc/runtime-api/(~50 LOC: Cargo.toml + lib.rs + README)membersand[workspace.dependencies]runtime/hydradx/Cargo.toml(plus/stdpropagation)impl ... for Runtimeblock inruntime/hydradx/src/lib.rs(~30 LOC) and two trait imports