Skip to content

Commit b036009

Browse files
committed
Detect cross-chain swaps using Transaction + tx data
Refactor cross-chain swap detection to accept a primitives::Transaction instead of separate chain/to/memo parameters, and propagate transaction data throughout the stack. Key changes: - Change CrossChainProvider::is_swap to take &Transaction and update swap_provider/is_cross_chain_swap accordingly. - Update Across and Thorchain providers to detect swaps from the full Transaction (Across checks transaction.to, Thorchain checks memo or hex-decoded tx.data and router addresses). - Add Transaction.data field + with_data builder in primitives::Transaction and wire EVM mapper to store input data for native transfers. - Add primitives::hex::decode_hex_utf8 helper and use it for XRP memo decoding and Thorchain provider data decoding. - Move/add THORChain value conversion helpers (value_from/value_to) into thorchain asset code and update Thorchain provider to use them with proper error handling. - Add native_value/decimals handling to Thorchain TransactionCoin and use it when mapping swap results. - Update consumers/workers/storage to pass primitives::Transaction into cross-chain checks and ensure data is preserved where applicable. - Update/adapt unit tests for the new transaction-based detection and added data-based Thorchain detection. These changes unify swap detection on a single Transaction model, enable detection from EVM input data, and centralize THORChain asset value conversions.
1 parent 3e492a6 commit b036009

File tree

14 files changed

+209
-136
lines changed

14 files changed

+209
-136
lines changed

