Interchain Privacy is Here
Penumbra has achieved another major milestone in our journey to mainnet. The Penumbra testnet now integrates IBC and ICS-20 token transfers, bringing privacy to the Interchain: decentralized, permissionless token transfers from any IBC-compatible chain directly into and out of Penumbra's multi-asset shielded pool.
We believe that for privacy to succed, it must be useful. That's why we built an advanced DEX from the future that allows users to trade on-chain without leaking their alpha, protecting their trading strategy and history. But where do those assets come from? That's where Penumbra's IBC implementation comes in: it connects the shielded pool and DEX to any IBC-compatible chain.
This functionality is live today, with an IBC channel established between the Penumbra and Osmosis testnets. For instance, this transaction shields OSMO from the Osmosis testnet by moving it into Penumbra, and this transaction unlinkably transfers it back to a different Osmosis account. Last summer, we were the first non-Cosmos chain to open an IBC connection to the Cosmos Hub. This milestone is another first: the first Cosmos assets to be moved into a shielded pool over IBC.
In this post, we'll dig into how Penumbra's IBC functionality works, top-to-bottom: starting with a high-level overview of the user experience, and then working through a deep dive of the IBC stack and the challenges we overcame along the way.
Interchain Privacy
Let's walk through the steps required for a user of an IBC chain to shield their funds on Penumbra.
- Generate a Penumbra address using a Penumbra-compatible wallet.
- Send funds to that address, using a standard ICS-20 cross-chain transfer.
That's it. There are no extra steps. The user's funds are now shielded, and will appear in their Penumbra wallet.
Unlike Zcash or Namada, where users have to think about whether their assets are recorded privately, Penumbra delivers conceptual simplicity. Transferring tokens into Penumbra shields them. Transferring tokens out of Penumbra unshields them. The entire Interchain is our transparent pool.
This user experience is made possible by deep protocol-level integration work that ensures Penumbra integrates seamlessly with IBC. Let's look under the hood to see how it works.
Shielded ICS-20 Transfers
To initiate a cross-chain transfer into Penumbra, an IBC-connected counterparty chain will write an ICS-20 packet into its state. The ICS-20 packet describes information about the transfer: the sender address, the destination address, the amount, and the token denomination. This behavior is all standard, and requires no special Penumbra support.
An IBC relayer observes this packet, and forwards it to the Penumbra chain, along with a proof that it was created by the counterparty chain. When the Penumbra chain validates and processes the packet, it mints a new note controlled by the destination address and inserts it directly into the shielded pool. Penumbra's shielded pool is a next-generation iteration on the Zcash design, with support for arbitrary IBC assets, efficiency improvements, and more.
The new note is then detected by the user's Penumbra wallet as it privately scans the chain, the same way it would detect any other shielded transfer to their account. And no special handling on the client side is required for IBC assets, because Penumbra's shielded pool identifies assets by asset ID, designed to be computed directly from any ICS-20 denomination string, so IBC assets are handled just like any other asset.
The user can now transact privately on Penumbra, without publicly linking their future activity to the original deposit. To transfer back out of Penumbra, they create a transaction that burns shielded assets on Penumbra and instructs the Penumbra chain to create an outgoing transfer packet addressed to the counterparty chain.
Handling outbound transfers would seem to pose a problem. ICS-20 specifies that transfer packets include the sender address. This is crucial from a safety perspective, because it ensures that funds can be returned if the transfer times out without being acknowledged by the other chain. But putting the same address in inbound and outbound transfer packets would link them, destroying privacy. To handle this and similar use cases, Penumbra accounts are designed to support randomized ephemeral addresses, which are publicly unlinkable but privately point to the same Penumbra account. Penumbra clients automatically generate a new ephemeral address when creating a withdrawal transaction, ensuring that users can recover funds from failed transfers without compromising privacy.
Crucially, however, users don't need to consider or even know about any of these details. Because Penumbra's entire protocol and cryptography stack was holistically designed around this use case, we can provide a seamless user experience without compromising on privacy or security.
Privacy Beyond Transfers
Our strategy has been to start with one concrete use case, interchain private trading, and use it to deeply inform our protocol design. But this doesn't mean our ambition is limited -- it just means we think it's better to move from the specific to the general, iterating on designs as they interact with concrete technical requirements.
So while Penumbra will only support ordinary token transfers at launch, we haven't stopped thinking about what's next. After building private ICS-20 for token transfers, private Interchain Accounts (ICA) seem like an interesting stepping stone towards Penumbra's endgame as a fully general interchain privacy layer.
Like our ICS-20 implementation, which doesn't require any special handling on the counterparty chain, these interchain accounts would appear to the counterparty chain like any other. However, they'd be controlled by a bearer NFT recorded in the Penumbra shielded pool, and funded via shielded transfers. This would allow Penumbra users to privately interact with public state on other chains, before withdrawing back to the shielded pool.
If you think this could enable interesting use cases, let us know in Discord!
Penumbra's IBC stack
As one of the first non-Cosmos-SDK chains to build in the Cosmos ecosystem with Tendermint/CometBFT, much of the required tooling simply didn't exist, and so we've had to build a lot of the stack from the ground up, extracting reusable code along the way, and our IBC stack is no exception.
An IBC implementation interfaces between the messages defined by interchain standards (ICSs) and the host chain's state. It processes messages from counterparty chains, executes state changes, and writes new messages into the chain state. For this reason, while the abstract IBC protocol is generic, a specific IBC implementation is fundamentally intertwined with the host chain's state and execution model.
Our IBC implementation has two parts:
ibc-types
, a minimal, general-purpose Rust crate defining the foundational data types defined by the IBC protocol;penumbra-ibc
/ibc-async
, an asynchronous IBC implementation built on top ofibc-types
.
The ibc-types
library is intended to provide a common language for working
with IBC data, independent of the choices made by any specific implementation,
similar to the way that tendermint-rs
provides a common language for working
with Tendermint/CometBFT data across the Rust IBC ecosystem.
The penumbra-ibc
crate is our IBC implementation, designed around unique
features of Penumbra's state and execution model.
Penumbra's state model (defined by our penumbra-storage
crate) is built around
asynchronous reads, to allow more efficient disk I/O. A part of the Penumbra
code that needs multiple pieces of data from the chain state can read them
concurrently, rather than waiting for the first access to complete before
starting the second. Writes are performed synchronously, in lightweight
copy-on-write snapshots, so all execution is simulated in a state fork and only
applied later.
Penumbra's execution model (defined by our penumbra-component
crate, akin to
SDK modules) splits processing into multiple phases: stateless checks (like
proof verification) that can be parallelized across transactions, stateful
checks that can be parallelized within a transaction, and execution, which
operates serially.
Together, these design choices let us push most work out to the end-user device, parallelize as much verification as possible, and cut down dependency chains in the critical path of the remaining on-chain execution work. Our IBC implementation can take advantage of these properties and integrate cleanly with the rest of our code.
To our great disappointment, however, we weren't able to reuse or share code
with other Rust IBC efforts, such as Informal Systems' ibc-rs
. We originally
built the Penumbra IBC implementation using data types defined by ibc-rs
.
Later, the ibc-rs
team started filling out a complete implementation of IBC,
initially intended for Anoma. We weren't able to reuse it, because Penumbra's
state is accessed asynchronously, and it went through significant churn on its
way to discovering the right abstractions for its initial use-case, just as
Penumbra's execution model churned on our way to discovering the right
abstractions for our use case.
In our discussions with the ibc-rs
team last year, we highlighted our view
that the right move was to collaborate on a common set of domain types for
modeling IBC messages, and work towards deeper integration as codebases
stabilize. Historically, this worked fine, because IBC messages themselves are
specified as part of the IBC protocol, and so are relatively insulated from
implementation churn.
However, last February, the ibc-rs
team let us know that they were planning to
remove the IBC message types entirely, and shift ibc-rs
from a general-purpose
library to only provide an opaque IBC implementation. When we let them know this
would break our implementation, they suggested forking the code, as they'd done
with Hermes, Informal's IBC relayer, when its needs also conflicted with the
ibc-rs
direction. (Hermes now maintains its own entirely distinct library for
modeling IBC messages, called ibc-relayer-types
). As a result, we were forced
to fork when those changes landed and we no longer had an upstream.
Our hope is that going forward, we can reduce fragmentation and duplication of
effort in the Rust IBC ecosystem, as we pull out minimally-scoped components
bottom-up that can be reused by other projects. This strategy has worked in the
past, such as our domain types modeling the ABCI protocol used to communicate
with Tendermint (upstreamed into tendermint-rs
), our ABCI server
implementation (tower-abci
, reused by Namada), our Jellyfish merkle tree
implementation (the jmt
crate, now co-developed with Sovereign Labs), and our
async storage and state management library (penumbra-storage
, reused by
Astria).
The first step is ibc-types
, a minimally-opinionated set of
crates for modeling IBC data. We'd be interested in having this code reused by
either ibc-rs
or Hermes, were either of those teams so inclined. Longer-term,
we plan to continue to extract our IBC implementation into a standalone,
Penumbra-independent crate called ibc-async
, as described in our IBC Rescue
Plan tracking issue.
Coordinating ICS-23 Upgrades
One remaining speedbump relates to our merkle tree structure. Penumbra uses the Jellyfish Merkle Tree, a sparse merkle tree originally designed for Libra/Diem. To implement IBC, we needed to define an ICS-23 proof specification, to allow counterparty chains to understand our merkle proofs. ICS-23 is the Interchain Standard defining a generic merkle proof format, so that different chains can use different merkle trees without each chain having to specially implement verification for every kind of of merkle tree used by any counterparty chain.
ICS-23 specifies both existence proofs (proving that some key has some value) and _non_existence proofs (proving that some key does not exist in the tree). Nonexistence proofs are critical to supporting packet timeouts, which enable users to recover funds from failed transfers.
However, the original version of ICS-23 accidentally baked assumptions from the
IAVL tree used by the Cosmos SDK into non-existence proof specification. In
ICS-23, a non-inclusion proof for key k
is a pair of inclusion proofs for keys
k1
, k2
, a check that k1
and k2
are adjacent in the tree, and a check
that k1 < k < k2
. The problem is that this mechanism only works on ordered
trees like the IAVL tree where the key ordering corresponds to the tree
structure. On a sparse merkle tree like our JMT, this isn't the case -- sparsity
is achieved by using the hash H(k)
of the key, so keys are randomly ordered.
To fix this, we upstreamed a prehash_key_before_comparison
field into the
ICS23 spec. This provides a backwards-compatible extension supporting trees like
the JMT: if the field is absent, it's false
, and noninclusion proofs are
verified as before; if it's present, it's true
, and the noninclusion proof
verifier checks that H(k1)
and H(k2) are adjacent and that
H(k1) < H(k) <
H(k2)`.
This change was included in ICS-23 v0.10.0
. Unfortunately, however, it isn't
forwards compatible, because the Cosmos SDK rejects proof specifications with
unknown fields.
To ensure support for connections to Penumbra (and other chains with sparse
merkle trees), we're working to coordinate deployment of ICS-23 v0.10.0
across
the ecosystem. Any ibc-go
version greater than v7.1.0
includes it already,
and we've been working with the Interchain team to make backports available.
Because only ics23
needs updating, it may be possible to just use a replace
directive in go.mod
, without any other code changes.
In the meantime, we temporarily patched our IBC implementation to allow
counterparty chains to be created with a proof specification with an incorrect
prehash_key_before_comparison
field. This means timeouts won't work, but
allows us to exercise all other IBC functionality against existing Cosmos-SDK
testnets.
Getting Started
Shielded IBC is live now! Check out the user guide to participate in the
testnet and test out interchain transfers using pcli
.
Stay tuned for more updates on our pathway to mainnet. Slowly, and then all at once.