When we first sat down to talk about connecting Gno to the rest of the Cosmos ecosystem via IBC, the idea seemed straightforward. Gno runs Tendermint2 under the hood. Cosmos SDK chains run CometBFT. Both are BFT consensus engines with overlapping trust assumptions. A light client on each side should be able to verify the other’s state, and IBC should just work.
In practice, it took the better part of eight months — fitted around other work, never the only thing on our plates — across four repositories and three GitHub organizations to get from “should just work” to actually working. This is the story of how we built it.
1. The Problem Space
IBC — the Inter-Blockchain Communication protocol — is the backbone of the Cosmos ecosystem. It lets sovereign blockchains exchange messages, tokens, and arbitrary data without relying on a trusted bridge. The security model rests entirely on light client verification: each chain runs a light client of the other, verifying block headers and Merkle proofs to confirm that the counterparty chain actually committed to a particular piece of state.
For standard Cosmos SDK chains, this is a solved problem. The 07-tendermint light client module in ibc-go handles it. But Gno is not a standard Cosmos SDK chain.
Gno uses Tendermint2 (TM2), a fork of the original Tendermint consensus engine that diverged before CometBFT was born. The differences are subtle but pervasive: different protobuf message structures, different header fields (NumTxs, TotalTxs, AppVersion exist in TM2 but not CometBFT), different commit signature formats (TM2 uses Precommits containing CommitSig structs rather than CometBFT’s flat Signatures array), different address encoding, and different validator set hashing. A CometBFT light client cannot verify a TM2 header, and vice versa.
Beyond the consensus-level differences, Gno’s execution model is fundamentally different from the Cosmos SDK. There are no “modules” in the traditional sense — application logic lives in realms (smart contracts written in the Gno language) and packages (reusable libraries). There is no protobuf-based message routing. There is no MsgCreateClient or MsgRecvPacket transaction type. Everything happens through either MsgCall (calling a specific function on a realm) or MsgRun (uploading and executing ephemeral code).
We made a deliberate architectural decision early on: target IBC v2. IBC v2 strips away the connection and channel abstraction layers from IBC v1. In v2, there are only clients and packets. You create a light client of the counterparty, register a counterparty client ID, and send packets directly. This simplified model was a much better fit for Gno’s realm-based architecture — implementing the connection and channel state machines in Gno would have been a lot of unnecessary complexity for no real gain.
The plan was:
- Build a Gno light client module (
10-gno) for the Cosmos SDK side (AtomOne), so AtomOne can verify Gno chain state. - Build IBC core logic and a Tendermint light client as Gno realms and packages, so Gno can verify AtomOne chain state.
- Build a modular IBC v2 relayer in TypeScript that understands both chain types and can shuttle packets between them.
- Build whatever supporting infrastructure turns out to be necessary along the way.
That fourth item ended up being bigger than anyone anticipated.
2. The Relayer Foundation
We started with the relayer. Without a working relayer there was no way to test anything end-to-end. The existing TypeScript relayer in the Cosmos ecosystem — confio/ts-relayer — supported IBC v1 exclusively. We needed IBC v2, we needed Gno as a first-class chain type, and we needed containerized testing infrastructure that could spin up multiple heterogeneous chains.
The first commit landed in July 2025 with a basic E2E testing workflow. From there we built outward: a CLI, containerization support, Docker-based chain orchestration, and the modular architecture that would eventually support both Cosmos SDK chains and Gno through a common interface.
The core of the architecture is BaseIbcClient, an abstract class that defines the full IBC client interface — creating clients, updating clients, building headers, querying proofs, sending packets, receiving packets, acknowledging packets, handling timeouts. Two concrete implementations extend it: TendermintIbcClient for standard Cosmos SDK chains and GnoIbcClient for Gno.
TendermintIbcClient is relatively straightforward — it wraps @cosmjs/stargate and @cosmjs/tendermint-rpc to interact with standard Cosmos SDK chains. GnoIbcClient is where things got interesting, and where we hit our first real architectural challenge.
2.1 The ephemeral realm pattern
On a Cosmos SDK chain, creating an IBC client is a matter of constructing a MsgCreateClient protobuf message and broadcasting it. On Gno, there is no MsgCreateClient. The IBC core realm exposes a CreateClient function that takes complex structured arguments — a ClientState and a ConsensusState — that cannot be expressed as simple strings or numbers in a MsgCall.
Gno’s answer to this is MsgRun: you write a complete .gno source file, upload it as an ephemeral package, and the chain compiles and executes it in a single transaction. The code can import any on-chain package or realm, construct arbitrarily complex data structures, and call any exported function.
We turned this into a template system. For each IBC operation — CreateClient, UpdateClient, RecvPacket, Acknowledgement, Timeout, RegisterCounterparty — there is a Handlebars template that generates valid Gno source code. The relayer fills in the template variables (chain ID, heights, validator sets, proofs, packet data) and submits the result as a MsgRun transaction.
Here is a simplified CreateClient template:
package main
import (
"time"
"gno.land/p/aib/ibc/lightclient/tendermint"
"gno.land/p/aib/ibc/types"
"gno.land/r/aib/ibc/core"
)
func main() {
clientState := tendermint.ClientState{
ChainID: "{{ chainID }}",
TrustLevel: tendermint.Fraction{2, 3},
UnbondingPeriod: {{ unbondingPeriod }},
TrustingPeriod: {{ trustingPeriod }},
LatestHeight: types.Height{
RevisionNumber: {{ revisionNumber }},
RevisionHeight: {{ revisionHeight }},
},
ProofSpecs: ics23.GetSDKProofSpecs(),
}
consensusState := tendermint.ConsensusState{
Timestamp: time.Unix({{ timestampSec }}, {{ timestampNanos }}),
Root: tendermint.MerkleRoot{Hash: hexDec("{{ appHash }}")},
NextValidatorsHash: hexDec("{{ nextValHash }}"),
}
id := core.CreateClient(cross, clientState, consensusState)
println(id)
}
The cross keyword there is Gno’s crossing function mechanism — it explicitly declares that a function call crosses realm boundaries, carrying the caller’s identity and permissions with it. Without cross, the IBC core realm would see the ephemeral package as the caller, not the relayer’s account. With cross, the realm knows exactly who initiated the operation. This matters for access control: RegisterCounterparty, for instance, uses runtime.OriginCaller() to verify that the same account that created a client is the one registering its counterparty.
The UpdateClient template is significantly more complex. It serializes an entire signed header: validator sets with addresses, public keys, and voting powers for every validator; commit signatures; block metadata; and trusted state. For a chain with 100 validators, this template expands into thousands of lines of Gno code. The relayer constructs all of it dynamically from RPC queries and submits it as a single transaction.
There is real elegance to this approach. Rather than modifying the Gno VM to support custom protobuf message types (which would have required core Gno changes and a long review cycle), we leverage the existing MsgRun infrastructure. The Gno compiler itself becomes our deserializer.
3. The TM2 RPC Client
Early in development we hit a wall: there was no TypeScript library for talking to TM2 nodes. The @cosmjs/tendermint-rpc library speaks CometBFT’s dialect of the Tendermint RPC protocol, and TM2’s responses have subtly different shapes — enough to break everything, not enough to be obvious about it. Header fields differ. Commit structures differ. Validator response formats differ.
So we built @gnolang/tm2-rpc from scratch, following the same patterns as @cosmjs/tendermint-rpc so it would feel familiar to Cosmos developers. The library provides a Tm2Client class with methods for all the standard RPC endpoints: block, commit, validators, status, abciQuery, broadcastTxCommit, and so on. Under the hood it handles the encoding differences:
- TM2 headers include
numTxs,totalTxs, andappVersionfields that don’t exist in CometBFT. - TM2 commits use
precommits(an array ofCommitSigobjects withvalidatorIndex,blockId,round, etc.) rather than CometBFT’s flatsignaturesarray. - TM2 block IDs use
partsHeaderinstead ofpartSetHeader. - TM2 validator addresses come back as bech32-encoded strings rather than raw hex.
- Date/time handling needed custom parsing to maintain nanosecond precision.
The library includes custom response decoders that normalize TM2’s JSON-RPC responses into typed TypeScript objects, and request encoders that format queries the way TM2 expects them.
tm2-rpc became the foundation for all Gno-side relayer operations. Every header query, validator set fetch, and proof request flows through it.
4. The Gno Light Client on AtomOne (10-gno)
With the relayer taking shape and the RPC client working, we turned to the Cosmos SDK side. AtomOne needed to be able to verify Gno chain headers, which meant building a new IBC light client module.
The approach was to fork 07-tendermint from ibc-go and modify it to handle TM2’s consensus format. The PR description called it “essentially a modified version of 07-tendermint light client with updated types” — which is true as far as it goes, but significantly undersells the amount of work involved. The devil was entirely in the details.
4.1 Core data structures
ClientState tracks the Gno chain’s identity and verification parameters: chain ID, trust level (a Fraction type defaulting to 2/3 that converts to/from cmtmath.Fraction for Tendermint compatibility), trusting period, unbonding period, max clock drift, latest verified height, proof specs for IAVL and simple Merkle proofs, and a frozen height for misbehaviour handling. The Validate() method enforces that the trust level is between 1/3 and 1 (exclusive of both endpoints), that periods are positive, and that proof specs are present.
ConsensusState stores the verified state at a specific height: a timestamp, a Merkle root (from the app hash), and the next validator set hash. It also carries an LcType field set to "gno" to distinguish it from standard Tendermint consensus states during serialization.
Header wraps a TM2 SignedHeader (header plus commit), the validator set at the header’s height, and a trusted height plus trusted validator set for bisection verification. Its ValidateBasic method converts the protobuf types to native Gno types and runs the standard TM2 validation logic.
4.2 Type conversion — the quiet killer
The most delicate part of the implementation lives in helpers.go. The module receives protobuf-encoded data from the relayer but needs to verify it using TM2’s native Go types from gnolang/gno/tm2/pkg/bft/types. This requires a conversion layer between two type systems that look similar but differ in ways that matter.
ConvertToGnoValidatorSet maps each protobuf validator to a bfttypes.Validator, converting bech32-encoded addresses to raw crypto.Address bytes and protobuf PublicKey to TM2’s ed25519.PubKeyEd25519. ConvertToGnoCommit maps protobuf CommitSig entries to TM2’s commit format, handling a particularly nasty edge case: absent validator signatures appear as zero-value structs in protobuf but must be represented as nil entries in TM2’s Precommits slice. Getting this wrong means the commit hash won’t match and verification silently fails. ConvertToGnoBlockID handles the PartsHeader vs PartSetHeader naming difference. ConvertToGnoSignedHeader and ConvertToGnoHeader tie it all together, mapping every header field including the TM2-specific ones like NumTxs, TotalTxs, and AppVersion.
4.3 Verification
The verification logic in verifier.go implements both adjacent and non-adjacent header verification using TM2’s signing scheme.
For adjacent headers (height N to N+1), verification is simple: check that the new header’s validators hash matches the trusted header’s next validators hash, then verify that more than 2/3 of the new validator set signed the commit.
For non-adjacent headers (skipping heights), bisection verification kicks in. The module checks two things: first, that at least the trust level proportion (default 2/3) of the trusted validator set signed the new commit — this proves that enough validators you already trust are vouching for the new state. Second, that more than 2/3 of the new validator set also signed it — this proves the commit is actually valid.
The core signature verification function, VerifyLightCommit, iterates through the commit’s precommits, looks up each validator by address in the provided validator set, constructs the canonical vote sign bytes (chain ID, type, height, round, block ID, and timestamp), and verifies the Ed25519 signature. It tallies voting power as it goes and short-circuits once the threshold is met.
There is also an HeaderExpired check that returns true if the current time is past the header’s timestamp plus the trusting period — expired headers are no longer trustworthy even if the signatures check out.
4.4 Misbehaviour detection
The module handles two forms of misbehaviour, both of which result in freezing the client (setting the frozen height to prevent further updates).
Equivocation — two different valid headers at the same height. If both headers pass signature verification against the trusted validator set, the validators have committed a consensus violation. The CheckForMisbehaviour function catches this by comparing header hashes at equal heights.
BFT time violation — non-monotonic timestamps at different heights. If header A is at a greater height than header B but has an earlier or equal timestamp, something has gone wrong. The module checks this by looking up the stored consensus states for each header’s height.
The verifyMisbehaviour function validates both headers in the misbehaviour evidence against their respective trusted states. Each header goes through checkMisbehaviourHeader, which verifies that the trusted consensus state hasn’t expired, that the trust level of the trusted validators signed the header, and that the header’s timestamp is within the trusting period.
4.5 State management
The store layer in store.go manages consensus states with an efficient key scheme. Heights are encoded as big-endian uint64 bytes for proper lexicographic ordering, which means iteration over stored consensus states returns them in height order. The module provides GetNextConsensusState and GetPreviousConsensusState for navigating between stored heights — these are used during misbehaviour checking to compare timestamps between adjacent stored states.
For delay period verification (required by IBC to ensure proofs are old enough), the module tracks when each consensus state was processed: SetProcessedTime records the block time, and SetProcessedHeight records the block height at which each consensus state was stored. verifyDelayPeriodPassed then checks that enough time and enough blocks have elapsed since the state was stored before accepting proofs against it.
PruneAllExpiredConsensusStates cleans up consensus states whose trusting period has passed, along with their associated processed time and processed height entries. This keeps the store from growing unbounded as the client receives updates.
4.6 Module registration
The module registers itself with the Cosmos SDK through the standard AppModule / AppModuleBasic pattern in module.go, with ModuleName set to "10-gno". The codec.go file registers the four key interfaces — ClientState, ConsensusState, Header, and Misbehaviour — with the protobuf registry so the IBC framework can unmarshal them. Client IDs on AtomOne follow the pattern 10-gno-0, 10-gno-1, and so on.
The module went through several iterations. The first PR (#231) was opened in October 2025 and closed in favor of a cleaner approach. A second attempt (#232) followed. The final version (PR #261) landed in January 2026, restructured to minimize the diff from 07-tendermint so reviewers could focus on what actually changed rather than getting lost in boilerplate. It was merged on April 13, 2026, with the E2E relayer tests confirming the full round-trip worked.
5. IBC on Gno — The Realms
While the relayer and light client work was progressing on the Cosmos SDK and TypeScript side, the other half of the problem was being tackled in parallel: implementing IBC in the Gno language itself, running on Gnoland. This was not a port of ibc-go — it was a ground-up reimplementation from specification, designed around Gno’s realm-based execution model.
5.1 Packages
p/aib/ibc/types — Core IBC v2 types: Packet, Payload, Height, MerklePath, Acknowledgement, commitment hashing functions, and the message types (MsgSendPacket, MsgRecvPacket, MsgAcknowledgement, MsgTimeout). The Packet type follows IBC v2’s structure: sequence, source client, destination client, timeout timestamp, and a list of payloads. Each Payload carries source port, destination port, version, encoding, and value bytes.
p/aib/ibc/lightclient — The light client interface that any implementation must satisfy: Initialize, VerifyClientMessage, CheckForMisbehaviour, UpdateState, VerifyMembership, VerifyNonMembership, Status, LatestHeight, and TimestampAtHeight.
p/aib/ibc/lightclient/tendermint — A full Tendermint light client written in Gno, capable of verifying CometBFT headers from Cosmos SDK chains. This is the mirror of 10-gno on AtomOne: while 10-gno lets AtomOne verify Gno, this package lets Gno verify AtomOne.
It stores consensus states in an AVL tree keyed by height (with natural sort ordering for correct height comparison). It implements header verification with the same trust level and bisection logic as the Cosmos SDK version, commit signature verification using Ed25519 (via Go’s crypto/ed25519 standard library, which Gno supports), and misbehaviour detection for both equivocation and BFT time violations.
p/aib/ics23 — A complete ICS-23 proof verification implementation in Gno. Implementing ICS-23 from scratch is no small thing — it is the standard for Merkle proof verification across IBC, supporting existence proofs, non-existence proofs, and chained proofs across multiple tree levels. The implementation handles IAVL tree proofs and simple Merkle proofs, with leaf and inner node operations, hash verification, spec compliance checking, and left/right neighbor validation for non-existence proofs.
p/aib/encoding/proto — A minimal protobuf encoding library, hand-written since Gno has no protobuf compiler. It provides AppendVarint, AppendFixed64, AppendLengthDelimited, AppendTime (for google.protobuf.Timestamp), AppendTag, DecodeVarint, and DecodeString — just enough to serialize and deserialize IBC packets and commitments in a format Cosmos SDK chains can verify.
p/aib/merkle — Merkle tree utilities for computing simple Merkle roots, used in the commitment scheme.
5.2 Realms
r/aib/ibc/core — The IBC core realm, implementing the full IBC v2 packet lifecycle:
CreateClient(cur realm, clientState, consensusState)— Creates a new light client, returning a client ID like07-tendermint-1. Emits acreate_clientevent.RegisterCounterparty(cur realm, clientID, merklePrefix, counterpartyClientID)— Registers the counterparty’s client ID and Merkle prefix. Only the account that created the client can call this — enforced viaruntime.OriginCaller().UpdateClient(cur realm, clientID, clientMessage)— Updates a client with a new header or submits misbehaviour evidence. Checks for misbehaviour first and freezes the client if detected.SendPacket(cur realm, msg)— Sends a packet after checking client status and timeout bounds, stores the packet commitment, and invokes the app callback.RecvPacket(cur realm, msg)— Receives a packet by verifying the commitment proof against the light client, stores a receipt to prevent replays, and invokes the app callback. The acknowledgement returned by the app is stored for the counterparty to pick up.Acknowledgement(cur realm, msg)— Processes an acknowledgement by verifying the ack proof and invoking the app callback. Deletes the original commitment.Timeout(cur realm, msg)— Processes a timeout by verifying a non-membership proof (proving the counterparty never received the packet) and invoking the app callback.
Every function uses the cur realm crossing parameter. The commitment scheme stores packet commitments, receipts, and acknowledgements with keys constructed from the client ID and sequence number, using type-specific separators (0x01 for commitments, 0x02 for receipts, 0x03 for acknowledgements). These keys live in the Gno VM’s persistent storage, backed by an IAVL tree that the relayer can query with proofs.
r/aib/ibc/apps/transfer — A full ICS-20 token transfer application. It handles three token types:
- IBC voucher tokens (denom starting with
ibc/): minted as GRC20 tokens on receive, burned when sent back to the source chain. - Non-IBC GRC20 tokens: escrowed via
TransferFromwhen sent, returned on refund. - Native coins (ugnot): escrowed via the banker when sent, returned on refund.
The transfer app implements OnSendPacket, OnRecvPacket, OnAcknowledgementPacket, and OnTimeoutPacket callbacks, constructs FungibleTokenPacketData using the handwritten protobuf encoder, and handles denom path tracking for multi-hop transfers.
5.3 The protobuf challenge
One of the more interesting engineering problems on the Gno side was protobuf encoding. IBC v2 lets applications pick their own encoding for packet data, so protobuf wasn’t strictly required for communication — but it was unavoidable for verification. A Tendermint block’s hash is a Merkle tree computed over each header field’s protobuf-encoded bytes, and the “bytes to sign” for a commit are the length-delimited protobuf encoding of a CanonicalVote. To verify a counterparty’s header or check a validator’s signature, the Gno light client has to reproduce the exact protobuf bytes the original proposer signed. But Gno has no protobuf compiler, no code generation, and no reflection.
The solution was a minimal encoder written by hand in p/aib/encoding/proto. Each IBC type (Packet, Payload, Acknowledgement, FungibleTokenPacketData) has a ProtoMarshal() method that manually encodes each field in wire-format order. This is admittedly fragile — any schema change requires manually updating the encoding functions — but the serialized output is byte-for-byte compatible with what ibc-go produces, and for a fixed protocol like IBC the schema doesn’t change often.
We also needed protobuf decoding for incoming data. DecodeVarint and DecodeString handle the field-by-field parsing.
5.4 Event-driven packet discovery
On Cosmos SDK chains, the relayer discovers packets by querying Tendermint events (e.g., send_packet.packet_source_client). On Gno, events are emitted via chain.Emit() and indexed by Gno’s transaction indexer, which exposes them through a GraphQL API rather than CometBFT’s event subscription system.
The relayer queries Gno’s GraphQL endpoint to find send_packet and write_acknowledgement events within a height range, decodes the hex-encoded packet data from event attributes, and determines which packets still need relaying. This was one of the bigger departures from how existing Cosmos relayers work.
6. Proof Verification Across Chains
Getting proof verification to work correctly across the chain boundary was probably the hardest part of the whole project — and the least glamorous. When the relayer submits a RecvPacket to Gno with a proof from AtomOne, or submits an Acknowledgement to AtomOne with a proof from Gno, the proof has to be constructed exactly right for the target chain’s proof specification. One wrong byte and verification fails. No helpful error, just a rejected transaction.
6.1 Proofs from AtomOne (for Gno to verify)
When Gno needs to verify that AtomOne committed a packet, the relayer queries AtomOne’s IAVL store for the packet commitment at the appropriate key path, requesting a proof. AtomOne returns an ICS-23 proof with two operations: an IAVL proof (for the IBC module’s store) and a simple Merkle proof (for the multi-store root). The relayer serializes this as a MerkleProof and includes it in the RecvPacket template.
On the Gno side, verifyChainedMembershipProof in the Tendermint light client verifies the chained proof by checking each existence proof against its spec, calculating subroots at each level, and verifying that the final root matches the consensus state’s Merkle root.
6.2 Proofs from Gno (for AtomOne to verify)
When AtomOne needs to verify that Gno committed a packet, the key format is different. Gno stores IBC state in the VM’s persistent storage under paths like /pv/vm:gno.land/r/aib/ibc/core:{clientID}{separator}{sequence}. The relayer queries Gno’s ABCI store for this key with proof enabled, then converts the proof from Gno’s format to ICS-23 format.
The key construction in the relayer is exact:
const key = mergeUint8Arrays(
toAscii(`/pv/vm:gno.land/r/aib/ibc/core:${clientId}`),
fromHex("01"), // separator for commitments (02 for receipts, 03 for acks)
seq, // big-endian 8-byte sequence number
);
This matches the key format the Gno IBC core realm uses when storing commitments, and the proof returned by Gno’s IAVL store can be verified by AtomOne’s 10-gno module through standard ICS-23 verification.
7. E2E Testing Infrastructure
None of this would mean much without comprehensive end-to-end tests. We built a Docker-based testing infrastructure that spins up four separate chains and runs the full IBC lifecycle across different chain pairs.
The Docker Compose setup orchestrates five services:
- Mars — A Cosmos SDK chain (standard, no Gno light client). Used for Cosmos-to-Cosmos IBC testing.
- Venus — A second Cosmos SDK chain. The Mars/Venus pair validates that the relayer works correctly for standard IBC v2 (and v1) between Cosmos SDK chains before introducing Gno into the mix.
- Gno — A Gnoland chain with the IBC realms pre-deployed.
- A1Gno — An AtomOne chain with the
10-gnolight client module registered. This is the Cosmos SDK chain that actually talks to Gno. - Relayer — The TypeScript relayer connecting the chains.
The test suite is split across multiple files:
main.e2e.ts tests the Cosmos-to-Cosmos path. It creates IBC v2 clients between Mars and Venus, then also sets up an IBC v1 channel between them. This validates the relayer’s backward compatibility — it can handle both IBC v1 connection/channel handshakes and IBC v2’s simpler client-only model.
gno.e2e.ts tests the Gno-to-AtomOne path. It creates a 07-tendermint client on Gno (tracking A1Gno’s state) and a 10-gno client on A1Gno (tracking Gno’s state), then registers counterparties on both sides.
txs.e2e.ts is where the real action is. It runs six transfer scenarios:
- Mars to Venus — Standard ICS-20 transfer between two Cosmos SDK chains. Sends
uatonefrom Mars, verifies the IBC voucher arrives on Venus. - Venus to Mars (return) — Sends the IBC voucher back, verifying it gets burned on Venus and the original
uatoneis unescrowed on Mars. - AtomOne to Gno — Sends
uatonefrom A1Gno to Gno. On the Gno side, this mints a GRC20 IBC voucher token. This is the first cross-VM-boundary transfer. - Gno to AtomOne (return) — Sends the IBC voucher back from Gno, burning the GRC20 token and unescrowing the original
uatoneon A1Gno. - Gno to AtomOne (native) — Sends
ugnot(Gno’s native coin) from Gno to A1Gno. This exercises the native coin escrow path on Gno’s side and mints an IBC voucher on A1Gno. - AtomOne to Gno (native return) — Sends the
ugnotIBC voucher back from A1Gno, burning it and unescrowing the originalugnoton Gno.
These six tests cover every combination: Cosmos-to-Cosmos, Cosmos-to-Gno, Gno-to-Cosmos, IBC voucher tokens in both directions, native coins in both directions, and the full escrow/mint/burn/unescrow lifecycle. The tests use Vitest and run in CI.
8. The Timeline
Looking back at the git history, it’s worth remembering that none of this was anyone’s only job. The work happened in bursts between other priorities, which is part of why the calendar spread is what it is:
- July 2025 — First E2E testing workflow for the relayer.
- August 2025 — CLI, containerization, custom address prefix support.
- September 2025 — Docker image builds, dev environment improvements. First
tm2-rpcE2E CI workflow. - October 2025 — First attempts at the AtomOne Gno light client (PRs #231 and #232, eventually closed).
- November 2025 — Initial Gno IBC realm structure. First (closed) Gno IBC PR on the relayer.
- January 2026 — Refined AtomOne Gno light client PR opened. Gno IBC support merged in the relayer.
- February 2026 — GRC20 voucher tokens. Transfer functionality. E2E transfer tests.
- March 2026 — Bug fixes,
tm2-rpcv1.0.0, transfer app refinements, memo support, non-IBC GRC20 handling. - April 2026 — Final AtomOne light client PR merged. Continued refinement.
9. Lessons Learned
The VM is your serializer. Generating Gno source code and submitting it via MsgRun turned what could have been months of work adding custom message types to the Gno VM into a solved problem using existing infrastructure. The compiler handles deserialization, type checking, and execution. No VM changes needed.
Handwritten protobuf is viable at small scale. We only needed to encode a handful of message types. A careful ProtoMarshal function for each one, producing wire-compatible output, was enough. Not how you’d build a general-purpose system, but for a fixed protocol it works.
IBC v2 was the right call. Skipping the connection and channel state machines cut the Gno-side implementation surface area dramatically. A client and counterparty registration is all you need to start sending packets.
Proof verification is where the real complexity hides. Getting proofs to work across two different IAVL implementations, with different store key conventions, different path encodings, and different proof operation formats, was the hardest part. The proof construction and verification code is small but represents the deepest understanding of both chains’ internals.
Type conversion will get you. The helpers.go file in 10-gno — a couple hundred lines of converting between protobuf types and TM2 native types — doesn’t look like much. But each field mapping (bech32 to raw bytes, PartsHeader vs PartSetHeader, nil vs zero-value commit signatures) was a potential consensus failure. These bugs only show up at runtime, usually as a commit hash mismatch with no helpful error message.
Test across the boundary early. We had unit tests passing on both sides independently for weeks before the first successful E2E relay. The remaining bugs were all at the interfaces: timestamp unit mismatches (seconds vs nanoseconds), off-by-one errors in proof heights (the app hash at height H reflects state committed at H-1), and encoding mismatches in commitment hashes.
10. What’s Next
With IBC v2 infrastructure in place, any Gno realm can register as an IBC application, define its payload format, and exchange messages with any Cosmos SDK chain running the 10-gno light client. The transfer app is working, enabling token movement between Gno and the broader Cosmos ecosystem.
There is still work ahead. The relayer needs hardening for production use. Client recovery and upgrade paths need implementation on the Gno side. And the access control model for relayer operations — currently based on creator identity — may evolve.
But the bridge is built. Two chains with different consensus implementations, different execution models, different programming languages, and different state storage systems can verify each other’s state and exchange trust-minimized messages. IBC was always meant to connect any BFT chain to any other — not just Cosmos SDK chains talking to themselves. With Gno, we finally proved that’s true.
This work was a collaboration across the All in Bits, AtomOne, and Gnolang teams. The code lives in four repositories: atomone-hub/atomone (10-gno light client module), allinbits/gno-realms (IBC realms for Gno), allinbits/ibc-v2-ts-relayer (modular IBC v2 relayer), and gnolang/tm2-rpc (TM2 RPC client library).