apps/daemon/src/consumers/store/store_transactions_consumer.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ const TRANSACTION_BATCH_SIZE: usize = 100;
1515
fn set_cross_chain_in_transit(transactions: Vec<Transaction>) -> Vec<Transaction> {
1616
transactions
1717
.into_iter()
18-
.map(|mut tx| {
19-
if tx.state == TransactionState::Confirmed && tx.transaction_type != TransactionType::Swap && cross_chain::is_cross_chain_swap(&tx.id.chain, &tx.to, tx.memo.as_deref())
18+
.map(|mut transaction| {
19+
if transaction.state == TransactionState::Confirmed
20+
&& transaction.transaction_type != TransactionType::Swap
21+
&& cross_chain::is_cross_chain_swap(&transaction)
2022
{
21-
tx.state = TransactionState::InTransit;
23+
transaction.state = TransactionState::InTransit;
2224
}
23-
tx
25+
transaction
2426
})
2527
.collect()
2628
}
@@ -113,7 +115,9 @@ impl MessageConsumer<TransactionsPayload, usize> for StoreTransactionsConsumer {
113115
txn_ids.insert(transaction.id.clone());
114116
asset_ids.extend(transaction_asset_ids.iter().cloned());
115117

116-
let is_outdated = self.config.is_transaction_outdated(transaction.created_at.naive_utc(), chain, transaction.transaction_type.clone());
118+
let is_outdated = self
119+
.config
120+
.is_transaction_outdated(transaction.created_at.naive_utc(), chain, transaction.transaction_type.clone());
117121
let should_notify = !is_outdated && is_notify_devices;
118122

119123
if should_notify {

apps/daemon/src/worker/transactions/in_transit_updater.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ impl InTransitUpdater {
5959

6060
async fn process_transaction(&self, row: &TransactionRow, cutoff: NaiveDateTime) -> Result<bool, Box<dyn Error + Send + Sync>> {
6161
let chain = row.chain();
62+
let transaction = row.as_primitive(row.get_addresses());
6263

63-
let Some(provider) = cross_chain::swap_provider(&chain, row.to_address.as_deref(), row.memo.as_deref()) else {
64+
let Some(provider) = cross_chain::swap_provider(&transaction) else {
6465
info_with_fields!("in_transit unknown provider", chain = chain.as_ref(), hash = row.hash);
6566
return Ok(false);
6667
};

crates/gem_evm/src/rpc/mapper.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ impl EthereumMapper {
7474
&& Self::get_data_cost(&transaction.input).is_some_and(|data_cost| transaction_reciept.gas_used <= BigUint::from(TRANSFER_GAS_LIMIT + data_cost));
7575

7676
if is_native_transfer || is_native_transfer_with_data {
77+
let data = if is_native_transfer_with_data { Some(transaction.input.clone()) } else { None };
7778
let transaction = primitives::Transaction::new(
7879
hash,
7980
chain.as_asset_id(),
@@ -88,7 +89,8 @@ impl EthereumMapper {
8889
None,
8990
None,
9091
created_at,
91-
);
92+
)
93+
.with_data(data);
9294
return Some(transaction);
9395
}
9496

crates/gem_xrp/src/models/rpc.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,7 @@ pub struct TransactionMemoData {
163163

164164
impl TransactionMemo {
165165
pub fn decoded_data(&self) -> Option<String> {
166-
let data = self.memo.data.as_ref()?;
167-
let bytes = hex::decode(data).ok()?;
168-
String::from_utf8(bytes).ok()
166+
primitives::hex::decode_hex_utf8(self.memo.data.as_ref()?)
169167
}
170168
}
171169

crates/primitives/src/hex.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ pub fn encode_with_0x(data: &[u8]) -> String {
2222
format!("0x{}", hex::encode(data))
2323
}
2424

25+
pub fn decode_hex_utf8(value: &str) -> Option<String> {
26+
let bytes = decode_hex(value).ok()?;
27+
String::from_utf8(bytes).ok()
28+
}
29+
2530
pub fn decode_hex(value: &str) -> Result<Vec<u8>, HexError> {
2631
let stripped = value.trim().strip_prefix("0x").unwrap_or(value.trim());
2732
if stripped.is_empty() {

crates/primitives/src/transaction.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ pub struct Transaction {
5656
pub utxo_outputs: Option<Vec<TransactionUtxoInput>>,
5757
#[serde(skip_serializing_if = "Option::is_none")]
5858
pub metadata: Option<serde_json::Value>,
59+
#[typeshare(skip)]
60+
#[serde(skip_serializing_if = "Option::is_none")]
61+
pub data: Option<String>,
5962
#[serde(rename = "createdAt")]
6063
pub created_at: DateTime<Utc>,
6164
}
@@ -95,6 +98,7 @@ impl Transaction {
9598
utxo_inputs: vec![].into(),
9699
utxo_outputs: vec![].into(),
97100
metadata,
101+
data: None,
98102
created_at,
99103
}
100104
}
@@ -132,6 +136,7 @@ impl Transaction {
132136
utxo_inputs: utxo_inputs.unwrap_or_default().into(),
133137
utxo_outputs: utxo_outputs.unwrap_or_default().into(),
134138
metadata,
139+
data: None,
135140
created_at,
136141
}
137142
}
@@ -236,6 +241,7 @@ impl Transaction {
236241
utxo_inputs: self.utxo_inputs.clone(),
237242
utxo_outputs: self.utxo_outputs.clone(),
238243
metadata: self.metadata.clone(),
244+
data: self.data.clone(),
239245
created_at: self.created_at,
240246
}
241247
}
@@ -326,6 +332,11 @@ impl Transaction {
326332
..self
327333
}
328334
}
335+
336+
pub fn with_data(mut self, data: Option<String>) -> Self {
337+
self.data = data;
338+
self
339+
}
329340
}
330341

331342
#[cfg(test)]

crates/storage/src/models/transaction.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ impl TransactionRow {
9494
utxo_inputs: inputs.unwrap_or_default().into(),
9595
utxo_outputs: outputs.unwrap_or_default().into(),
9696
metadata: self.metadata.clone(),
97+
data: None,
9798
created_at: self.created_at.and_utc(),
9899
}
99100
}

crates/swapper/src/across/provider.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ impl crate::cross_chain::CrossChainProvider for AcrossCrossChain {
4848
SwapperProvider::Across
4949
}
5050

51-
fn is_swap(&self, chain: &Chain, to_address: Option<&str>, _memo: Option<&str>) -> bool {
52-
let Some(to_address) = to_address else { return false };
53-
AcrossDeployment::deployment_by_chain(chain).is_some_and(|d| d.spoke_pool.eq_ignore_ascii_case(to_address))
51+
fn is_swap(&self, transaction: &primitives::Transaction) -> bool {
52+
if transaction.to.is_empty() {
53+
return false;
54+
}
55+
AcrossDeployment::deployment_by_chain(&transaction.asset_id.chain).is_some_and(|d| d.spoke_pool.eq_ignore_ascii_case(&transaction.to))
5456
}
5557
}
5658

crates/swapper/src/cross_chain.rs

Lines changed: 81 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,143 @@
1+
use primitives::Transaction;
2+
13
use crate::SwapperProvider;
24
use crate::across::AcrossCrossChain;
35
use crate::thorchain::ThorchainCrossChain;
4-
use primitives::Chain;
56

67
pub trait CrossChainProvider: Send + Sync {
78
fn provider(&self) -> SwapperProvider;
8-
fn is_swap(&self, chain: &Chain, to_address: Option<&str>, memo: Option<&str>) -> bool;
9+
fn is_swap(&self, transaction: &Transaction) -> bool;
910
}
1011

1112
const PROVIDERS: [&dyn CrossChainProvider; 2] = [&ThorchainCrossChain, &AcrossCrossChain];
1213

13-
pub fn swap_provider(chain: &Chain, to_address: Option<&str>, memo: Option<&str>) -> Option<SwapperProvider> {
14-
PROVIDERS.iter().find(|p| p.is_swap(chain, to_address, memo)).map(|p| p.provider())
14+
pub fn swap_provider(transaction: &Transaction) -> Option<SwapperProvider> {
15+
PROVIDERS.iter().find(|p| p.is_swap(transaction)).map(|p| p.provider())
1516
}
1617

17-
pub fn is_cross_chain_swap(chain: &Chain, to_address: &str, memo: Option<&str>) -> bool {
18-
swap_provider(chain, Some(to_address), memo).is_some()
18+
pub fn is_cross_chain_swap(transaction: &Transaction) -> bool {
19+
swap_provider(transaction).is_some()
1920
}
2021

2122
#[cfg(test)]
2223
mod tests {
2324
use super::*;
25+
use primitives::Chain;
2426

2527
#[test]
2628
fn test_thorchain_swap_detected() {
27-
let memo = "=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0";
28-
assert_eq!(
29-
swap_provider(&Chain::Ethereum, Some("0x0000000000000000000000000000000000000000"), Some(memo)),
30-
Some(SwapperProvider::Thorchain),
31-
);
29+
let transaction = Transaction {
30+
memo: Some("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0".to_string()),
31+
..Transaction::mock()
32+
};
33+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Thorchain));
3234
}
3335

3436
#[test]
3537
fn test_thorchain_non_swap_memo() {
36-
assert!(!is_cross_chain_swap(
37-
&Chain::Ethereum,
38-
"0x0000000000000000000000000000000000000000",
39-
Some("ADD:ETH.ETH:0x123"),
40-
));
38+
assert!(!is_cross_chain_swap(&Transaction {
39+
memo: Some("ADD:ETH.ETH:0x123".to_string()),
40+
..Transaction::mock()
41+
}));
4142
}
4243

4344
#[test]
4445
fn test_no_memo() {
45-
assert!(swap_provider(&Chain::Ethereum, Some("0x0000000000000000000000000000000000000000"), None).is_none());
46+
assert!(swap_provider(&Transaction::mock()).is_none());
4647
}
4748

4849
#[test]
4950
fn test_across_swap_detected() {
50-
assert_eq!(
51-
swap_provider(&Chain::Ethereum, Some("0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5"), None),
52-
Some(SwapperProvider::Across),
53-
);
51+
let transaction = Transaction {
52+
to: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5".to_string(),
53+
..Transaction::mock()
54+
};
55+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Across));
5456
}
5557

5658
#[test]
5759
fn test_across_swap_case_insensitive() {
58-
assert!(is_cross_chain_swap(&Chain::Ethereum, "0x5c7bcd6e7de5423a257d81b442095a1a6ced35c5", None));
60+
let transaction = Transaction {
61+
to: "0x5c7bcd6e7de5423a257d81b442095a1a6ced35c5".to_string(),
62+
..Transaction::mock()
63+
};
64+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Across));
5965
}
6066

6167
#[test]
6268
fn test_across_unsupported_chain() {
63-
assert!(!is_cross_chain_swap(&Chain::Fantom, "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", None));
69+
let transaction = Transaction {
70+
asset_id: Chain::Fantom.as_asset_id(),
71+
to: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5".to_string(),
72+
..Transaction::mock()
73+
};
74+
assert!(swap_provider(&transaction).is_none());
6475
}
6576

6677
#[test]
6778
fn test_across_arbitrum() {
68-
assert_eq!(
69-
swap_provider(&Chain::Arbitrum, Some("0xe35e9842fceaca96570b734083f4a58e8f7c5f2a"), None),
70-
Some(SwapperProvider::Across),
71-
);
79+
let transaction = Transaction {
80+
asset_id: Chain::Arbitrum.as_asset_id(),
81+
to: "0xe35e9842fceaca96570b734083f4a58e8f7c5f2a".to_string(),
82+
..Transaction::mock()
83+
};
84+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Across));
7285
}
7386

