Pelagos Go SDK is the toolkit Pelagos validators expect you to use when you author an appchain. An appchain is your application-specific runtime packaged as a Docker container that runs alongside the validator stack, consumes consensus snapshots, and publishes deterministic side effects. The Go SDK supplies the runtime harness for loading consensus batches, sequencing your transactions, coordinating multichain reads, and emitting external transactions that other chains must see.
This README concentrates on that Go-centric workflow. It assumes you will start from the public example repository, adapt its docker-compose topology, and keep all deterministic logic inside the container that hosts your appchain.
- Introduction
- TL;DR Quickstart
- Repository orientation
- Quickstart
- Data directory structure
- State management and batch processing
- Configuring required chains
- Multichain data access
- External transactions
- Designing custom transaction formats
- Extending JSON-RPC APIs
- Testing and debugging routines
- Determinism checklist
- FAQ
- Glossary
Use the public example as a starting point. Replace placeholders with your own values as you customize.
# 1) Clone the example template next to this SDK repo
git clone https://github.com/0xAtelerix/example my-appchain
cd my-appchain
# 2) Launch pelacli and your appchain
docker compose up --build
# 3) Probe health (HTTP server in your appchain's process)
# Replace <rpc_host>:<rpc_port> with the port you configured
curl -s http://<rpc_host>:<rpc_port>/health
# 4) Send a transaction via JSON-RPC (method names provided by the SDK helpers)
curl -s -X POST http://<rpc_host>:<rpc_port>/rpc -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"sendTransaction","params":[{"sender":"0xabc...","value":1}]}'
# 5) Rebuild just your appchain service as you iterate
docker compose build appchain && docker compose up appchainThe Go SDK lives under gosdk/. The directories and modules below map to the core capabilities your appchain will plug together:
- Runtime harness (
gosdk/appchain.go) – orchestrates configuration, storage wiring, gRPC servers, and the main execution loop that pulls consensus batches into your state transition. - Initialization (
gosdk/init.go) – providesInitApp()to bootstrap all common components (databases, multichain access, subscriber) with sensible defaults. - Core appchain types (
gosdk/apptypes/) – supplies shared interfaces for transactions, receipts, batches, and external payloads so your business logic can interoperate with the validator stack. - Transaction pool (
gosdk/txpool/) – manages queued transactions, batching, and hash verification so validators stay in sync with the payloads your appchain will execute. - Multichain access (
gosdk/multichain.go,gosdk/multichain_sql.go) – opens deterministic, read-only windows into Pelagos-hosted data sets for other chains (EVM, Solana, etc.). Uses SQLite for consistent, cross-platform access via theMultichainStateAccessorinterface. - Subscription control (
gosdk/subscriber.go) – lets you declare which external contracts, addresses, or topics must be tracked, ensuring multichain readers deliver the events your appchain requires. - Receipt storage (
gosdk/receipt/) – persists execution receipts keyed by transaction hash so clients can audit outcomes. - External transaction builders (
gosdk/external/) – helps encode cross-chain payloads (EVM, Solana, additional targets) that your batch processor may emit after state transitions succeed. - JSON-RPC server (
gosdk/rpc/) – provides a scaffold for transaction submission, state queries, and health endpoints that you can extend with appchain-specific methods. - Token helpers (
gosdk/library/tokens/) – includes reference codecs for reading token balances and transfers from multichain data sets when your appchain logic depends on them. - Generated gRPC bindings (
gosdk/proto/) – packages the emitter and health service stubs so your runtime can expose the same APIs validators call to stream execution outputs and monitor liveness.
The fastest path to a working Pelagos appchain is to fork 0xAtelerix/example and use it as your integration harness.
-
Fork and clone the template. Create a GitHub fork under your organization, then clone it locally alongside this SDK repository. The example already vendors
gosdkas a module dependency and is structured for direct customization. -
Review the Docker composition. The root
docker-compose.ymlspins up:- a validator service with the consensus stack and MDBX volumes that mirror what runs in production,
- a fetcher that requests transaction batches and external payloads from validators,
- your appchain container, built from the local Dockerfile, which links to the validator network, and supporting services (PostgreSQL, Redis, or other caches) when the example demonstrates richer workflows.
-
Run the stack. From the example repository, execute
docker-compose up --build. This compiles your appchain binary, builds the container image, and starts pelacli and your appchain services. Keep the compose logs open; they show consensus progress, batch ingestion, and RPC traffic for diagnosis. -
Insert your business logic. Modify the Go modules inside the example project to:
- implement your
TransactionandReceipttypes, - implement your
ExternalBlockProcessorto handle external chain data, - extend the JSON-RPC server for custom submission or query endpoints,
- subscribe to external datasets through
MultichainStateAccesswhen your appchain must block on foreign chain data. The example keeps these hooks in isolated packages so you can replace them without rewriting the compose workflow.
- implement your
-
Iterate with docker-compose. Rebuild the appchain container (
docker-compose build appchain) or restart the service (docker-compose up --build appchain) to verify deterministic behavior against the validator snapshot stream.
Once the template behaves as expected locally, you can push the forked repository to your Pelagos validator partners for staging. They reuse the same compose topology, ensuring your appchain container integrates with the network exactly as tested.
The SDK and pelacli share a common data directory (default: /data). The SDK abstracts away the internal details, but understanding the high-level structure helps with configuration:
./data/
├── multichain/ # External chain data (accessed via storage.Multichain())
│ ├── 11155111/ # Ethereum Sepolia blocks and receipts
│ └── 80002/ # Polygon Amoy blocks and receipts
├── consensus/ # Consensus data (read by SDK, written by pelacli)
│ ├── events/ # Consensus snapshots and validator sets
│ └── txbatch/ # Transaction batches for your appchain
│ └── 42/ # Per-appchain (chainID=42)
└── appchain/ # Your appchain's databases
├── db/ # Main database (blocks, state, receipts)
└── txpool/ # Transaction pool
The SDK provides path helper functions (in gosdk/paths.go) for custom configurations. For most use cases, the defaults work out of the box.
The SDK provides a streamlined initialization flow through gosdk.InitApp() that sets up all common components with sensible defaults. Here's how a typical appchain main function looks:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize all common components
storage, config, err := gosdk.InitApp[MyTransaction[MyReceipt], MyReceipt](
ctx,
gosdk.InitConfig{
ChainID: 42,
DataDir: "./data",
EmitterPort: ":9090",
RequiredChains: []uint64{uint64(gosdk.EthereumSepoliaChainID), uint64(gosdk.PolygonAmoyChainID)}, // Sepolia + Polygon Amoy
CustomTables: application.Tables(),
},
)
if err != nil {
log.Fatal().Err(err).Msg("Failed to init appchain")
}
defer storage.Close()
// Create appchain with SDK's default batch processor
appchain := gosdk.NewAppchain(
storage,
config,
gosdk.NewDefaultBatchProcessor[MyTransaction[MyReceipt], MyReceipt](
NewExtBlockProcessor(storage.Multichain()),
storage.Multichain(),
storage.Subscriber(),
),
MyBlockConstructor,
)
// Run appchain
go appchain.Run(ctx)
// Start RPC server...
}InitApp() returns:
Storage– contains all databases and storage components (appchain DB, txpool, multichain accessor, subscriber)Config– the fully populatedAppchainConfigwith paths and settings
The Storage object provides accessor methods:
storage.AppchainDB()– main appchain database for blocks, checkpoints, and receiptsstorage.TxPool()– transaction pool for managing pending transactionsstorage.Multichain()– multichain state accessor for external chain data (EVM, Solana)storage.Subscriber()– manages external chain subscriptions for filteringstorage.TxBatchDB()– read-only database for transaction batches from pelacli
Your transaction type must implement apptypes.AppTransaction. Each transaction carries your domain-specific fields and a Process method that mutates state:
type MyTransaction[R MyReceipt] struct {
Sender string `cbor:"1,keyasint"`
Value int `cbor:"2,keyasint"`
}
func (tx MyTransaction[R]) Hash() [32]byte {
// Return deterministic hash of transaction
return sha256.Sum256([]byte(tx.Sender + strconv.Itoa(tx.Value)))
}
func (tx MyTransaction[R]) Process(rw kv.RwTx) (R, []apptypes.ExternalTransaction, error) {
// Read current state
current := readCounter(rw, tx.Sender)
// Update state
next := current + uint64(tx.Value)
writeCounter(rw, tx.Sender, next)
// Return receipt and any external transactions to emit
return MyReceipt{NewBalance: next}, nil, nil
}The SDK provides a BatchProcessor interface for processing batches. Most apps use DefaultBatchProcessor which:
- Processes each app transaction by calling
tx.Process() - Filters external blocks by your subscriptions
- Delegates matched external blocks to your
ExternalBlockProcessor
For custom batch processing logic, implement the BatchProcessor interface:
type BatchProcessor[appTx apptypes.AppTransaction[R], R apptypes.Receipt] interface {
ProcessBatch(
ctx context.Context,
batch apptypes.Batch[appTx, R],
dbtx kv.RwTx,
) ([]R, []apptypes.ExternalTransaction, error)
}Usage:
// Default - use SDK's DefaultBatchProcessor
appchain := gosdk.NewAppchain(
storage,
config,
gosdk.NewDefaultBatchProcessor[MyTx, MyReceipt](
myExtBlockProcessor,
storage.Multichain(),
storage.Subscriber(),
),
blockBuilder,
)
// Custom - provide your own BatchProcessor implementation
appchain := gosdk.NewAppchain(
storage,
config,
myCustomBatchProcessor,
blockBuilder,
)When implementing a custom BatchProcessor, you have full control over batch processing logic. You can:
- Use
ExternalBlockProcessorinternally (likeDefaultBatchProcessordoes) - Handle external blocks directly in
ProcessBatch - Use a completely different architecture
To handle data from external chains (EVM, Solana), implement the ExternalBlockProcessor interface:
// Verify your type implements the interface
var _ gosdk.ExternalBlockProcessor = &ExtBlockProcessor{}
type ExtBlockProcessor struct {
multichain gosdk.MultichainStateAccessor
}
func NewExtBlockProcessor(multichain gosdk.MultichainStateAccessor) *ExtBlockProcessor {
return &ExtBlockProcessor{multichain: multichain}
}
func (p *ExtBlockProcessor) ProcessBlock(
block apptypes.ExternalBlock,
dbtx kv.RwTx,
) ([]apptypes.ExternalTransaction, error) {
ctx := context.Background()
var externalTxs []apptypes.ExternalTransaction
switch {
case gosdk.IsEvmChain(apptypes.ChainType(block.ChainID)):
// Get EVM block and receipts from multichain database
evmBlock, err := p.multichain.EVMBlock(ctx, block)
if err != nil {
return nil, err
}
receipts, err := p.multichain.EVMReceipts(ctx, block)
if err != nil {
return nil, err
}
// Process logs from receipts
for _, receipt := range receipts {
for _, log := range receipt.Logs {
// Handle events, update state via dbtx
// Optionally emit cross-chain transactions
}
}
// Use evmBlock for block-level data (timestamp, etc.)
_ = evmBlock
case gosdk.IsSolanaChain(apptypes.ChainType(block.ChainID)):
solBlock, err := p.multichain.SolanaBlock(ctx, block)
if err != nil {
return nil, err
}
// Process Solana transactions
for _, tx := range solBlock.Transactions {
// Handle program interactions
_ = tx
}
}
return externalTxs, nil
}The SDK automatically:
- Opens an MDBX write transaction
- Calls your transaction's
Process()for each tx in the batch - Calls your
ExternalBlockProcessor.ProcessBlock()for matched external blocks - Stores receipts
- Calculates the new state root
- Builds the block header
- Commits the transaction
If an error occurs, the write transaction is rolled back and the batch is retried.
The SDK needs to know which external chains your appchain will read data from. This is configured via the RequiredChains field in InitConfig:
storage, config, err := gosdk.InitApp[MyTx, MyReceipt](
ctx,
gosdk.InitConfig{
ChainID: 42,
DataDir: "./data",
RequiredChains: []uint64{uint64(gosdk.EthereumSepoliaChainID), uint64(gosdk.PolygonAmoyChainID)}, // Ethereum Sepolia + Polygon Amoy
},
)The example appchain defaults to Ethereum Sepolia if required_chains is not specified in config.yaml:
// In example/cmd/config.go
if len(c.RequiredChains) == 0 {
c.RequiredChains = []uint64{uint64(gosdk.EthereumSepoliaChainID)}
}This ensures demos work out of the box. For production, always explicitly specify your required chains in the config file.
The SDK validates chain IDs against known EVM and Solana chains:
- See
gosdk/chain_consts.gofor the full list of supported networks
During InitApp():
- For each chain in
RequiredChains, waits for pelacli to populate{dataDir}/multichain/{chainID}/ - Opens multichain database connections for those chains
- Returns
MultichainStateAccessorviastorage.Multichain()
If a required chain's data isn't available (e.g., pelacli hasn't synced it), InitApp() will wait until the data directory appears.
Always explicitly specify required chains in your config file:
# config.yaml
required_chains:
- 11155111 # Ethereum Sepolia
- 80002 # Polygon AmoyThis makes your appchain's external dependencies clear and prevents relying on defaults that may change.
During startup, subscribe to the addresses and contracts you care about:
subscriber.SubscribeEthContract(gosdk.EthereumSepoliaChainID, gosdk.EthereumAddress{/* bytes */})
subscriber.SubscribeSolanaAddress(gosdk.SolanaDevnetChainID, gosdk.SolanaAddress{/* bytes */})These declarations ensure the required external data is available before a batch is processed. The SDK gates ProcessBlock until the referenced block is present, so your logic can safely assume the data exists.
To emit cross-chain transactions, return them from ProcessBlock:
// Inside ProcessBlock, after processing an event:
extTx, err := external.NewExTxBuilder(
abiEncodedPayload,
gosdk.EthereumSepoliaChainID,
).Build()
if err != nil {
return nil, err
}
externalTxs = append(externalTxs, extTx)After the batch commits:
- Validators aggregate the emitted external transactions
- They reach quorum on which transactions should leave the network
- The TSS (threshold-signing) appchain applies signatures
- Transactions are broadcast to target chains
Your transaction type must implement apptypes.AppTransaction:
type AppTransaction[R Receipt] interface {
Hash() [32]byte
Process(kv.RwTx) (receipt R, externalTxs []ExternalTransaction, err error)
}Guidelines:
- Use deterministic encoding. Use CBOR tags for consistent serialization across validators.
- Derive hashes deterministically. The txpool indexes by
tx.Hash(), so ensure it covers all serialized fields. - Validate in Process. Return errors for malformed inputs to prevent state mutations.
- Track lifecycle with receipts. Implement
apptypes.Receiptso clients can query outcomes.
The SDK provides a composable RPC server:
// Create server
rpcServer := rpc.NewStandardRPCServer(nil)
// Add middleware
rpcServer.AddMiddleware(myMiddleware)
// Add standard methods (sendTransaction, getBlock, getReceipt, etc.)
rpc.AddStandardMethods[MyTx, MyReceipt, MyBlock](
rpcServer,
storage.AppchainDB(),
storage.TxPool(),
chainID,
)
// Add custom methods
myCustomRPC := NewCustomRPC(rpcServer, storage.AppchainDB())
myCustomRPC.AddRPCMethods()
// Start server
rpcServer.StartHTTPServer(ctx, ":8080")# Send transaction
curl -s -X POST http://localhost:8080/rpc -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"sendTransaction","params":[{"sender":"0xabc...","value":1}]}'
# Get pending transactions
curl -s -X POST http://localhost:8080/rpc -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"getPendingTransactions","params":[]}'
# Get transaction status
curl -s -X POST http://localhost:8080/rpc -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"getTransactionStatus","params":["<tx_hash>"]}'-
Run tests. Use
go test ./...ormake teststo run unit and integration tests. -
Use the example test as a template. See
gosdk/example_app_test.gofor how to set up an appchain with in-memory storage for testing. -
Replay consensus snapshots. Point your config at archived validator outputs to replay problematic epochs.
-
Inspect multichain state. Use the multichain accessor methods (
EVMBlock,EVMReceipts,SolanaBlock) for ad-hoc queries. -
Enable observability. Set a logger via
InitConfig.Loggerand optionally configure Prometheus metrics.
The SDK assumes every validator replays identical state transitions. Keep your logic deterministic:
- Avoid
time.Now, random values, or network I/O insideProcess/ProcessBlock. - Do all writes through the provided
kv.RwTx. - Iterate maps deterministically (sort keys before ranging when order matters).
- Derive nonces/ids from batch inputs, not local state.
- Use stable transaction encoding (explicit CBOR tags).
- Keep logging outside state mutation paths.
What runs where? Your appchain logic runs inside your Docker image. Validators run consensus, fetchers, and host your image alongside them.
How do I access other chains? Use MultichainStateAccessor after declaring subscriptions. The SDK blocks ProcessBlock until referenced external blocks are present.
How do I emit cross-chain payloads? Return apptypes.ExternalTransaction from your ProcessBlock. Validators and the TSS appchain handle signing and broadcast.
How do I submit transactions? Start the JSON-RPC server and use the standard methods added via rpc.AddStandardMethods.
- Appchain: Your deterministic runtime packaged as a Docker image.
- Batch: Ordered set of transactions the runtime processes atomically.
- BatchProcessor: Interface for processing batches of transactions and external blocks.
- DefaultBatchProcessor: SDK's default
BatchProcessorimplementation that handles transaction processing and external block filtering. - ExternalBlockProcessor: Interface you implement to handle external chain data (used by
DefaultBatchProcessor). - External block: Reference to finalized data from another chain used in processing.
- Fetcher: Service that writes transaction batches and external chain data to databases for appchain consumption.
- Storage: Contains all databases and storage components (appchain DB, txpool, multichain accessor, subscriber). Returned by
InitApp(). - MultichainStateAccessor: Interface for reading external chain data (EVM blocks/receipts, Solana blocks). Implementation:
MultichainStateAccessSQL(SQLite). - Pelacli: CLI tool that provides consensus, transaction batches, and external chain data for local development.
- Subscriber: Component that tracks which external addresses/contracts your appchain cares about.
- TSS appchain: Threshold-signing service that transports external transactions.