galacticcouncil / galacticcouncil/hydration-node
Expose a RouterApi runtime API for off-chain quoting
- Dominant language
- Rust
- Stars
- 208
- Forks
- 109
- Avg merge
- 6d 3h
- Merged PRs (30d)
- 3
Description
# 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::::calculate_expected_amount_out(&route, amount_in) -> Result` ([lib.rs:624](https://github.com/galacticcouncil/hydration-node/blob/master/pallets/route-executor/src/lib.rs#L624))
- `Pallet::::calculate_expected_amount_in(&route, amount_out) -> Result` ([lib.rs:659](https://github.com/galacticcouncil/hydration-node/blob/master/pallets/route-executor/src/lib.rs#L659))
- ` as RouteSpotPriceProvider>::spot_price_with_fee(&route) -> Option` ([lib.rs:896](https://github.com/galacticcouncil/hydration-node/blob/master/pallets/route-executor/src/lib.rs#L896))
- `Pallet::::get_route_or_default(asset_in, asset_out, &route) -> Route` ([lib.rs:561](https://github.com/galacticcouncil/hydration-node/blob/master/pallets/route-executor/src/lib.rs#L561)) 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
```rust
// pallets/route-executor/rpc/runtime-api/src/lib.rs
sp_api::decl_runtime_apis! {
pub trait RouterApi
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,
) -> Result;
/// 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,
) -> Result;
/// 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,
) -> Option;
}
}
```
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 = BoundedVec, ConstU32>` 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)
```rust
impl pallet_route_executor_rpc_runtime_api::RouterApi for Runtime {
fn calculate_sell(
asset_in: AssetId,
asset_out: AssetId,
amount_in: Balance,
route: Route,
) -> Result {
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,
) -> Result {
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,
) -> Option {
let resolved = Router::get_route_or_default(asset_in, asset_out, &route);
>::spot_price_with_fee(&resolved)
}
}
```
Read-only, no state mutation, weight bounded by `route.len()`. The implementations are thin wrappers around existing `pub fn`s: `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` (mirrors the underlying), or convert to `Result` 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`? The existing `CurrenciesApi` returns naked `Balance`; the existing `AaveTradeExecutor` API returns `Option`. I'd argue real error names (`PoolNotSupported`, `RouteCalculationFailed`, etc.) are more useful than `None`, but happy to change.
3. **Per-hop breakdown** — `calculate_sell_breakdown(...) -> Vec>` 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)
Contributor guide
Assessment
This issue has not been assessed yet.