Skip to content

Commit 7f8d735

Browse files
shamardyclaude
andcommitted
feat(tron): add gasless TRC20 withdraw branch (sign-only)
Branch build_tron_withdraw on WithdrawFeeMethod (Native/Gasless/Auto): Gasless signs a TIP-712 PermitTransfer locally and returns it as a TronGasfreeRelayPayload wrapped in TransactionData::Unsigned for later submission via send_raw_transaction. Auto compares native vs gasless quotes and falls back; Gasless honors fallback_to_native on deterministic provider unavailability. Add a TxFeeDetails::TronGasless variant for the token-paid fee shape so fee details can stay sized for native withdraws and clients can branch on the type tag instead of probing optional fields. Thread per-token gasless config (transfer_max_fee cap) through ERC20 token activation; the cap is enforced into the signed permit's max_fee. Reject fee_method=gasless|auto on non-TRON coins explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18553a1 commit 7f8d735

16 files changed

Lines changed: 1124 additions & 194 deletions

File tree

mm2src/coins/eth.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ use std::str::from_utf8;
118118
use std::str::FromStr;
119119
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
120120
use std::sync::{Arc, Mutex};
121-
use tron::gasfree::ResolvedTronGaslessProvider;
121+
use tron::gasfree::{ResolvedTronGaslessProvider, ResolvedTronGaslessTokenConfig};
122122
use web3::types::{
123123
Action as TraceAction, BlockId, BlockNumber, Bytes, CallRequest, FilterBuilder, Log, Trace, TraceFilterBuilder,
124124
Transaction as Web3Transaction, TransactionId, U64,
@@ -1087,6 +1087,8 @@ pub struct EthCoinImpl {
10871087
/// or when gasless is not configured. Tokens can access this when withdrawing
10881088
/// via `platform_coin()`.
10891089
pub(crate) tron_gasless_provider: Option<ResolvedTronGaslessProvider>,
1090+
/// Token-level TRON GasFree configuration resolved at activation time.
1091+
pub(crate) tron_gasless_token_config: Option<ResolvedTronGaslessTokenConfig>,
10901092
/// This spawner is used to spawn coin's related futures that should be aborted on coin deactivation
10911093
/// and on [`MmArc::stop`].
10921094
pub abortable_system: AbortableQueue,
@@ -7258,6 +7260,7 @@ pub async fn eth_coin_from_conf_and_request(
72587260
gas_limit_v2,
72597261
estimate_gas_mult,
72607262
tron_gasless_provider: None,
7263+
tron_gasless_token_config: None,
72617264
abortable_system,
72627265
};
72637266

@@ -8263,6 +8266,7 @@ impl EthCoin {
82638266
gas_limit_v2: EthGasLimitV2::default(),
82648267
estimate_gas_mult: None,
82658268
tron_gasless_provider: self.tron_gasless_provider.clone(),
8269+
tron_gasless_token_config: self.tron_gasless_token_config.clone(),
82668270
abortable_system: self.abortable_system.create_subsystem().unwrap(),
82678271
};
82688272
EthCoin(Arc::new(coin))

mm2src/coins/eth/eth_withdraw.rs

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ use super::{
22
u256_from_big_decimal, u256_to_big_decimal, ChainSpec, ChainTaggedAddress, EthCoinType, EthDerivationMethod,
33
EthPrivKeyPolicy, Public, WithdrawError, WithdrawRequest, WithdrawResult, ERC20_CONTRACT, H256,
44
};
5+
use crate::eth::tron::gasfree::withdraw::{
6+
is_standard_tron_withdraw_unavailable, maybe_build_tron_gasless_withdraw, validate_non_tron_gasless_request,
7+
TronGaslessMode,
8+
};
9+
use crate::eth::tron::gasfree::GaslessWithdrawError;
510
use crate::eth::tron::sign::sign_tron_transaction;
611
use crate::eth::tron::withdraw::{
7-
build_tron_trc20_withdraw, build_tron_trx_withdraw, validate_tron_fee_policy, TronWithdrawContext,
12+
build_standard_tron_withdraw, validate_tron_fee_policy, StandardTronWithdrawParams, TronWithdrawContext,
813
};
914
use crate::eth::tron::TronAddress;
1015
use crate::eth::wallet_connect::WcEthTxParams;
@@ -28,13 +33,15 @@ use crypto::hw_rpc_task::HwRpcTaskAwaitingStatus;
2833
use crypto::trezor::trezor_rpc_task::{TrezorRequestStatuses, TrezorRpcTaskProcessor};
2934
use crypto::{CryptoCtx, HwRpcError};
3035
use ethabi::Token;
36+
use ethereum_types::U256;
3137
use futures::compat::Future01CompatExt;
3238
use kdf_walletconnect::{WalletConnectCtx, WalletConnectOps};
3339
use mm2_core::mm_ctx::MmArc;
3440
use mm2_err_handle::map_mm_error::MapMmError;
3541
use mm2_err_handle::mm_error::MmResult;
3642
use mm2_err_handle::prelude::{MapToMmResult, MmError, MmResultExt, OrMmError};
3743
use prost::Message;
44+
use std::convert::TryFrom;
3845
use std::ops::Deref;
3946
use std::sync::Arc;
4047
#[cfg(target_arch = "wasm32")]
@@ -67,6 +74,12 @@ where
6774
#[allow(clippy::result_large_err)]
6875
fn on_finishing(&self) -> Result<(), MmError<WithdrawError>>;
6976

77+
#[allow(clippy::result_large_err)]
78+
fn on_fetching_gasless_quote(&self) -> Result<(), MmError<WithdrawError>>;
79+
80+
#[allow(clippy::result_large_err)]
81+
fn on_signing_gasless_authorization(&self) -> Result<(), MmError<WithdrawError>>;
82+
7083
/// Signs the transaction with a Trezor hardware wallet.
7184
async fn sign_tx_with_trezor(
7285
&self,
@@ -84,7 +97,7 @@ where
8497
from_tagged: &ChainTaggedAddress,
8598
to_tagged: &ChainTaggedAddress,
8699
tx: TransactionData,
87-
amount: ethabi::ethereum_types::U256,
100+
amount: U256,
88101
total_fee: &BigDecimal,
89102
fee_details: TxFeeDetails,
90103
) -> WithdrawResult {
@@ -286,6 +299,7 @@ where
286299
"Memo is not yet supported for TRON withdraw (TRON charges 1 TRX burn fee for memo)".to_owned(),
287300
));
288301
}
302+
let gasless_mode = TronGaslessMode::try_from(req)?;
289303

