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}