Skip to main content

qorechain/
cross_vm.rs

1//! High-level unified cross-VM call helper for QoreChain's `x/crossvm` module.
2//!
3//! QoreChain runs multiple VMs (EVM, CosmWasm, SVM) over one state machine. The
4//! `x/crossvm` module routes a single `MsgCrossVMCall` from a source VM to a
5//! target VM — e.g. an EVM dApp triggering a CosmWasm contract, or an SVM
6//! program calling into the EVM. This helper mirrors the canonical TypeScript
7//! SDK's `crossVm` surface: it builds, signs, and broadcasts one (or many)
8//! `MsgCrossVMCall` without the caller hand-assembling protobuf or remembering
9//! the type URL.
10//!
11//! Construct a [`CrossVm`] with the signer's key material and account context
12//! (chain id, account number, sequence), then:
13//!
14//! - [`CrossVm::call`] — build + sign + broadcast one cross-VM call.
15//! - [`CrossVm::build_call`] — build + sign only (returns the
16//!   [`BuiltTx`](crate::tx::BuiltTx) for inspection or deferred broadcast).
17//! - [`CrossVm::call_atomic`] — pack N calls into **one** tx so they settle
18//!   atomically (all-or-nothing in a single block).
19//! - [`CrossVm::get_message`] — read a routed message's status via
20//!   `qor_getCrossVMMessage`.
21//!
22//! ## Payload
23//!
24//! [`Payload`] is either:
25//! - [`Payload::Raw`] — opaque bytes, used as-is. This is the form for EVM
26//!   targets: the SDK does not ABI-encode high-level EVM calls in Rust (that is
27//!   TypeScript-only); pass already-encoded calldata.
28//! - [`Payload::CosmWasm`] — a `serde_json::Value`, serialized to its compact
29//!   UTF-8 JSON bytes (the execute-msg form a CosmWasm contract expects).
30//!
31//! ## VM types
32//!
33//! [`CallOptions::source_vm`] defaults to [`VM_TYPE_EVM`]. The accepted target
34//! VM strings are [`VM_TYPE_EVM`], [`VM_TYPE_COSMWASM`], and [`VM_TYPE_SVM`].
35
36use crate::error::{Error, Result};
37use crate::msg::crossvm::cross_vm_call_any;
38use crate::query::QorClient;
39use crate::tx::{
40    broadcast, send_messages, BroadcastMode, BuiltTx, Coin as TxCoin, Fee, SendMessagesParams,
41};
42use cosmrs::proto::cosmos::base::v1beta1::Coin as ProtoCoin;
43use serde_json::Value;
44
45/// The EVM VM-type string.
46pub const VM_TYPE_EVM: &str = "evm";
47/// The CosmWasm VM-type string.
48pub const VM_TYPE_COSMWASM: &str = "cosmwasm";
49/// The SVM (Solana VM) VM-type string.
50pub const VM_TYPE_SVM: &str = "svm";
51
52/// The set of valid VM-type strings.
53pub const VM_TYPES: &[&str] = &[VM_TYPE_EVM, VM_TYPE_COSMWASM, VM_TYPE_SVM];
54
55/// The payload of a cross-VM call.
56#[derive(Debug, Clone)]
57pub enum Payload {
58    /// Opaque bytes, used as-is (the form for EVM targets: pass ABI-encoded
59    /// calldata).
60    Raw(Vec<u8>),
61    /// A JSON value serialized to compact UTF-8 bytes (the CosmWasm execute-msg
62    /// form).
63    CosmWasm(Value),
64}
65
66impl Payload {
67    /// Resolves the payload to its on-wire bytes.
68    pub fn to_bytes(&self) -> Result<Vec<u8>> {
69        match self {
70            Payload::Raw(b) => Ok(b.clone()),
71            Payload::CosmWasm(v) => serde_json::to_vec(v)
72                .map_err(|e| Error::InvalidResponse(format!("serialize cosmwasm payload: {e}"))),
73        }
74    }
75}
76
77impl From<Vec<u8>> for Payload {
78    fn from(b: Vec<u8>) -> Self {
79        Payload::Raw(b)
80    }
81}
82
83impl From<Value> for Payload {
84    fn from(v: Value) -> Self {
85        Payload::CosmWasm(v)
86    }
87}
88
89/// Options for a single cross-VM call.
90#[derive(Debug, Clone)]
91pub struct CallOptions {
92    /// Source VM type. Defaults to [`VM_TYPE_EVM`] when empty.
93    pub source_vm: String,
94    /// Target VM type (e.g. [`VM_TYPE_COSMWASM`]).
95    pub target_vm: String,
96    /// The target contract address/identifier in the target VM's address space.
97    pub target_contract: String,
98    /// The call payload (raw bytes or a CosmWasm JSON value).
99    pub payload: Payload,
100    /// Funds to send with the call.
101    pub funds: Vec<TxCoin>,
102}
103
104impl CallOptions {
105    /// Creates options with the given target VM, contract, and payload, defaulting
106    /// `source_vm` to [`VM_TYPE_EVM`] and no funds.
107    pub fn new(
108        target_vm: impl Into<String>,
109        target_contract: impl Into<String>,
110        payload: impl Into<Payload>,
111    ) -> Self {
112        Self {
113            source_vm: VM_TYPE_EVM.to_string(),
114            target_vm: target_vm.into(),
115            target_contract: target_contract.into(),
116            payload: payload.into(),
117            funds: Vec::new(),
118        }
119    }
120
121    /// Overrides the source VM.
122    pub fn source_vm(mut self, vm: impl Into<String>) -> Self {
123        self.source_vm = vm.into();
124        self
125    }
126
127    /// Sets the funds carried with the call.
128    pub fn funds(mut self, funds: Vec<TxCoin>) -> Self {
129        self.funds = funds;
130        self
131    }
132}
133
134/// The signer/account context plus broadcast target for cross-VM calls.
135///
136/// Mirrors [`SendMessagesParams`] minus the messages: it carries the secp256k1
137/// key pair, the chain id, the on-chain account number / sequence, the fee, and
138/// the REST URL used to broadcast. An optional [`QorClient`] enables
139/// [`CrossVm::get_message`].
140#[derive(Debug, Clone)]
141pub struct CrossVm {
142    /// The signer's bech32 sender address.
143    pub sender: String,
144    /// The signer's 32-byte secp256k1 private key.
145    pub private_key: Vec<u8>,
146    /// The signer's 33-byte compressed secp256k1 public key.
147    pub public_key: Vec<u8>,
148    /// The chain id (e.g. `"qorechain-diana"`).
149    pub chain_id: String,
150    /// The signer's on-chain account number.
151    pub account_number: u64,
152    /// The signer's current account sequence (nonce).
153    pub sequence: u64,
154    /// The fee to pay.
155    pub fee: Fee,
156    /// The REST (LCD) URL used to broadcast (`/cosmos/tx/v1beta1/txs`).
157    pub rest_url: String,
158    /// Broadcast mode. Defaults to [`BroadcastMode::Sync`].
159    pub mode: BroadcastMode,
160    /// Optional `qor_*` client for [`CrossVm::get_message`].
161    pub qor: Option<QorClient>,
162}
163
164impl CrossVm {
165    /// Builds + signs a single `MsgCrossVMCall` into a broadcast-ready [`BuiltTx`]
166    /// (does not broadcast).
167    pub fn build_call(&self, opts: &CallOptions) -> Result<BuiltTx> {
168        self.build_atomic(std::slice::from_ref(opts))
169    }
170
171    /// Builds + signs a single `MsgCrossVMCall` and broadcasts it, returning the
172    /// REST broadcast response JSON.
173    pub async fn call(&self, opts: &CallOptions) -> Result<Value> {
174        let built = self.build_call(opts)?;
175        broadcast(&self.rest_url, &built.tx_raw_bytes, self.mode).await
176    }
177
178    /// Builds + signs **one** tx containing N `MsgCrossVMCall` messages (atomic)
179    /// and broadcasts it, returning the REST broadcast response JSON.
180    pub async fn call_atomic(&self, opts: &[CallOptions]) -> Result<Value> {
181        let built = self.build_atomic(opts)?;
182        broadcast(&self.rest_url, &built.tx_raw_bytes, self.mode).await
183    }
184
185    /// Builds + signs one tx containing N `MsgCrossVMCall` messages (does not
186    /// broadcast). Returns an error if `opts` is empty.
187    pub fn build_atomic(&self, opts: &[CallOptions]) -> Result<BuiltTx> {
188        if opts.is_empty() {
189            return Err(Error::InvalidResponse(
190                "cross_vm: at least one call is required".into(),
191            ));
192        }
193        let mut messages = Vec::with_capacity(opts.len());
194        for o in opts {
195            let source_vm = if o.source_vm.is_empty() {
196                VM_TYPE_EVM
197            } else {
198                o.source_vm.as_str()
199            };
200            let payload = o.payload.to_bytes()?;
201            messages.push(cross_vm_call_any(
202                self.sender.clone(),
203                source_vm,
204                o.target_vm.clone(),
205                o.target_contract.clone(),
206                payload,
207                to_proto_coins(&o.funds),
208            ));
209        }
210
211        send_messages(SendMessagesParams {
212            private_key: self.private_key.clone(),
213            public_key: self.public_key.clone(),
214            messages,
215            chain_id: self.chain_id.clone(),
216            account_number: self.account_number,
217            sequence: self.sequence,
218            fee: self.fee.clone(),
219            memo: String::new(),
220            timeout_height: 0,
221        })
222    }
223
224    /// Reads a routed cross-VM message's status by id via `qor_getCrossVMMessage`.
225    ///
226    /// Returns an error if no [`QorClient`] was configured.
227    pub async fn get_message(&self, id: &str) -> Result<Value> {
228        let qor = self.qor.as_ref().ok_or_else(|| {
229            Error::MissingEndpoint("qor (cross_vm.get_message requires a QorClient)".into())
230        })?;
231        qor.get_cross_vm_message(id).await
232    }
233}
234
235fn to_proto_coins(coins: &[TxCoin]) -> Vec<ProtoCoin> {
236    coins
237        .iter()
238        .map(|c| ProtoCoin {
239            denom: c.denom.clone(),
240            amount: c.amount.clone(),
241        })
242        .collect()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    #[test]
251    fn payload_raw_passthrough() {
252        let p = Payload::Raw(vec![1, 2, 3]);
253        assert_eq!(p.to_bytes().unwrap(), vec![1, 2, 3]);
254    }
255
256    #[test]
257    fn payload_cosmwasm_is_compact_json() {
258        let p = Payload::CosmWasm(json!({ "increment": {} }));
259        assert_eq!(p.to_bytes().unwrap(), br#"{"increment":{}}"#.to_vec());
260    }
261
262    #[test]
263    fn call_options_defaults_source_to_evm() {
264        let o = CallOptions::new(VM_TYPE_COSMWASM, "qor1contract", vec![0u8]);
265        assert_eq!(o.source_vm, VM_TYPE_EVM);
266        assert_eq!(o.target_vm, "cosmwasm");
267    }
268
269    #[test]
270    fn vm_type_constants() {
271        assert_eq!(VM_TYPES, &["evm", "cosmwasm", "svm"]);
272    }
273}