Authors: @NotJeremyLiu, @barry
Intro
This blog post is designed to help smart contract and module developers prepare for the cross-chain future by offering simple guidance on how to make their code more accessible to omnichain users and transaction flows.
As blockchains become more numerous + applications continue to segregate into different chains, users will increasingly be attempting to interact with contracts + modules using capital they have on remote chains. Making these interactions seamless is our goal here.
We’re writing this post for a few reasons:
- The Skip API recently launched “post-route actions”, which allow frontends to trigger a variety of arbitrary actions after multi-chain swap + transfer flows (e.g. CosmWasm smart contract calls, Stride autopilot commands). We’re excited to see developers use this new feature to build dapps + frontends that allow staking, depositing, borrowing/lending, and buying NFTs from anywhere. But we want to make sure folks know how to design their contracts to make these omnichain flows accessible for end users.
- Relatedly, SCV-Security recently discovered a security vulnerability in how PFM and IBC-hooks (the core technologies that enable these expressive cross-chain workflows) interact. This vulnerability has been fixed in the latest versions of PFM (7.0.0+, 6.1.0+, 5.2.0+, 4.1.0+), so we strongly recommend all chains upgrade to a newer version. But the fix introduces new UX challenges that smart contract developers need to be aware of to provide their end users with the best cross-chain UX possible.
The TL;DR is:
- To make contracts & modules maximally accessible cross-chain, we recommend all developers implement explicit authority/token delegation in the call data of permissionless operations (e.g. depositing, LPing) that unlock permissioned inverses (e.g. withdrawing) or award tokens (e.g. LP shares) that would otherwise be implicitly given to the caller address. (e.g. A vault should allow the depositer to specify a distinct / different address as a withdrawal address). We call this the “authority delegation pattern”
- The authority delegation pattern is a major accessibility upgrade for the omnichain world because the address that calls a contract will depend on where the transaction flow first originated (e.g. A contract call that originates on a remote chain A will have a different caller address than one that originates on the local chain, one that originates on a remote chain B, or even one that originates on a remote chain C + gets forwarded through remote chain A). Allowing users to specify a delegate address in the calldata means a contract can initially be called from anywhere (including via some crazy PFM-enabled route), and later the user can just use their local address reliably when performing any future permissioned interactions.
- The dependence of caller address on the originating location of the cross-chain transaction flow is a feature, not a bug, of PFM + IBC-hooks. This is what prevents users from spoofing one another cross-chain without actually being able to sign on their behalf. (We’ll see how).
In this article, we’ll dive in more detail and provide clear examples to make this all real. We’ll cover:
- The security vulnerability that SCV-Security originally discovered in PFM / IBC-hooks
- The solution to the vulnerability
- How the solution introduced new UX challenges
- Why enabling users to specify recovery addresses is a pretty good solution to those UX challenges that improves cross-chain accessibility
- How multi-caller contracts (CW-1) make modules accessible cross-chain
- How Skip is improving ibc-hooks to further enhance cross-chain accessibility for chains that don’t support CosmWasm
This post is a part of our series on interoperability. The goal of the series is to produce simple, actionable, content for developers on all things bridging, relaying, IBC, and interop.
This post assumes some level of familiarity with PFM & IBC-hooks. The best introductions to those technologies is our last post: How to Give IBC Super Powers.
Running Example: Bob the depositer and Alice the thief
For the purposes of this post, we’ll use the following long running example:
- Bob has ATOM on Osmosis in the
osmoBOB
address - Bob wants to deposit his ATOM into Mars on Neutron — where he controls
neutronBOB
(Bob is a degen gambler currently at risk of getting liquidated + needs to top up his collateral)
To ensure he has the correct/canonical version of Atom on Neutron, he must first transfer his ATOM from Osmosis to the Hub (where he controls the cosmosBOB
address), then from the Hub to Neutron.
Meanwhile, Alice is a frightening hacker whose mission is to steal Bob’s ATOM out of the money market he just deposited into.
Putting the whole thing together, we get something like this:
Bob can fully execute this entire cross-chain flow with a single transaction that leverages PFM to forward his ATOM through the Hub + IBC-hooks to call the contract on. This is what the message call data would look like:
MsgTransfer {
// INITIAL TRANSFER DATA
sender: "osmosBOB"
receiver: "cosmosBOB"
sourcePort: "transfer"
sourceChannel: "channel-1"
token: {
denom: IBC_ATOM_ON_OSMOSIS
amount: 1_000_000
}
memo: {
// PFM DATA TO FORWARD TO NEUTRON
forward: {
receiver: "neutronBOB",
port: "transfer",
channel: "channel-141",
timeout: 0,
retries: 2,
// IBC HOOKS DATA TO CALL MARS CONTRACT
next: {
wasm: {
contract: "MARS_COLLATERAL_VAULT_ON_NEUTRON",
msg: {
MARS_DEPOSIT_CALLDATA_HERE
}
}
}
}
}
}
The Vulnerability: Address Spoofing via PFM
The widely adopted version of PFM allows attackers to set the “sender” of their transfer to the terminal chain to be any address on the intermediate chain — even if they don’t control the address and can’t actually sign on its behalf.
As a result, this version of PFM effectively allows the attacker to impersonate an arbitrary user on the intermediate chain from the point of view of the destination chain and the smart contracts on it (at least contracts that rely on the sender field).
Returning to our example: Alice can interact with contracts on Neutron as if she’s Bob by initiating an IBC transfer on Osmosis to the Hub, where she:
- Sets the receiver address to
cosmosBOB
- Triggers a transfer to Neutron using PFM
- Calls whatever contract she wants as Bob (e.g. In this case, she could borrow more tokens up to his deposit limit)
Under the hood, Alice can execute this attack because PFM automatically sets the sender
of the outbound transfer to receiver
of the inbound transfer.
So Alice can execute the attack simply by generating a transfer like this one:
MsgTransfer {
// INITIAL TRANSFER DATA
sender: "osmoALICE" // THIS IS WHATS DIFFERENT
receiver: "cosmosBOB" // BOB'S DUPLICATE RECEIVER
sourcePort: "transfer"
sourceChannel: "channel-1"
token: {
denom: IBC_ATOM_ON_OSMOSIS
amount: 1_000_000
}
memo: {
// PFM DATA TO FORWARD TO NEUTRON
forward: {
receiver: "neutronBOB",
port: "transfer",
channel: "channel-141",
timeout: 0,
retries: 2,
// IBC HOOKS DATA TO CALL MARS CONTRACT
next: {
wasm: {
contract: "MARS_COLLATERAL_VAULT_ON_NEUTRON",
msg: {
MARS_BORROW_CALLDATA
}
}
}
}
}
}
The Solution: Origin-based Address Derivation
The problem here is that PFM sets the final transfer sender to be the intermediate receiver with no verification. Instead, PFM should set the final transfer sender to an address based on where the incoming transfer originated — since this is what distinguishes the legitimate flow from the illegitimate one.
This is exactly what THIS PR does. It sets the sender address for the outgoing transfer based on the sender and channel over which the incoming transfer was received.
Returning to our example, Alice’s attempt to hack Bob will now go differently:
When the Mars contract eventually gets called on Neutron, Alice’s transaction will call it from a different address than Bob’s, ensuring she can’t borrow on his behalf.
Since this path-based caller-address derivation protects the end user, we strongly recommend all chains upgrade to a version of PFM that includes the patch: 7.0.0+, 6.1.0+, 5.2.0+, 4.1.0+
The Problem with the Solution: Cross-chain workflow fracturing (Path-Dependent Caller Addresses)
This is great! The security vulnerability is effectively mitigated. Contracts can reliably distinguish between users, even when the users are leveraging intermediate hops to initiate the transactions from far flung chains.
But the downside of this is that now the address calling a contract depends on the chain the user initiates the flow from AND the IBC path she takes to reach the destination chain. This means the caller of the contract on the destination chain will be different depending on whether it’s called:
- Directly
- via IBC hooks after a PFM transfer from chain A to chain B
- via IBC-hooks directly after a transfer initiated on chain B
- … and so on…
This is problematic because many contracts have unpermissioned first operations (e.g. staking, depositing, LPing) that enable permissioned inverse operations (e.g. withdrawing, claiming rewards).
As a result, without thoughtful contract design, these sophisticated contracts with permissioned capabilities (e.g. vault contracts that support deposits + withdrawals, liquidity pools, borrowing/lending contracts, liquid staking contracts, etc…) become very hard to access cross-chain. Contracts that rely on sender for permissioning won’t be able to share permissions between contract invocations that started on different chains, leading to a highly fractured and painful user experience.
Revisiting the example: If Bob deposited his funds into Mars originally with a transfer sequence that initiated on Osmosis and hopped through the Hub, he won’t be able to withdraw his funds if he initiates the flow from Juno — or even locally from Neutron. He’ll walk away from the contract feeling frustrated and confused:
Contract + Module Design Solution: Authority Delegation Pattern
The best way to help users avoid unexpected authentication failures is to write contracts and modules that do not give permissions or tokens implicitly / exclusively to the contract caller / token sender. Instead, contracts and modules should allow callers to delegate authority or mint / send receipts to a separate address explicitly specified in the calldata.
Put more explicitly: Unpermissioned operations (e.g. depositing) that enable permissioned inverses (e.g. withdrawing) or directly unlock / mint / reward tokens or receipt tokens (e.g. swapping, LPing) should take in as a parameter in the calldata the address that should receive authority to perform the permissioned operation (in the case of permissioned inverse operations) or the tokens earned by the operation (in the case of receipt tokens)
Examples of this design pattern:
- Astroport’s
receiver
parameter in theprovide_liquidity
message - Mars’
on_behalf_of
parameter in thedeposit
message - Astroport’s
to
parameter in theswap
message
This improves accessibility because users can initiate their first interaction with a contract from any chain but can reliably use it again with their local address thereafter.
As a counterexample, MsgDelegate
in the staking module does not implement the delegation pattern. The authority to claim rewards + undelegate automatically gets attributed to the sender of the tokens. This means anyone implementing a cross-chain staking application needs to deploy entry point contracts that wrap the staking module and allow for specifying “recovery” addresses to claim rewards + undelegate through the contract.
This highlights an important point: It’s possible for someone to come along and implement a wrapper contract that handles authority delegation on behalf of basically any contract or module that doesn’t do so (including for the staking module). However, this isn’t always easy to do, since the burden of accounting can move to the wrapper contract — even when the upstream module/contract already implements its own accounting. In the staking example, the staking wrapper contract would need to track not only how much each user deposits, but also when they deposited + claimed rewards.
Multi-caller contracts extend cross-chain accessibility to modules
So now, users on remote chains can easily use smart contracts that implement the authority delegation pattern via ibc-hooks. But there’s still a problem: ibc-hooks can only call CosmWasm contracts. Native CosmosSDK modules and contracts in other VMs (e.g. Polaris, Ethermint, Move) are still inaccessible cross-chain.
Fortunately, the proxy contract standard (CW1) provides an easy solution here for chains that support CosmWasm. In short, because CosmWasm contracts have the ability to emit messages to SDK modules, we can send messages to CosmosSDK modules from IBC-hooks using a proxy contract as an intermediary.
The basic flow is:
- Deploy a generic CW1 contract that proxies an array of messages it receives to given target modules
- Have users call this contract in the ibc-hooks contract + use it to proxy messages to whatever CosmosSDK module they need to access, rather than calling the module directly.
Visually the call stack looks like this:
Skip has an example of one such contract that we deployed to Stargaze to execute arbitrary CosmWasm messages here.
Developers can also use more sophisticated versions of these proxy contracts to provide the authority delegation pattern on behalf of contracts + modules that don’t natively support it. For example, this would be required to support cross-chain staking, since the MsgDelegate
doesn’t implement the authority delegation pattern as of CosmosSDK v0.50. A proxy contract specialized to enable cross-chain staking would track who can claim rewards + unstake the tokens on behalf of each inbound transfer. (And it would need to implement logic to track/assign accumulated rewards based on when each transfer took place.)
Improving IBC-hooks to make non-CosmWasm + permissioned-CosmWasm chains accessible cross-chain
So via a proxy contract, users can directly call CosmosSDK modules from remote chains via IBC-hooks (at least for the subset of messages that implement the authority delegation pattern).
Now, the only chains that are inaccessible to remote users are those that:
- Don’t support CosmWasm
- Enable only limited/permissioned CosmWasm and might want to limit / avoid the use of generic proxies
For these chains, Skip is working on a core improvement to the IBC-hooks module that will enable the module to execute CosmosSDK messages directly, rather than interfacing with CosmWasm. This new version of ibc-hooks will make all messages + modules on these chains fully accessible to remote users.
We expect to release this new version of IBC-hooks in 4-6 weeks.
Summary
Smart contract and module developers must build their applications with the expectation that users will interact with them from different chains — and one individual user may interact with them from many chains.
In practice, this means not relying on message sender or contract caller implicitly for permissioning because this field is path-dependent. As a result, reliance on it produces fractured, frustrating user experiences.
But the solution is simple: Explicit permission delegation. Contracts that allow for the inclusion of an address in the calldata to explicitly delegate permissions to are easily accessible cross chain.
When the user performs the first (usually permissionless interaction), they can initiate their flow from anywhere and specify their local address as the delegate of any tokens or permissioned they earned. When they return to it, they can freely access their permissions + tokens from their address on the local chain.
Proxy contracts + the improvements to IBC-hooks that Skip is working on extends omnichain functionality to modules + non-CosmWasm chains.
About Skip
Skip helps developers provide extraordinary user experiences across all stages of the transaction lifecycle, from transaction construction, through cross-chain relaying + tracking, to block construction.
We build two main products:
- Skip API: A unified REST/RPC service and SDK that helps developers create more seamless cross-chain experiences for their end users with IBC. It’s carefully designed so that even developers who are completely new to IBC can use it to build applications and frontends that offer advanced cross chain functionality (e.g. universal IBC denom recommendations, cross-chain DEX aggregation, any-to-any swaps and transfers, multi-chain IBC lifecycle tracking, and much more). *IBC.FUN is powered by the Skip API and swaps & cross-chain transfers in all major Cosmos wallets are powered by the Skip API.
- Protocol-owned-builder (POB) : A CosmosSDK module that customizes your mempool to provide MEV recapture + protection, advanced fee markets, oracles, and more.
We work closely with the top wallets, protocols, defi aggregators, and chains in the Cosmos ecosystem (Osmosis, Berachain, Stride, Keplr, Leap, Noble, Strangelove, etc…)
Please get in touch if you’re interested in chatting about or using either of these products. Both are available now and free to use.