Skip to content

Commit

Permalink
cosmwasm: ibc contracts (#2591)
Browse files Browse the repository at this point in the history
* cosmwasm: add wormchain-ibc-receiver and wormhole-ibc contracts

* Address review comments from jynnantonix and hendrikhofstadt

* Fix lint errors and test failures

* Update naming to reflect new mapping of channelId -> chainId

* Return errors in ibc handlers that should never be called

* Remove contract name and version logic from migration handlers

* Add query handlers to wormhole-ibc contract

* Add wormchain channel id whitelisting to wormhole-ibc contract

* Increase packet timeout to 1 year

* Rebase on main, update imports to new names

* Add governance replay protection to both contracts

* wormhole_ibc SubmitUpdateChannelChain should only handle a single VAA

* better error messages

* Better logging and strip null characters from channel_id from governance VAA

* add brackets back for empty query methods

* Update Cargo.lock

* Only send wormhole wasm event attributes via IBC and add attribute whitelist on the receiver end

* tilt: fix terra2 deploy

* Update based on comments from jynnantonix

---------

Co-authored-by: Evan Gray <battledingo@gmail.com>
  • Loading branch information
nik-suri and evan-gray committed May 18, 2023
1 parent 5aa99a9 commit 892274f
Show file tree
Hide file tree
Showing 19 changed files with 1,428 additions and 256 deletions.
862 changes: 607 additions & 255 deletions cosmwasm/Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions cosmwasm/Cargo.toml
Expand Up @@ -10,6 +10,8 @@ members = [
"contracts/global-accountant",
"packages/wormhole-bindings",
"packages/cw_transcode",
"contracts/wormhole-ibc",
"contracts/wormchain-ibc-receiver"
]

# Needed to prevent unwanted feature unification between normal builds and dev builds. See
Expand Down Expand Up @@ -37,3 +39,5 @@ global-accountant = { path = "contracts/global-accountant" }
wormhole-bindings = { path = "packages/wormhole-bindings" }
wormhole-cosmwasm = { path = "contracts/wormhole" }
wormhole-sdk = { path = "../sdk/rust/core" }
wormchain-ibc-receiver = { path = "contracts/wormchain-ibc-receiver" }
wormhole-ibc = { path = "contracts/wormhole-ibc" }
5 changes: 5 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/.cargo/config
@@ -0,0 +1,5 @@
[alias]
wasm = "build --release --target wasm32-unknown-unknown"
wasm-debug = "build --target wasm32-unknown-unknown"
unit-test = "test --lib --features backtraces"
integration-test = "test --test integration"
22 changes: 22 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/Cargo.toml
@@ -0,0 +1,22 @@
[package]
name = "wormchain-ibc-receiver"
version = "0.1.0"
authors = ["Wormhole Project Contributors"]
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[features]
backtraces = ["cosmwasm-std/backtraces"]

[dependencies]
cosmwasm-std = { version = "1.0.0", features = ["ibc3"] }
cosmwasm-schema = "1"
cw-storage-plus = "0.13.2"
anyhow = "1"
semver = "1.0.16"
thiserror = "1.0.31"
wormhole-bindings = "0.1.0"
wormhole-sdk = { version = "0.1.0", features = ["schemars"] }
serde_wormhole = "0.1.0"
157 changes: 157 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/src/contract.rs
@@ -0,0 +1,157 @@
use crate::error::ContractError;
use crate::msg::{AllChannelChainsResponse, ChannelChainResponse, ExecuteMsg, QueryMsg};
use crate::state::{CHANNEL_CHAIN, VAA_ARCHIVE};
use anyhow::{bail, ensure, Context};
use cosmwasm_std::{entry_point, to_binary, Binary, Deps, Empty, Event, StdResult};
use cosmwasm_std::{DepsMut, Env, MessageInfo, Order, Response};
use serde_wormhole::RawMessage;
use std::str;
use wormhole_bindings::WormholeQuery;
use wormhole_sdk::ibc_receiver::{Action, GovernancePacket};
use wormhole_sdk::vaa::{Body, Header};
use wormhole_sdk::Chain;

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
_deps: DepsMut,
_env: Env,
info: MessageInfo,
_msg: Empty,
) -> Result<Response, anyhow::Error> {
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("owner", info.sender))
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(_deps: DepsMut, _env: Env, _msg: Empty) -> Result<Response, anyhow::Error> {
Ok(Response::default())
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut<WormholeQuery>,
_env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, anyhow::Error> {
match msg {
ExecuteMsg::SubmitUpdateChannelChain { vaas } => submit_vaas(deps, info, vaas),
}
}

fn submit_vaas(
mut deps: DepsMut<WormholeQuery>,
info: MessageInfo,
vaas: Vec<Binary>,
) -> Result<Response, anyhow::Error> {
let evts = vaas
.into_iter()
.map(|v| handle_vaa(deps.branch(), v))
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Response::new()
.add_attribute("action", "submit_vaas")
.add_attribute("owner", info.sender)
.add_events(evts))
}

