Skip to main content

Staking Dashboard Dedot API Service Integration

· 17 min read
Ross Bulat
Full Stack Engineer

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 BehaviorSubjects 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 that service 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 status
  • consts: Fetching of groups of chain constants
  • query: Chain state queries, multi-queries and entries queries
  • subscribe: Chain state subscriptions and multi-subscriptions
  • runtimeApi: Runtime API call handlers
  • tx: 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.