290304
// 2. Validate key policy (only Iguana/HDWallet supported for TRON MVP)
291305
match coin.priv_key_policy {
@@ -314,26 +328,39 @@ where
314328
.tron_rpc()
315329
.ok_or_else(|| WithdrawError::InternalError("TRON RPC client is not initialized".to_owned()))?;
316330

317-
let my_balance = coin.address_balance(from_tagged).compat().await.map_mm_err()?;
318-
let my_balance_dec = u256_to_big_decimal(my_balance, coin.decimals).map_mm_err()?;
331+
let trc20_contract = match &coin.coin_type {
332+
EthCoinType::Eth => None,
333+
EthCoinType::Erc20 { token_addr, .. } => Some(TronAddress::from(*token_addr)),
334+
EthCoinType::Nft { .. } => {
335+
return MmError::err(WithdrawError::ProtocolNotSupported(
336+
"NFT withdraw is not supported for TRON".to_owned(),
337+
))
338+
},
339+
};
319340

320-
if req.max && my_balance.is_zero() {
321-
return MmError::err(WithdrawError::ZeroBalanceToWithdrawMax);
341+
if gasless_mode == TronGaslessMode::Gasless {
342+
let Some(contract_tron) = trc20_contract else {
343+
return MmError::err(WithdrawError::Gasless(GaslessWithdrawError::Unavailable));
344+
};
345+
346+
if let Some(details) =
347+
maybe_build_tron_gasless_withdraw(self, tron, &from_tagged, &to_tagged, contract_tron, gasless_mode)
348+
.await?
349+
{
350+
return Ok(details);
351+
}
322352
}
323353

354+
let my_balance = coin.address_balance(from_tagged).compat().await.map_mm_err()?;
355+
let my_balance_dec = u256_to_big_decimal(my_balance, coin.decimals).map_mm_err()?;
356+
324357
let amount_base_units = if req.max {
358+
if my_balance.is_zero() {
359+
return MmError::err(WithdrawError::ZeroBalanceToWithdrawMax);
360+
}
325361
my_balance
326362
} else {
327-
let amount = u256_from_big_decimal(&req.amount, coin.decimals).map_mm_err()?;
328-
if amount > my_balance {
329-
let required_dec = u256_to_big_decimal(amount, coin.decimals).map_mm_err()?;
330-
return MmError::err(WithdrawError::NotSufficientBalance {
331-
coin: ticker.to_owned(),
332-
available: my_balance_dec,
333-
required: required_dec,
334-
});
335-
}
336-
amount
363+
u256_from_big_decimal(&req.amount, coin.decimals).map_mm_err()?
337364
};
338365

339366
// 4. Convert addresses to TRON format
@@ -347,7 +374,7 @@ where
347374
let resources = tron.get_account_resource(&from_tron).await.map_mm_err()?;
348375
let prices = tron.get_chain_prices().await.map_mm_err()?;
349376

350-
// 7. Build tx, estimate fees — branching on coin type
377+
// 7. Build tx and estimate fees.
351378
let withdraw_ctx = TronWithdrawContext {
352379
from: &from_tron,
353380
to: &to_tron,
@@ -357,24 +384,42 @@ where
357384
fee_coin: ticker,
358385
expiration_seconds: req.expiration_seconds,
359386
};
360-
let (raw, tron_fee_details, final_amount) = match &coin.coin_type {
361-
EthCoinType::Eth => {
362-
build_tron_trx_withdraw(&withdraw_ctx, amount_base_units, my_balance, &my_balance_dec, req.max)?
363-
},
364-
EthCoinType::Erc20 {
365-
token_addr, platform, ..
366-
} => {
367-
let contract_tron = TronAddress::from(*token_addr);
368-
let trc20_ctx = TronWithdrawContext {
369-
fee_coin: platform.as_str(),
370-
..withdraw_ctx
387+
let standard_tron_withdraw_params = StandardTronWithdrawParams {
388+
coin_type: &coin.coin_type,
389+
ticker,
390+
decimals: coin.decimals,
391+
balance: my_balance,
392+
balance_dec: &my_balance_dec,
393+
amount_base_units,
394+
is_max: req.max,
395+
ctx: &withdraw_ctx,
396+
tron,
397+
};
398+
399+
let (raw, tron_fee_details, final_amount) = match build_standard_tron_withdraw(standard_tron_withdraw_params)
400+
.await
401+
{
402+
Ok(prepared) => prepared,
403+
Err(standard_fee_route_err) => {
404+
if gasless_mode != TronGaslessMode::Auto
405+
|| req.max
406+
|| !is_standard_tron_withdraw_unavailable(&standard_fee_route_err)
407+
{
408+
return Err(standard_fee_route_err);
409+
}
410+
411+
let Some(contract_tron) = trc20_contract else {
412+
return Err(standard_fee_route_err);
371413
};
372-
build_tron_trc20_withdraw(&trc20_ctx, tron, &contract_tron, amount_base_units).await?
373-
},
374-
EthCoinType::Nft { .. } => {
375-
return MmError::err(WithdrawError::ProtocolNotSupported(
376-
"NFT withdraw is not supported for TRON".to_owned(),
377-
))
414+
415+
if let Some(details) =
416+
maybe_build_tron_gasless_withdraw(self, tron, &from_tagged, &to_tagged, contract_tron, gasless_mode)
417+
.await?
418+
{
419+
return Ok(details);
420+
}
421+
422+
return Err(standard_fee_route_err);
378423
},
379424
};
380425

@@ -417,6 +462,8 @@ where
417462
return self.build_tron_withdraw(from_tagged, to_tagged).await;
418463
}
419464

465+
validate_non_tron_gasless_request(&req)?;
466+
420467
// ── EVM withdraw: existing path (unchanged) ──
421468
let my_balance = coin.address_balance(from_tagged).compat().await.map_mm_err()?;
422469
let my_balance_dec = u256_to_big_decimal(my_balance, coin.decimals).map_mm_err()?;
@@ -603,6 +650,18 @@ impl EthWithdraw for InitEthWithdraw {
603650
.map_mm_err()
604651
}
605652

653+
fn on_fetching_gasless_quote(&self) -> Result<(), MmError<WithdrawError>> {
654+
self.task_handle
655+
.update_in_progress_status(WithdrawInProgressStatus::FetchingGaslessQuote)
656+
.map_mm_err()
657+
}
658+
659+
fn on_signing_gasless_authorization(&self) -> Result<(), MmError<WithdrawError>> {
660+
self.task_handle
661+
.update_in_progress_status(WithdrawInProgressStatus::SigningGaslessAuthorization)
662+
.map_mm_err()
663+
}
664+
606665
async fn sign_tx_with_trezor(
607666
&self,
608667
derivation_path: &DerivationPath,
@@ -677,6 +736,14 @@ impl EthWithdraw for StandardEthWithdraw {
677736
Ok(())
678737
}
679738

739+
fn on_fetching_gasless_quote(&self) -> Result<(), MmError<WithdrawError>> {
740+
Ok(())
741+
}
742+
743+
fn on_signing_gasless_authorization(&self) -> Result<(), MmError<WithdrawError>> {
744+
Ok(())
745+
}
746+
680747
async fn sign_tx_with_trezor(
681748
&self,
682749
_derivation_path: &DerivationPath,

mm2src/coins/eth/for_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ fn make_eth_coin(
9595
gas_limit_v2,
9696
estimate_gas_mult,
9797
tron_gasless_provider: None,
98+
tron_gasless_token_config: None,
9899
abortable_system: AbortableQueue::default(),
99100
}))
100101
}

mm2src/coins/eth/tron/fee.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize};
2424
const RESULT_BYTES_OVERHEAD_PER_CONTRACT: u64 = 64;
2525
/// TRON signatures are 65 bytes (`r || s || v`), used here as estimation placeholder.
2626
const PLACEHOLDER_SIGNATURE_LEN: usize = 65;
27+
pub const TRON_GASFREE_PROVIDER_NAME: &str = "gasfree";
2728

2829
/// Fee breakdown for a TRON transaction.
2930
///
@@ -37,20 +38,32 @@ pub struct TronTxFeeDetails {
3738
pub bandwidth_fee: BigDecimal,
3839
pub energy_fee: BigDecimal,
3940
pub total_fee: BigDecimal,
40-
/// TODO(Commit 9): populated by `build_tron_withdraw` when the gasless branch is taken. Check if it's better to have 2 variants while backward compatible
41-
#[serde(default, skip_serializing_if = "Option::is_none")]
42-
pub gasless: Option<TronGaslessFeeMeta>,
4341
}
4442

45-
/// Gasless provider fee metadata for TRC20 withdrawals routed through GasFree.
43+
/// Fee rail used for TRON gasless fee metadata.
44+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
45+
#[serde(rename_all = "snake_case")]
46+
pub enum TronGaslessFeeMethod {
47+
/// Provider-sponsored TRC20 transfer authorized through GasFree.
48+
Gasless,
49+
}
50+
51+
/// Fee details for TRC20 withdrawals routed through GasFree.
52+
///
53+
/// Distinct variant from [`TronTxFeeDetails`] because the user pays the provider
54+
/// fee in the token itself (no TRX bandwidth/energy) and signs an off-chain
55+
/// authorization rather than broadcasting on-chain.
4656
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
47-
pub struct TronGaslessFeeMeta {
48-
pub fee_method: String,
49-
pub provider: String,
57+
pub struct TronGaslessFeeDetails {
58+
pub coin: String,
59+
pub fee_method: TronGaslessFeeMethod,
60+
pub provider_name: String,
5061
pub gasfree_address: String,
5162
pub transfer_fee: BigDecimal,
5263
pub activation_fee: BigDecimal,
5364
pub total_token_fee: BigDecimal,
65+
#[serde(default, skip_serializing_if = "Option::is_none")]
66+
pub signed_max_fee: Option<BigDecimal>,
5467
pub trace_id: Option<String>,
5568
}
5669

@@ -192,7 +205,6 @@ fn estimate_fee_details(
192205
bandwidth_fee: sun_to_trx_decimal(bandwidth_fee_sun),
193206
energy_fee: sun_to_trx_decimal(energy_fee_sun),
194207
total_fee: sun_to_trx_decimal(total_fee_sun),
195-
gasless: None,
196208
}
197209
}
198210

mm2src/coins/eth/tron/gasfree/config.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::client::TronGasfreeClient;
22
use super::error::TronGaslessConfigError;
33
use crate::eth::tron::{Network, TronAddress};
4+
use ethereum_types::U256;
45
use serde::Deserialize;
56
use std::str::FromStr;
67
use std::sync::Arc;
@@ -59,8 +60,15 @@ impl TronGaslessProviderConfig {
5960
}
6061
}
6162

63+
/// Token-level GasFree settings resolved at activation time for TRON TRC20 assets.
64+
#[derive(Clone, Debug, PartialEq)]
65+
pub struct ResolvedTronGaslessTokenConfig {
66+
/// Maximum provider fee allowed for transfers, converted at activation to token base units.
67+
pub transfer_max_fee_token_base_units: Option<U256>,
68+
}
69+
6270
/// Fully-validated GasFree provider state that can be stored on coin context.
63-
/// Redult of [`resolve_tron_gasless_provider`](super::resolve_tron_gasless_provider).
71+
/// Result of [`resolve_tron_gasless_provider`](super::resolve_tron_gasless_provider).
6472
#[derive(Clone)]
6573
pub struct ResolvedTronGaslessProvider {
6674
raw: TronGaslessProviderConfig,

mm2src/coins/eth/tron/gasfree/error.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::eth::{format_remote_error, Web3RpcError};
12
use common::HttpStatusCode;
23
use derive_more::Display;
34
use http::StatusCode;
@@ -108,3 +109,19 @@ impl From<SlurpError> for TronGasfreeError {
108109
}
109110
}
110111
}
112+
113+
impl From<Web3RpcError> for TronGasfreeError {
114+
fn from(err: Web3RpcError) -> Self {
115+
match err {
116+
Web3RpcError::Transport(message) => TronGasfreeError::Transport(message),
117+
Web3RpcError::Timeout(message) => TronGasfreeError::Timeout(message),
118+
Web3RpcError::BadResponse(message) | Web3RpcError::InvalidResponse(message) => {
119+
TronGasfreeError::InvalidResponse(message)
120+
},
121+
Web3RpcError::RemoteError { code, message } => {
122+
TronGasfreeError::InvalidResponse(format_remote_error(code, message))
123+
},
124+
other => TronGasfreeError::InvalidResponse(other.to_string()),
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)