7487
#[test]
7588
fn test_thorchain_takes_priority_over_across() {
76-
let memo = "=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0";
77-
assert_eq!(
78-
swap_provider(&Chain::Ethereum, Some("0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5"), Some(memo)),
79-
Some(SwapperProvider::Thorchain),
80-
);
89+
let transaction = Transaction {
90+
to: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5".to_string(),
91+
memo: Some("=:ETH.USDT:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:1635978e6/1/0".to_string()),
92+
..Transaction::mock()
93+
};
94+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Thorchain));
8195
}
8296

8397
#[test]
8498
fn test_non_evm_chains_no_panic() {
85-
assert!(!is_cross_chain_swap(&Chain::Bitcoin, "bc1qaddress", None));
86-
assert!(!is_cross_chain_swap(&Chain::Solana, "So1111111111111111111111111111111111111111", None));
87-
assert!(!is_cross_chain_swap(&Chain::Ton, "EQAddress", None));
88-
assert!(!is_cross_chain_swap(&Chain::Cosmos, "cosmos1address", None));
89-
assert!(!is_cross_chain_swap(&Chain::Sui, "0xaddress", None));
99+
let btc = Transaction {
100+
asset_id: Chain::Bitcoin.as_asset_id(),
101+
to: "bc1qaddress".to_string(),
102+
..Transaction::mock()
103+
};
104+
let sol = Transaction {
105+
asset_id: Chain::Solana.as_asset_id(),
106+
to: "So1111111111111111111111111111111111111111".to_string(),
107+
..Transaction::mock()
108+
};
109+
assert!(swap_provider(&btc).is_none());
110+
assert!(swap_provider(&sol).is_none());
90111
}
91112

