pb3-gen-sol
May 1, 2026 · View on GitHub
pb3-gen-sol is a protoc plugin that generates Solidity protobuf decoders for proto3 schemas. The generator targets Solidity >=0.8.0, emits a shared Pb.sol runtime, and supports the field-option-based Solidity type mappings used by Celer contracts.
Features
- Proto3 decoder generation for top-level messages and enums.
- Cross-package reference support, including multi-segment proto package names.
- Separate
Pb.solruntime support library. - Solidity native type overrides via
google.protobuf.FieldOptionsand(soltype). - Foundry-based Solidity regression suite, with binary
.pbfixtures regenerated from checked-in human-readable.textpbsources. - Go integration test that builds the plugin and runs
protocend to end. - Go unit tests covering generator parameter parsing and
soltypeoption handling.
Why protobuf for Solidity?
If your off-chain stack already speaks protobuf — multiple producer
languages writing the same signed bytes, schema evolution managed via
tag numbers — pb3-gen-sol lets on-chain Solidity contracts consume
the same payloads without hand-rolling ABI structs or duplicating the
schema.
The trade-off vs hand-rolled Solidity ABI:
- Language- and platform-neutral. Any client (Go, Rust, TS, Java,
etc.) can produce
.pbbytes that decode on EVM (this repo), non-EVM chains (CosmWasm, Move, Solana programs with protobuf libraries), or any off-chain verifier — all from one schema. - Native schema evolution. Adding fields with new tag numbers is safe; older decoders skip unknown tags per the proto3 spec. ABI has no native skip-unknown — adding a field shifts every offset and breaks every existing decoder.
- Smaller wire size on sparse messages. Proto3 elides zero-valued fields entirely; ABI always encodes every field.
ABI decode is cheaper in gas on most shapes. On L1 mainnet the wire-size advantage roughly cancels the decode-gas penalty; on L2 rollups protobuf's smaller wire size wins on virtually every shape. See test/sol/bench/benchmark.md for the full numbers and per-path breakdown.
Supported Types
Native proto types:
uint32uint64boolbytesstring
Supported (soltype) overrides:
uint8uintuint256addressaddress payablebytes32
uint is the varint-backed path for protobuf uint64 fields exposed as Solidity's native unsigned integer type. uint256 is the bytes-backed path for true 256-bit values.
Example Schema
syntax = "proto3";
package mytest;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
string soltype = 54321;
}
message MyMsg {
bytes addr = 1 [(soltype) = "address"];
uint32 num = 2 [(soltype) = "uint8"];
bool has_moon = 3;
}
More complete examples live under test/.
Generate Solidity
Build the plugin and place it on your PATH:
go build -o protoc-gen-sol .
export PATH="$PWD:$PATH"
Then invoke protoc:
protoc --sol_out=importpb=true:out proto/example.proto
The generated file name is derived from the proto package name. With importpb=true, generated libraries import Pb.sol; otherwise the runtime is embedded into each generated file.
Plugin Parameters
msg=<Name>: only emit selected top-level message decoders. Repeat the parameter to whitelist multiple messages.importpb=true|false: emitPb.solas a separate artifact and import it from generated files.
Example:
protoc \
--sol_out=msg=Msg1,msg=Msg2,importpb=true:test/lib \
test/proto/unit/test.proto test/proto/unit/a.proto test/proto/unit/b.proto
Development Workflow
Regenerate the checked-in test fixtures:
bash test/generate_sol_pb.sh
Human-readable fixture inputs live under test/textpb/{unit,bench}/*.textpb. The regeneration script encodes them via protoc and writes binary .pb outputs into test/fixtures/bin/ for Foundry to consume.
Run Go validation:
go test ./...
Run only the committed end-to-end generator integration test:
go test ./test -run TestGeneratorFixtures
Run only the generator unit tests:
go test ./generator
Run Solidity validation:
cd test
forge fmt --check
forge build
forge test -vv
Test Layout
test/
├── main_integration_test.go Go integration test (builds plugin, invokes protoc, asserts output shape)
├── generate_sol_pb.sh regenerates Solidity decoders + binary protobuf fixtures
├── bench_fixtures.manifest maps each bench textpb basename → its proto message type
├── foundry.toml Foundry project config (this is the Foundry root)
├── proto/
│ ├── unit/ test.proto, a.proto, b.proto — schemas used by the unit tests
│ └── bench/ chain.proto, entity.proto — AgentPay schemas used by the bench
├── textpb/
│ ├── unit/ msg*.textpb, b.textpb — fixture sources for the unit tests
│ └── bench/ bench_*.textpb — fixture sources for the bench
├── fixtures/bin/ *.pb regenerated from textpb (gitignored)
├── lib/ generated Solidity decoder libraries (Foundry src)
└── sol/ Foundry test root
├── PbDecoding.t.sol decoder correctness + malformed-input revert tests
├── utils/TestBase.sol minimal hand-rolled assertEq helpers (no forge-std dep)
└── bench/ decode-gas benchmark vs abi.encode/decode
├── Bench.t.sol paired pb_decX / abi_decX tests
└── benchmark.md benchmark numbers + interpretation
generator/generator_test.go covers plugin parameter parsing, soltype option decoding, and other generator-only invariants.
The integration test is intentionally committed because it is the cheapest end-to-end check that the plugin still builds, protoc still invokes it correctly, and the generated Solidity still carries the expected modernization and hardening changes.
CI
GitHub Actions is the authoritative CI. The workflow runs:
- Go tests, including the generator integration test.
- Fixture regeneration plus generated-output drift detection.
- Foundry formatting, build, and test checks.
The Forge job also installs Go and protoc because it regenerates Solidity and protobuf fixtures before running the Solidity checks.
Compatibility Notes
- Valid protobuf payloads remain wire-compatible with the previous generator for the supported schema features.
- Generated Solidity now targets
pragma solidity >=0.8.0;and uses the modernPb.solshared-runtime layout whenimportpb=trueis used. - Decoder behavior is intentionally stricter for malformed payloads: oversized
uint256byte strings, wrong-lengthaddressandbytes32fields, truncated varints, oversized varints exceeding the protobufuint64range (whether they appear as keys, length prefixes, or field values), and truncated length-delimited values now revert deterministically. - Per-field wire-type validation is folded into dispatch: each known tag matches only when both its tag number and its expected protobuf wire type appear together in the encoded key. A known tag arriving with the wrong wire type does not match any branch and is skipped per proto3 unknown-field semantics, leaving the field at its default zero value rather than decoding garbled data.
- Empty top-level messages and empty embedded messages are now accepted instead of failing due to the removed legacy
raw.length > 1guard. - Narrow integer overrides (
uint8,uint32,uint64from(soltype)or native proto types) silently truncate on-wire values that exceed the target width. The decoder follows Solidity narrow-cast semantics; producers that emit out-of-range values for a narrow field would be observed truncated, not rejected. If your contract treats a narrow field as an authoritative bound, validate it explicitly after decode. - Unknown fields with wire types
Fixed32(4-byte) andFixed64(8-byte) are skipped per the proto3 spec, even though the generator does not emit those types as decoded fields. This keeps older decoders forward-compatible with schemas that addfixed32/fixed64/float/doublefields under tags the older decoder isn't aware of.
Known Gaps
- No support for
int32orint64. - Nested message and enum definitions are still unsupported.
- Sparse or reordered proto enum numeric values are unsupported because Solidity enums must remain dense
0..N. - Repeated scalar numerics (
uint32,uint64,bool, enum) are decoded packed-only. The proto3 default is packed; schemas explicitly using[packed=false]are rejected at generation time. - The generator only emits decoders today.