fn handle_vaa(deps: DepsMut<WormholeQuery>, vaa: Binary) -> anyhow::Result<Event> {
// parse the VAA header and data
let (header, data) = serde_wormhole::from_slice::<(Header, &RawMessage)>(&vaa)
.context("failed to parse VAA header")?;

// Must be a version 1 VAA
ensure!(header.version == 1, "unsupported VAA version");

// call into wormchain to verify the VAA
deps.querier
.query::<Empty>(&WormholeQuery::VerifyVaa { vaa: vaa.clone() }.into())
.context(ContractError::VerifyQuorum)?;

// parse the VAA body
let body = serde_wormhole::from_slice::<Body<&RawMessage>>(data)
.context("failed to parse VAA body")?;

// validate this is a governance VAA
ensure!(
body.emitter_chain == Chain::Solana
&& body.emitter_address == wormhole_sdk::GOVERNANCE_EMITTER,
"not a governance VAA"
);

// parse the governance packet
let govpacket: GovernancePacket =
serde_wormhole::from_slice(body.payload).context("failed to parse governance packet")?;

// validate the governance VAA is directed to wormchain
ensure!(
govpacket.chain == Chain::Wormchain,
"this governance VAA is for another chain"
);

// governance VAA replay protection
let digest = body
.digest()
.context("failed to compute governance VAA digest")?;

if VAA_ARCHIVE.has(deps.storage, &digest.hash) {
bail!("governance vaa already executed");
}
VAA_ARCHIVE
.save(deps.storage, &digest.hash, &true)
.context("failed to save governance VAA to archive")?;

// match the governance action and execute the corresponding logic
match govpacket.action {
Action::UpdateChannelChain {
channel_id,
chain_id,
} => {
ensure!(chain_id != Chain::Wormchain, "the wormchain-ibc-receiver contract should not maintain channel mappings to wormchain");

let channel_id_str =
str::from_utf8(&channel_id).context("failed to parse channel-id as utf-8")?;
let channel_id_trimmed = channel_id_str.trim_start_matches(char::from(0));

// update storage with the mapping
CHANNEL_CHAIN
.save(
deps.storage,
channel_id_trimmed.to_string(),
&chain_id.into(),
)
.context("failed to save channel chain")?;
Ok(Event::new("UpdateChannelChain")
.add_attribute("chain_id", chain_id.to_string())
.add_attribute("channel_id", channel_id_trimmed))
}
}
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::ChannelChain { channel_id } => {
query_channel_chain(deps, channel_id).and_then(|resp| to_binary(&resp))
}
QueryMsg::AllChannelChains {} => {
query_all_channel_chains(deps).and_then(|resp| to_binary(&resp))
}
}
}

fn query_channel_chain(deps: Deps, channel_id: Binary) -> StdResult<ChannelChainResponse> {
CHANNEL_CHAIN
.load(deps.storage, channel_id.to_string())
.map(|chain_id| ChannelChainResponse { chain_id })
}

fn query_all_channel_chains(deps: Deps) -> StdResult<AllChannelChainsResponse> {
CHANNEL_CHAIN
.range(deps.storage, None, None, Order::Ascending)
.map(|res| {
res.map(|(channel_id, chain_id)| (Binary::from(Vec::<u8>::from(channel_id)), chain_id))
})
.collect::<StdResult<Vec<_>>>()
.map(|channels_chains| AllChannelChainsResponse { channels_chains })
}
7 changes: 7 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/src/error.rs
@@ -0,0 +1,7 @@
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ContractError {
#[error("failed to verify quorum")]
VerifyQuorum,
}
180 changes: 180 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/src/ibc.rs
@@ -0,0 +1,180 @@
use anyhow::{bail, ensure};
use cosmwasm_std::{
entry_point, from_slice, to_binary, Attribute, Binary, ContractResult, DepsMut, Env,
Ibc3ChannelOpenResponse, IbcBasicResponse, IbcChannelCloseMsg, IbcChannelConnectMsg,
IbcChannelOpenMsg, IbcChannelOpenResponse, IbcPacketAckMsg, IbcPacketReceiveMsg,
IbcPacketTimeoutMsg, IbcReceiveResponse, StdError, StdResult,
};

use crate::msg::WormholeIbcPacketMsg;

// Implementation of IBC protocol
// Implements 6 entry points that are required for the x/wasm runtime to bind a port for this contract
// https://github.com/CosmWasm/cosmwasm/blob/main/IBC.md#writing-new-protocols

pub const IBC_APP_VERSION: &str = "ibc-wormhole-v1";

/// 1. Opening a channel. Step 1 of handshake. Combines ChanOpenInit and ChanOpenTry from the spec.
/// The only valid action of the contract is to accept the channel or reject it.
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_channel_open(
_deps: DepsMut,
_env: Env,
msg: IbcChannelOpenMsg,
) -> StdResult<IbcChannelOpenResponse> {
let channel = msg.channel();

if channel.version.as_str() != IBC_APP_VERSION {
return Err(StdError::generic_err(format!(
"Must set version to `{}`",
IBC_APP_VERSION
)));
}

if let Some(counter_version) = msg.counterparty_version() {
if counter_version != IBC_APP_VERSION {
return Err(StdError::generic_err(format!(
"Counterparty version must be `{}`",
IBC_APP_VERSION
)));
}
}

// We return the version we need (which could be different than the counterparty version)
Ok(Some(Ibc3ChannelOpenResponse {
version: IBC_APP_VERSION.to_string(),
}))
}