92113
#[test]
93114
fn test_thorchain_swap_no_to_address() {
94-
let memo = "=:s:0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4:0/1/0:g1:50";
95-
assert_eq!(swap_provider(&Chain::Litecoin, None, Some(memo)), Some(SwapperProvider::Thorchain));
115+
let transaction = Transaction {
116+
asset_id: Chain::Litecoin.as_asset_id(),
117+
to: String::new(),
118+
memo: Some("=:s:0xBA4D1d35bCe0e8F28E5a3403e7a0b996c5d50AC4:0/1/0:g1:50".to_string()),
119+
..Transaction::mock()
120+
};
121+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Thorchain));
96122
}
97123

98124
#[test]
99125
fn test_thorchain_evm_router_detected() {
100-
assert_eq!(
101-
swap_provider(&Chain::Ethereum, Some("0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146"), None),
102-
Some(SwapperProvider::Thorchain),
103-
);
126+
let transaction = Transaction {
127+
to: "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146".to_string(),
128+
..Transaction::mock()
129+
};
130+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Thorchain));
131+
}
132+
133+
#[test]
134+
fn test_thorchain_evm_data_detected() {
135+
let data = "0x3d3a623a626331713965797870616730777875386a74756b7a747a6b636876637a65793039616134397632326c353a302f312f303a67313a3530";
136+
let transaction = Transaction {
137+
to: "0xdfb89f7b854b79fdac99ddeb55921349ca649def".to_string(),
138+
data: Some(data.to_string()),
139+
..Transaction::mock()
140+
};
141+
assert_eq!(swap_provider(&transaction), Some(SwapperProvider::Thorchain));
104142
}
105143
}

crates/swapper/src/thorchain/asset.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,27 @@
1+
use num_bigint::BigInt;
12
use primitives::{Asset, AssetId};
3+
use std::str::FromStr;
24

35
use crate::asset::*;
46

57
use super::chain::THORChainName;
68

9+
const THORCHAIN_DECIMALS: i32 = 8;
10+
11+
pub fn value_from(value: &str, decimals: i32) -> BigInt {
12+
let value = BigInt::from_str(value).unwrap_or_default();
13+
let diff = decimals - THORCHAIN_DECIMALS;
14+
let factor = BigInt::from(10).pow(diff.unsigned_abs());
15+
if diff > 0 { value / factor } else { value * factor }
16+
}
17+
18+
pub fn value_to(value: &str, decimals: i32) -> BigInt {
19+
let value = BigInt::from_str(value).unwrap_or_default();
20+
let diff = decimals - THORCHAIN_DECIMALS;
21+
let factor = BigInt::from(10).pow(diff.unsigned_abs());
22+
if diff > 0 { value * factor } else { value / factor }
23+
}
24+
725
#[derive(Clone, Debug)]
826
pub struct THORChainAsset {
927
pub symbol: String,
@@ -206,6 +224,22 @@ mod tests {
206224
);
207225
}
208226

227+
#[test]
228+
fn test_value_from() {
229+
assert_eq!(value_from("1000000000000000000", 18), BigInt::from(100000000));
230+
assert_eq!(value_from("1000000000", 10), BigInt::from(10000000));
231+
assert_eq!(value_from("1000000000", 6), BigInt::from_str("100000000000").unwrap());
232+
assert_eq!(value_from("1000000000", 8), BigInt::from(1000000000));
233+
}
234+
235+
#[test]
236+
fn test_value_to() {
237+
assert_eq!(value_to("2509674", 18), BigInt::from_str("25096740000000000").unwrap());
238+
assert_eq!(value_to("10000000", 10), BigInt::from(1000000000));
239+
assert_eq!(value_to("79158429", 6), BigInt::from(791584));
240+
assert_eq!(value_to("160661010", 8), BigInt::from(160661010));
241+
}
242+
209243
#[test]
210244
fn test_tron_usdt_memo() {
211245
let tron_destination = "TEB39Rt69QkgD1BKhqaRNqGxfQzCarkRCb".to_string();

0 commit comments

Comments
 (0)