Staking Dashboard Dedot API Service Integration
This post introduces a significant Polkadot Staking Dashboard refactor that addresses long-standing limitations. At the heart of this upgrade is a fully decoupled architecture and new infrastructure for managing chain state.
The next-generation Dedot API — a lightweight, high-performance Substrate client offering fully typed APIs in a compact bundle - has also been deeply integrated. This integration positions the dashboard as a production-ready reference implementation for teams looking to integrate Dedot into their own apps.
In this article, I'll outline the core problems addressed in the refactor, walk through the Dedot integration, and share the key design decisions that shaped this transformation.
Problems Solved
Heading into the "Polkadot Hub" era, otherwise known as "Plaza" or the "AssetHub Migration", Staking Dashboard has had to solve some fundamental problems to reposition it for this multi-chain setup:
-
Plaza Compatibility: Automatically routes staking queries based on whether a chain uses AssetHub or the Relay Chain — no manual config needed.
-
Multi-Network Support (No Forking): New networks can be added via config only, thanks to abstracted logic and metadata services — no need to fork the codebase.
-
Fully Typed Chain State: Uses Dedot API with TypeScript generics to distinguish between chain types (Staking, Relay, People), enabling autocompletion and safer refactors.
-
Global Chain State Management: Maintains app-wide chain state (e.g. active era, nominations) via API-layer subscriptions, reducing redundancy and improving UI responsiveness.
-
Decoupled API from UI: Blockchain logic is isolated into services, allowing UI components to work across networks without assumptions about API structure.
Solving these problems pave the way for more sophisticated features and network support, for example, a liquid staking feature that requires deep integtation away from the relay / system chains.
Dedot API Integration
Project Structure
The Polkadot Staking Dashboard is a monorepo; a workspace of packages that comprise the application. This walkthrough focuses on 3 of these packages:
-
dedot-api: Contains all Dedot API logic, from connecting to a client, fetching chainspec, querying and submitting transactions.
-
global-bus: Responsible for storing state and making that state accessible workspace-wide via Rxjs subscriptions. The global bus decouples this state from the UI itself, and allows other packages to access or subscribe to its members.
-
app: The main UI and React hierarchy of the dashboard. App contexts and components subscribe to global bus state, and update their own state with the data they require. This in-turn triggers re-renders when such data updates,
Notably, this project structure completely decouples the api
client, and app-wide state, from the UI side. The UI does not need to know anything about the Dedot API client itself, only the state that dictates what is displayed in the UI.
This setup allows an update cycle with clear separation of concerns.
The dedot-api
package is responsible for connecting to, and handling everything node related. It also exposes an interface for the UI to query chain state and submit transactions. Upon network switching, it is also responsible for unsubscribing to active subscriptions and resetting state. dedot-api
will be explored in more detail in the following sections.
Global bus simply holds observbles along with utilities to manage them. Staking Dashboard's global-bus
forces all BehaviorSubject
s to be private, but exposes getters & setters to manage their state. To manage an API status, for example, the following boilerplate is defined:
// Staking Dashboard forces this to be private
export const _apiStatus = new BehaviorSubject<Record<string, ApiStatus>>({})
// This is public and used by components to subscribe to
export const apiStatus$ = _apiStatus.asObservable()
// Getter: Gets the API status for a chain ID
export const getApiStatus = (id: string) =>
_apiStatus.getValue()[id] || 'disconnected'
// Setter: Sets the API status for a given chain ID
export const setApiStatus = (id: string, status: ApiStatus) => {
_apiStatus.next({
..._apiStatus.getValue(),
[id]: status,
})
}
// Setter: Resets state to an empty record
export const resetApiStatus = () => {
_apiStatus.next({})
}
Storing state and providing utilities to manage such state is the only job of the global bus. The UI can use the global bus, as can the Dedot API package. The UI can leverage the global bus to set initial React state values, for example - this is especially useful if those initial values are derived from local storage. Dedot API on the other hand populates the global bus as it receives chain data.
As a simple mental modal, Dedot API provides data to the global bus, whereas the UI consumes that data. There are some exceptions to this based on user interaction, such as switching network.
Within the app
itself, components simply subscribe to global bus observables and update their local state. This can be done in a useEffect hook, that also handles unsubscribe logic on component unmount:
// Import observable from the global bus package
import apiStatus$ from ‘global-bus’
// Subscribe to global bus within a component
useEffect(() => {
const subApiStatus = apiStatus$.subscribe((result) => {
setApiStatus(result)
})
return () => {
subApiStatus.unsubscribe()
}
}, [])
So with enough intuition to now understand how the app is functioning, let's dive into the dedot-api
package itself and explore how it functions to serve multi-network, type-safe services.
Defining Network Services
The dedot-api
package defines services that networks can implement. When implemented, services drive everything node-related, including:
- Initialising the node connection via WebSocket or light client
- Boostrapping network data like fetching chain specs, ss58 prefix, existential deposit, and so on
- Subscribing to required chain state and updating the global bus in real time
- Exposing an interface the UI can call to fetch queries and submit transactions.
The way a service is configured varies depending on how many chains are required on the network. At the time of writing, Staking Dashboard supports Polkadot, Kusama and Westend networks, and they all currently adhere to the same chain setup. Concretely, they all comprise:
- A Relay chain: Where all staking and validator data currently resides (pending AssetHub migration).
- A People chain: Where identities are stored - required for displaying identities for stakers, validators and pool operators.
With this in mind, we can implement one service, that I've called the default service
, that connects to both of these chains for every network that implements it. This is done within the init function of the dedot-api
package, initDedotService
.
Let's take a look at how a default service is being instantiated. (Source File):
if (network === 'westend') {
const { Service, apis, ids } = await getDefaultService(network, rest)
service = new Service(cur, ids, ...apis)
}
if (network === 'kusama') {
const { Service, apis, ids } = await getDefaultService(network, rest)
service = new Service(cur, ids, ...apis)
}
if (network === 'polkadot') {
const { Service, apis, ids } = await getDefaultService(network, rest)
service = new Service(cur, ids, ...apis)
}
Although simple and perhaps tedius looking upon first look, these conditional statements and initialisations are doing quite a bit of work:
- They allow us to call a different service initialisers depending on the active network. We currently only require the default service that provides a Relay chain and People chain, but more services could be implemented, for solo-chain networks, for example.
- The service class itself is returned, along with the instantiated Dedot API clients, and the chain IDs of those clients.
- The conditionals are ensuring type-safety, by narrowing down the chain types of the initialised API clients. Note how we instantiate
service
within each conditional - this is ensuring thatservice
is type-narrowed to the correct chain types.
This last point is crucially important for type safety. getDefaultService
is a generic function with the following signature:
getDefaultService: <T extends NetworkId>(
network: T,
{ rpcEndpoints, providerType }: NetworkConfig
) => Promise<DefaultService<T>>
So T
is dictating which network we're providing a service for, and ultimately which chain types are being returned for the Dedot clients.
If we delve into the actual return types of DefaultService
, we can see that the service class itself is being returned (services for each supported network are defined here), along with type-safe Dedot Clients - the clients we use for api.query
, api.tx
, etc.
With these chain types provided, the entire chain metadata is now type-safe and intellisense-ready. Finally, ids
simply provides strings of the provided client ids. The following types break down the return type of getDefaultService
:
export type DefaultService<T extends keyof ServiceType> = {
Service: ServiceType[T]
apis: [DedotClient<Service[T][0]>, DedotClient<Service[T][1]>]
ids: [NetworkId, SystemChainId]
}
export interface ServiceType {
polkadot: typeof PolkadotService
kusama: typeof KusamaService
westend: typeof WestendService
}
import type {
KusamaApi,
PolkadotApi,
WestendApi,
...
} from '@dedot/chaintypes'
export type Service = {
polkadot: [PolkadotApi, PolkadotPeopleApi]
kusama: [KusamaApi, KusamaPeopleApi]
westend: [WestendApi, WestendPeopleApi]
}
With API clients now initialised with the required service class, initDedotService
now simply exposes its public interface via global-bus
, and starts the service, beginning its bootstrapping of chain state and subscriptions:
// Expose service interface via global bus
setServiceInterface(service.interface)
// Start the service
await service.start()
Defining Dedot API Clients
So far we've been discussing service bootstrapping without mentioning the dedot
or @dedot/chaintypes
packages. For developers accustomed to using Polkadot JS API, Initialising Dedot Clients will look familiar. A standard websocket provider api client initialisation looks like the following:
import { WsProvider } from 'dedot';
import type { PolkadotApi } from '@dedot/chaintypes';
const provider = new WsProvider('wss://rpc.polkadot.io');
const client = await DedotClient.new<PolkadotApi>(provider);
One notable difference here is that the PolkadotApi
interface is being imported - this is known as a chain type, which types the entire client
interface. With chain types being pure TypeScript, app bundle sizes are not affected, and intellisense within your IDE becomes available. Dedot's Chaintypes contributed to making the solution this article is discussing possible.
For more information about getting started with Dedot, visit the official docs.
Implementing a Default Service
To understand the basic structure of the Default Service, we can look at the abstract class each default service must implement. DefaultServiceClass
(Source Code) defines the generics and class members each default service must implement.
Three generic parameters are defined in this class definition:
export abstract class DefaultServiceClass<
RelayApi extends RelayChain,
PeopleApi extends PeopleChain,
StakingApi extends StakingChain,
> extends ServiceClass {
...
}
These generic types are simply unions that categorise chains into specific groups:
// A union of the relay chain types
type RelayChain = PolkadotApi | KusamaApi | WestendApi
// A union of the people chain types
type RelayChain = PolkadotPeopleApi | KusamaPeopleApi | WestendPeopleApi
// A union of the chain types that staking resides on
// NOTE: These will change when staking is migrated to AssetHub
type StakingChain = PolkadotApi | KusamaApi | WestendApi
DefaultServiceClass
expects a chain type for a Relay chain, a People chain, and a staking chain (the chain on which staking and nomination pools reside). Note that there can be overlap here - the class simply needs knowledge of which chain types to use.
So for the Polkadot service, we can implement this class with the PolkadotApi
chain type, satisfying the Relay chain and Staking chain, and PolkadotPeopleApi
as the People chain type. Within the service class, these types can then be assigned to subscriptions, queries, and transactions.
Implementing the Polkadot Service
PolkadotService
implements DefaultServiceClass
with PolkadotApi
and PolkadotPeopleApi
. These chain types are then passed into each query and each subscription within the service:
import type { PolkadotApi } from '@dedot/chaintypes/polkadot'
import type { PolkadotPeopleApi } from '@dedot/chaintypes/polkadot-people'
export class PolkadotService
implements DefaultServiceClass<
// Relay Chain
PolkadotApi,
// People Chain
PolkadotPeopleApi,
// Staking-residing chain
PolkadotApi>
{
relayChainSpec: ChainSpecs<PolkadotApi>
peopleChainSpec: ChainSpecs<PolkadotPeopleApi>
apiStatus: {
relay: ApiStatus<PolkadotApi>
people: ApiStatus<PolkadotPeopleApi>
}
coreConsts: CoreConsts<PolkadotApi>
stakingConsts: StakingConsts<PolkadotApi>
blockNumber: BlockNumberQuery<PolkadotApi>
activeEra: ActiveEraQuery<PolkadotApi>
relayMetrics: RelayMetricsQuery<PolkadotApi>
poolsConfig: PoolsConfigQuery<PolkadotApi>
stakingMetrics: StakingMetricsQuery<PolkadotApi>
eraRewardPoints: EraRewardPointsQuery<PolkadotApi>
fastUnstakeConfig: FastUnstakeConfigQuery<PolkadotApi>
fastUnstakeQueue: FastUnstakeQueueQuery<PolkadotApi>
subActiveAddress: Subscription
subImportedAccounts: Subscription
subActiveEra: Subscription
subAccountBalances: AccountBalances<PolkadotApi, PolkadotPeopleApi> = {
relay: {},
people: {},
}
subStakingLedgers: StakingLedgers<PolkadotApi> = {}
subProxies: Proxies<PolkadotApi> = {}
subActivePoolIds: Subscription
subActivePools: ActivePools<PolkadotApi> = {}
}
Every query and subscription is generic over an chain type group, which is why they must be provided within the service class. To maintain package modularity and ease of maintenance, chain interactions are stored in separate folders depending on their type, and separated into separate files.
spec
: Fetching of chain state and API statusconsts
: Fetching of groups of chain constantsquery
: Chain state queries, multi-queries and entries queriessubscribe
: Chain state subscriptions and multi-subscriptionsruntimeApi
: Runtime API call handlerstx
: Transaction definitions
These functions and classes do not assume a particular chain, but instead define what kind of chain they expect, such as a RelayChain
or a StakingChain
.
The granularity of services classes and their contents allow easy maintenance, even when chain state is not consistent between networks. For instance, Westend will carry out its AssetHub migration before Kusama and Polkadot, which will result in WestendAssetHub
being assigned to the StakingChain
union in place of the current WestendApi
Relay chain.
Polkadot Service Flow
Having discussed the general structure of the Polkadot service, let's break down what the start
function does:
- Chain specs are fetched for both Relay and People chains and set in the global bus.
- Relay chain constants and Staking chain constants are fetched and set in the global bus.
- "Base" queries are subscribed to, ranging from the block number, active era, and various groups of chain state queries that either the service or UI requires.
- Subscriptions to global bus are initialised and handled. Examples include:
- The active era, which therein allow further subscriptions to staking metrics.
- The Staking Dashboard active account, that then subscribes to the account's fast unstake status.
- All imported accounts to the dashboard, which in-turn
- Subscribes to each account's balances, on both Relay and People chain
- Subscribes to each account's staking ledger
- Subscribes to each account's proxies
- Active nomination pool IDs, or the IDs of nomination pools that imported accounts are members of, which in-turn:
- Subscribes to the corresponding bonded pool, its reward pool, and its nominators.
This service flow embeds subscriptions within subscriptions, which adds complexity to the service. This is why separation of concerns is valuable; with this API logic completely abstracted from the UI.
Managing New and Stale Subscription Entries
In multiple instances, subscriptions need to be tidied up as well as removed. A concise example is with the active pool ID subscriptions, whereby removed pool IDs require an unsubscribe to that pool. Conversely, newly added pool IDs require a new subscription.
To deal with these changes elegently, Rxjs's pairwise
and startsWith
utilities can be leveraged, giving the callback access to both it's previous and current state:
this.subActivePoolIds = activePoolIds$
// Access previous and current state of observable
.pipe(startWith([]), pairwise())
.subscribe(([prev, cur]) => {
// This diff function determines which pool IDs have been removed, and which added
const { added, removed } = diffPoolIds(prev, cur)
// For removed pool IDs, unsubscribe from its activePool subscription
removed.forEach((poolId) => {
this.subActivePools[poolId]?.unsubscribe()
})
// For added pool IDs, initialise activePool subscriptions
added.forEach((poolId) => {
this.subActivePools[poolId] = new ActivePoolQuery(
// NOTE: I am explicitly providing the correct api class for each query
this.apiRelay,
poolId,
this.stakingConsts.poolsPalletId,
this.interface
)
})
})
The service flow discussed here all happens automatically, starting from the service's start
function. It provides the core state for the Staking Dashboard's currently active network and its imported accounts.
Exposing a Service Interface
In addition to the one-way data flow of the service start
function, there are cases where the UI needs to interact with the service for specific data, rather than relying on the global bus. Examples include:
- Fetching identity data for a page of validators as they are browsed
- Runtime API calls for a nomination pool's reward balance when a pool page is visited
- Transaction instantiation and submission.
These are functionalities that the app requests upon a user input - and this requires a public service API the UI can use to interact with the service. This is why the service interface was exposed via the global bus just before start
was called:
// Expose service interface via global bus
setServiceInterface(service.interface) // <- UI now has access to service interface
The service interface itself is defined here, providing the queries, transactions and signer utilities for the dashboard. Each service then implements this interface, providing the correct APIs to the respective function calls within the service class itself, remaining to our core value of the app not needing any information about which chain it needs to Interact with.
To drill this concept, let's look at the query.bondedPool
interface:
// Public service interface
query: {
bondedPool: (poolId: number) => Promise<BondedPoolQuery | undefined>
}
This is the publicly exposed API, which just rquires a poolId
for the query, which either returns a bonded pool, or undefined if nothing was found. When the service implements this query however, it is responsible for passing the correct API to the corresponding function call:
// Service class implementation
query: {
bondedPool: async (poolId) =>
await query.bondedPool(this.apiRelay, poolId),
}
The Relay chain API is being passed to the bondedPool
function in this case.
Exposing the Service Interface to React Components
The service interface is subscribed to within the app's Api
context, and is exposed as a serviceApi
object for the entire app to use:
// Listen for service interface updates and expose via this context
useEffect(() => {
const subServiceInterface = serviceInterface$.subscribe((result) => {
setServiceApi(result)
})
return () => {
subServiceInterface.unsubscribe()
}
}, [])
...
// Expose `serviceApi` state for rest of the app to use
return (
<APIContext.Provider
value={{
serviceApi,
}}
>
{children}
</APIContext.Provider>
)
Components can then access this serviceApi
via the useApi
hook, an idiomatic way for the React component hierarchy.
const MyComponent = () => {
const { serviceApi } = useApi()
const bondedPool = await serviceApi.query.bondedPool(1)
...
}
Unsubscribing on Network Switchng
Importantly, all service classes must implement unsubscribe and tidy-up logic (See the Polkadot service unsubscribe function):
- Service class subscriptions are unsubscribed
- Dedot queries are unsubscribed
- The Dedot clients are then disconnected
- Global bus chain state is reset
Again, Rxjs's pairwise
utility is leveraged to perform this tidy-up before a new Service is instantiated on a network switch (Source Code).
Conclusion & Next Steps
This refactor lays the foundation for a more modular, scalable, and future-proof Staking Dashboard. The Dedot API now plays a critical role in the Staking Dashboard's developer and user experience.
Looking ahead, there are two key directions to take the implementation further:
- AssetHub Chain to Default Service: As each network transitions staking logic to AssetHub, the default service can be extended to treat AssetHub as a third chain api, alongside the Relay chain and People chain.
- External Network Integrations: With the new service-based architecture, it's now straightforward to introduce additional networks that fork the Staking Dashboard, such as the Vara network, by creating dedicated service modules—no forking required.
These improvements continue to move the dashboard toward a more universal, multi-network staking interface that’s easier to maintain and extend across the Polkadot ecosystem.
Useful Links
-
Dedot API Docs: https://docs.dedot.dev/
-
Dedot Telegram Group: https://t.me/JoinDedot
-
Staking Dashboard GitHub: https://github.com/polkadot-cloud/polkadot-staking-dashboard