/// 2. Step 2 of handshake. Combines ChanOpenAck and ChanOpenConfirm from the spec.
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_channel_connect(
_deps: DepsMut,
_env: Env,
msg: IbcChannelConnectMsg,
) -> StdResult<IbcBasicResponse> {
let channel = msg.channel();
let connection_id = &channel.connection_id;

Ok(IbcBasicResponse::new()
.add_attribute("action", "ibc_connect")
.add_attribute("connection_id", connection_id))
}

/// 3. Closing a channel - whether due to an IBC error, at our request, or at the request of the other side.
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_channel_close(
_deps: DepsMut,
_env: Env,
_msg: IbcChannelCloseMsg,
) -> StdResult<IbcBasicResponse> {
Err(StdError::generic_err("user cannot close channel"))
}

/// 4. Receiving a packet.
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_packet_receive(
_deps: DepsMut,
_env: Env,
msg: IbcPacketReceiveMsg,
) -> StdResult<IbcReceiveResponse> {
handle_packet_receive(msg).or_else(|e| {
// we try to capture all app-level errors and convert them into
// acknowledgement packets that contain an error code.
let acknowledgement = encode_ibc_error(format!("invalid packet: {}", e));
Ok(IbcReceiveResponse::new()
.set_ack(acknowledgement)
.add_attribute("action", "ibc_packet_ack"))
})
}

/// Decode the IBC packet as WormholeIbcPacketMsg::Publish and take appropriate action
fn handle_packet_receive(msg: IbcPacketReceiveMsg) -> Result<IbcReceiveResponse, anyhow::Error> {
let packet = msg.packet;
// which local channel did this packet come on
let channel_id = packet.dest.channel_id;
let wormhole_msg: WormholeIbcPacketMsg = from_slice(&packet.data)?;
match wormhole_msg {
WormholeIbcPacketMsg::Publish { msg: publish_attrs } => {
receive_publish(channel_id, publish_attrs)
}
}
}

const EXPECTED_WORMHOLE_IBC_EVENT_ATTRS: [&str; 8] = [
"message.message",
"message.sender",
"message.chain_id",
"message.nonce",
"message.sequence",
"message.block_time",
"message.tx_index",
"message.block_height",
];

fn receive_publish(
channel_id: String,
publish_attrs: Vec<Attribute>,
) -> Result<IbcReceiveResponse, anyhow::Error> {
// check the attributes are what we expect from wormhole
ensure!(
publish_attrs.len() == EXPECTED_WORMHOLE_IBC_EVENT_ATTRS.len(),
"number of received attributes does not match number of expected"
);

for key in EXPECTED_WORMHOLE_IBC_EVENT_ATTRS {
let mut matched = false;
for attr in &publish_attrs {
if key == attr.key {
matched = true;
break;
}
}
if !matched {
bail!(
"expected attribute unmmatched in received attributes: {}",
key
);
}
}

// send the ack and emit the message with the attributes from the wormhole message
let acknowledgement = to_binary(&ContractResult::<()>::Ok(()))?;
Ok(IbcReceiveResponse::new()
.set_ack(acknowledgement)
.add_attribute("action", "receive_publish")
.add_attribute("channel_id", channel_id)
.add_attributes(publish_attrs))
}

// this encode an error or error message into a proper acknowledgement to the recevier
fn encode_ibc_error(msg: impl Into<String>) -> Binary {
// this cannot error, unwrap to keep the interface simple
to_binary(&ContractResult::<()>::Err(msg.into())).unwrap()
}

/// 5. Acknowledging a packet. Called when the other chain successfully receives a packet from us.
/// Never should be called as this contract never sends packets
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_packet_ack(
_deps: DepsMut,
_env: Env,
_msg: IbcPacketAckMsg,
) -> StdResult<IbcBasicResponse> {
Err(StdError::generic_err(
"ack should never be called as this contract never sends packets",
))
}

/// 6. Timing out a packet. Called when the packet was not recieved on the other chain before the timeout.
/// Never should be called as this contract never sends packets
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_packet_timeout(
_deps: DepsMut,
_env: Env,
_msg: IbcPacketTimeoutMsg,
) -> StdResult<IbcBasicResponse> {
Err(StdError::generic_err(
"timeout should never be called as this contract never sends packets",
))
}
5 changes: 5 additions & 0 deletions cosmwasm/contracts/wormchain-ibc-receiver/src/lib.rs
@@ -0,0 +1,5 @@
pub mod contract;
pub mod error;
pub mod ibc;
pub mod msg;
pub mod state;

0 comments on commit 892274f

Please sign in to comment.