Configuration
Protokit app-chains are assembled from small, composable pieces called modules. Before diving into individual areas like the runtime or the sequencer, it is worth understanding how configuration of those modules works in principle — because the same pattern is used everywhere, all the way down the stack.
How configuration works in principle
Every configurable piece in Protokit follows the same two-step shape:
- A record of modules: a plain object that maps a module name to a module class. This record decides what is part of your app-chain (which runtime modules, which protocol hooks, which sequencer services, etc.).
- A matching configuration object: an object with the same keys as the module record, providing the per-module configuration values. Its type is derived from the module record, so the compiler will complain if a module is missing a config or if a config shape does not match the module it belongs to.
A minimal example of this pattern - here for the runtime - looks like this:
import { Balance } from "@proto-kit/library";
import { ModulesConfig } from "@proto-kit/common";
import { Balances } from "../modules/balances";
export const modules = {
Balances,
};
export const config: ModulesConfig<typeof modules> = {
Balances: {
totalSupply: Balance.from(10_000 * 1e9),
},
};
export default {
modules,
config,
};The important things to notice are:
moduleslists the module classes under stable names (Balances, …).configis typed asModulesConfig<typeof modules>, which forces it to have an entry for each module and rejects values that don’t match the module’s expected config type.
This module record + matching config pattern is the single mental model you need. The rest of Protokit is just applying it recursively at multiple levels.
Modules all the way down
An app-chain is itself a module container, and its entries are also module containers. The configuration therefore forms a small tree:
AppChain
├─ Runtime (container of runtime modules — your business logic)
├─ Protocol (container of protocol modules / hooks)
└─ Sequencer (container of sequencer modules — networking, storage, block production, …)Each of these sub-containers is configured using the same modules + config shape described above. When you
configure the AppChain, you simply pass the pre-built modules and config objects from each sub-container
into the corresponding slot.
The AppChain
The AppChain is the top-level container that wires everything together. Its own “modules” are at least composed of
the three sub-containers - Runtime, Protocol and Sequencer - plus optional AppchainModule modules
(used for the client appchain for example).
A server-side app-chain assembled from the individual pieces can look like this (this is how starter-kit scaffolds this)
const appChain = AppChain.from({
Runtime: Runtime.from(runtime.modules),
Protocol: Protocol.from(protocol.modules),
Sequencer: Sequencer.from({
Database: InMemoryDatabase,
Mempool: PrivateMempool,
// ...
}),
});
export default async (): Promise<Startable> => {
appChain.configure({
Runtime: runtime.config,
Protocol: protocol.config,
Sequencer: {
Database: {},
Mempool: {
targetBlockSize: 20,
},
// ...
},
});
return appChain;
};Notice how the recursive structure shows up concretely:
Runtime: Runtime.from(runtime.modules)builds a runtime container from your runtime module record.Protocol: Protocol.from(protocol.modules)does the same for the protocol.Sequencer: Sequencer.from({ … })builds a sequencer container from a record of sequencer modules.
And then, inside appChain.configure({ … }), the matching config object mirrors that structure exactly, and recursively.
You can even have multiple levels of
ModuleContainers, like for example the GraphqlServerModule
Client vs. server
The same AppChain abstraction is used on both sides of the wire. The server side is started by Protokit’s CLI
using a chain.config.ts, while the client uses a client.config.ts to connect to a running app-chain (typically
over GraphQL):
import {
AuroSigner,
ClientAppChain,
GraphqlBlockExplorerTransportModule,
GraphqlClient,
GraphqlNetworkStateTransportModule,
GraphqlQueryTransportModule,
GraphqlTransactionSender,
} from "@proto-kit/sdk";
import runtime from "../../runtime";
import { Runtime } from "@proto-kit/module";
import { Protocol } from "@proto-kit/protocol";
import { Sequencer } from "@proto-kit/sequencer";
import { VanillaProtocolModules } from "@proto-kit/library";
const appChain = ClientAppChain.from({
Runtime: Runtime.from(runtime.modules),
Protocol: Protocol.from(VanillaProtocolModules.mandatoryModules({})),
Sequencer: Sequencer.from({}),
Signer: AuroSigner,
GraphqlClient,
QueryTransportModule: GraphqlQueryTransportModule,
NetworkStateTransportModule: GraphqlNetworkStateTransportModule,
BlockExplorerTransportModule: GraphqlBlockExplorerTransportModule,
TransactionSender: GraphqlTransactionSender,
});
appChain.configure({
Runtime: runtime.config,
Protocol: VanillaProtocolModules.defaultConfig(),
GraphqlClient: {
url: process.env.NEXT_PUBLIC_PROTOKIT_GRAPHQL_URL!,
},
Signer: {},
Sequencer: {},
QueryTransportModule: {},
NetworkStateTransportModule: {},
TransactionSender: {},
BlockExplorerTransportModule: {},
});
export const client = appChain;The client re-uses the same runtime.modules and runtime.config, which is what guarantees that the client and
the server agree on the shape of the runtime.
Putting it together
Once you internalize the “module record + matching config” pattern, every level of Protokit reads the same way:
- You pick which modules are part of a container.
- You provide a config object whose keys mirror those modules.
- You optionally hand the resulting container and configuration pair to the next container up.
The AppChain, the Runtime, the Protocol and the Sequencer are all just instances of that idea at different
zoom levels — which is why the same chain.config.ts can describe anything from a tiny in-memory test chain to a
fully deployed app-chain with settlement on Mina.