Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit ec8da70

Browse files
committed
Fix Hyperliquid partial unstake and show pending withdrawals
- Use the entered amount for unstake instead of the full delegation - Surface pending unstaking withdrawals with their 7-day unlock countdown - Reuse a shared system validator from primitives across chains
1 parent 3e940ab commit ec8da70

8 files changed

Lines changed: 182 additions & 53 deletions

File tree

‎crates/gem_hypercore/src/models/balance.rs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use gem_evm::ethereum_address_checksum;
22
use serde::{Deserialize, Serialize};
33
use serde_serializers::deserialize_f64_from_str;
4+
use strum::{Display, EnumString};
45

56
#[derive(Debug, Clone, Serialize, Deserialize)]
67
#[serde(rename_all = "camelCase")]
@@ -57,6 +58,53 @@ impl DelegationBalance {
5758
}
5859
}
5960

61+
#[derive(Debug, Clone, Serialize, Deserialize)]
62+
#[serde(rename_all = "camelCase")]
63+
pub struct DelegatorHistoryEntry {
64+
pub time: u64,
65+
pub delta: DelegatorDelta,
66+
}
67+
68+
#[derive(Debug, Clone, Serialize, Deserialize)]
69+
#[serde(rename_all = "camelCase")]
70+
pub struct DelegatorDelta {
71+
pub withdrawal: Option<DelegatorWithdrawal>,
72+
}
73+
74+
#[derive(Debug, Clone, Serialize, Deserialize)]
75+
#[serde(rename_all = "camelCase")]
76+
pub struct DelegatorWithdrawal {
77+
#[serde(deserialize_with = "deserialize_f64_from_str")]
78+
pub amount: f64,
79+
pub phase: WithdrawalPhase,
80+
}
81+
82+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Display, EnumString)]
83+
#[serde(from = "String", into = "String")]
84+
pub enum WithdrawalPhase {
85+
#[strum(serialize = "initiated")]
86+
Initiated,
87+
#[strum(serialize = "finalized")]
88+
Finalized,
89+
#[strum(default)]
90+
Other(String),
91+
}
92+
93+
impl From<String> for WithdrawalPhase {
94+
fn from(value: String) -> Self {
95+
match value.parse() {
96+
Ok(phase) => phase,
97+
Err(_) => WithdrawalPhase::Other(value),
98+
}
99+
}
100+
}
101+
102+
impl From<WithdrawalPhase> for String {
103+
fn from(value: WithdrawalPhase) -> Self {
104+
value.to_string()
105+
}
106+
}
107+
60108
#[derive(Debug, Clone, Serialize, Deserialize)]
61109
#[serde(rename_all = "camelCase")]
62110
pub struct Validator {

‎crates/gem_hypercore/src/provider/staking.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use async_trait::async_trait;
22
use chain_traits::ChainStaking;
3+
use chrono::Utc;
4+
use futures::try_join;
35
use std::error::Error;
46

57
use gem_client::Client;
@@ -21,7 +23,7 @@ impl<C: Client> ChainStaking for HyperCoreClient<C> {
2123
}
2224

2325
async fn get_staking_delegations(&self, address: String) -> Result<Vec<DelegationBase>, Box<dyn Error + Sync + Send>> {
24-
let delegations = self.get_staking_delegations(&address).await?;
25-
Ok(staking_mapper::map_staking_delegations(delegations, self.chain))
26+
let (delegations, history) = try_join!(self.get_staking_delegations(&address), self.get_staking_history(&address))?;
27+
Ok(staking_mapper::map_staking_delegations(delegations, history, Utc::now(), self.chain))
2628
}
2729
}
Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,93 @@
1-
use crate::models::balance::{DelegationBalance, Validator};
1+
use crate::models::balance::{DelegationBalance, DelegatorHistoryEntry, Validator, WithdrawalPhase};
2+
use chrono::{DateTime, Duration, Utc};
23
use num_bigint::BigUint;
34
use number_formatter::BigNumberFormatter;
45
use primitives::{Asset, Chain, DelegationBase, DelegationState, DelegationValidator};
56
use std::str::FromStr;
67

78
pub fn map_staking_validators(validators: Vec<Validator>, chain: Chain, apy: Option<f64>) -> Vec<DelegationValidator> {
89
let calculated_apy = apy.unwrap_or_else(|| Validator::max_apr(validators.clone()));
9-
validators
10+
let mut result: Vec<DelegationValidator> = validators
1011
.into_iter()
1112
.map(|x| DelegationValidator::stake(chain, x.validator_address(), x.name, x.is_active, x.commission, calculated_apy))
12-
.collect()
13+
.collect();
14+
15+
result.push(DelegationValidator::system(chain));
16+
17+
result
1318
}
1419

15-
pub fn map_staking_delegations(delegations: Vec<DelegationBalance>, chain: Chain) -> Vec<DelegationBase> {
20+
pub fn map_staking_delegations(delegations: Vec<DelegationBalance>, history: Vec<DelegatorHistoryEntry>, now: DateTime<Utc>, chain: Chain) -> Vec<DelegationBase> {
1621
let native_decimals = Asset::from_chain(chain).decimals as u32;
17-
delegations
22+
let mut result: Vec<DelegationBase> = delegations
23+
.into_iter()
24+
.map(|x| DelegationBase {
25+
asset_id: chain.as_asset_id(),
26+
state: DelegationState::Active,
27+
balance: parse_balance(&x.amount.to_string(), native_decimals),
28+
shares: BigUint::from(0u32),
29+
rewards: BigUint::from(0u32),
30+
completion_date: None,
31+
delegation_id: x.validator_address(),
32+
validator_id: x.validator_address(),
33+
})
34+
.collect();
35+
36+
result.extend(map_pending_withdrawals(history, now, chain, native_decimals));
37+
result
38+
}
39+
40+
fn map_pending_withdrawals(history: Vec<DelegatorHistoryEntry>, now: DateTime<Utc>, chain: Chain, native_decimals: u32) -> Vec<DelegationBase> {
41+
let lock_time = chain.config().stake.as_ref().map(|stake| stake.lock_time).unwrap_or_default();
42+
let lock = Duration::seconds(lock_time as i64);
43+
44+
history
1845
.into_iter()
19-
.map(|x| {
20-
let balance = BigNumberFormatter::value_from_amount(&x.amount.to_string(), native_decimals)
21-
.ok()
22-
.and_then(|s| BigUint::from_str(&s).ok())
23-
.unwrap_or_default();
24-
DelegationBase {
46+
.filter_map(|entry| {
47+
let withdrawal = entry.delta.withdrawal?;
48+
if withdrawal.phase != WithdrawalPhase::Initiated {
49+
return None;
50+
}
51+
let completion_date = DateTime::from_timestamp_millis(entry.time as i64)? + lock;
52+
if completion_date <= now {
53+
return None;
54+
}
55+
Some(DelegationBase {
2556
asset_id: chain.as_asset_id(),
26-
state: DelegationState::Active,
27-
balance,
57+
state: DelegationState::Pending,
58+
balance: parse_balance(&withdrawal.amount.to_string(), native_decimals),
2859
shares: BigUint::from(0u32),
2960
rewards: BigUint::from(0u32),
30-
completion_date: None,
31-
delegation_id: x.validator_address(),
32-
validator_id: x.validator_address(),
33-
}
61+
completion_date: Some(completion_date),
62+
delegation_id: format!("unstaking_{}", entry.time),
63+
validator_id: DelegationValidator::SYSTEM_ID.to_string(),
64+
})
3465
})
3566
.collect()
3667
}
3768

69+
fn parse_balance(amount: &str, native_decimals: u32) -> BigUint {
70+
BigNumberFormatter::value_from_amount(amount, native_decimals)
71+
.ok()
72+
.and_then(|s| BigUint::from_str(&s).ok())
73+
.unwrap_or_default()
74+
}
75+
3876
#[cfg(test)]
3977
mod tests {
4078
use super::*;
41-
use crate::models::balance::ValidatorStats;
79+
use crate::models::balance::{DelegatorDelta, DelegatorWithdrawal, ValidatorStats};
4280
use primitives::{Chain, DelegationState};
4381

82+
fn withdrawal_entry(time: u64, amount: f64, phase: WithdrawalPhase) -> DelegatorHistoryEntry {
83+
DelegatorHistoryEntry {
84+
time,
85+
delta: DelegatorDelta {
86+
withdrawal: Some(DelegatorWithdrawal { amount, phase }),
87+
},
88+
}
89+
}
90+
4491
#[test]
4592
fn test_map_staking_validators() {
4693
let validators = vec![Validator {
@@ -52,13 +99,18 @@ mod tests {
5299
}];
53100

54101
let result = map_staking_validators(validators, Chain::HyperCore, None);
55-
assert_eq!(result.len(), 1);
102+
assert_eq!(result.len(), 2);
56103
assert_eq!(result[0].name, "Test Validator");
57104
assert_eq!(result[0].id, "0x5aC99df645F3414876C816Caa18b2d234024b487");
58105
assert_eq!(result[0].chain, Chain::HyperCore);
59106
assert!(result[0].is_active);
60107
assert_eq!(result[0].commission, 5.0);
61108
assert_eq!(result[0].apr, 15.0); // max_apr * 100
109+
110+
let system = &result[1];
111+
assert_eq!(system.id, DelegationValidator::SYSTEM_ID);
112+
assert_eq!(system.name, DelegationValidator::SYSTEM_NAME);
113+
assert!(system.is_active);
62114
}
63115

64116
#[test]
@@ -72,15 +124,16 @@ mod tests {
72124
}];
73125

74126
let result = map_staking_validators(validators, Chain::HyperCore, Some(10.0));
75-
assert_eq!(result.len(), 1);
127+
assert_eq!(result.len(), 2);
76128
assert_eq!(result[0].apr, 10.0); // Uses provided APY
129+
assert_eq!(result[1].id, DelegationValidator::SYSTEM_ID);
77130
}
78131

79132
#[test]
80133
fn test_map_staking_delegations() {
81134
let delegations: Vec<DelegationBalance> = serde_json::from_str(include_str!("../../testdata/staking_delegations.json")).unwrap();
82135

83-
let result = map_staking_delegations(delegations, Chain::HyperCore);
136+
let result = map_staking_delegations(delegations, vec![], Utc::now(), Chain::HyperCore);
84137

85138
assert_eq!(result.len(), 2);
86139

@@ -98,4 +151,29 @@ mod tests {
98151
assert_eq!(delegation2.validator_id, "0xaBCDefF4b3727B83A23697500EEf089020DF2cD2");
99152
assert_eq!(delegation2.balance.to_string(), "1814578086");
100153
}
154+
155+
#[test]
156+
fn test_map_staking_delegations_pending_withdrawals() {
157+
let now = DateTime::from_timestamp(1_780_000_000, 0).unwrap();
158+
let history = vec![
159+
withdrawal_entry(1_779_913_600_000, 1.5, WithdrawalPhase::Initiated),
160+
withdrawal_entry(1_779_308_800_000, 2.0, WithdrawalPhase::Initiated),
161+
withdrawal_entry(1_779_950_000_000, 3.0, WithdrawalPhase::Finalized),
162+
DelegatorHistoryEntry {
163+
time: 1_779_950_000_001,
164+
delta: DelegatorDelta { withdrawal: None },
165+
},
166+
];
167+
168+
let result = map_staking_delegations(vec![], history, now, Chain::HyperCore);
169+
170+
assert_eq!(result.len(), 1);
171+
let pending = &result[0];
172+
assert!(matches!(pending.state, DelegationState::Pending));
173+
assert_eq!(pending.validator_id, DelegationValidator::SYSTEM_ID);
174+
assert_eq!(pending.balance.to_string(), "150000000");
175+
assert_eq!(pending.delegation_id, "unstaking_1779913600000");
176+
assert_eq!(pending.completion_date, Some(now + Duration::seconds(518_400)));
177+
assert!(pending.completion_date.unwrap() > now);
178+
}
101179
}

‎crates/gem_hypercore/src/rpc/client.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::models::{
2-
balance::{Balances, DelegationBalance, StakeBalance, Validator},
2+
balance::{Balances, DelegationBalance, DelegatorHistoryEntry, StakeBalance, Validator},
33
candlestick::Candlestick,
44
metadata::HypercoreMetadataResponse,
55
order::{OpenOrder, UserFill},
@@ -115,6 +115,10 @@ impl<C: Client> HyperCoreClient<C> {
115115
self.info(json!({"type": "delegations", "user": user})).await
116116
}
117117

118+
pub async fn get_staking_history(&self, user: &str) -> Result<Vec<DelegatorHistoryEntry>, Box<dyn Error + Send + Sync>> {
119+
self.info(json!({"type": "delegatorHistory", "user": user})).await
120+
}
121+
118122
pub async fn get_staking_apy(&self) -> Result<f64, Box<dyn Error + Send + Sync>> {
119123
let validators = self.get_validators().await?;
120124
Ok(Validator::max_apr(validators))

‎crates/gem_hypercore/src/signer/core_signer.rs‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@ impl HyperCoreSigner {
103103
Ok(vec![deposit_action, delegate_action])
104104
}
105105
StakeType::Unstake(delegation) => {
106-
let balance = delegation.base.balance.to_string();
107-
let wei = BigNumberFormatter::value_as_u64(&balance, 0).map_err(|err| SignerError::InvalidInput(err.to_string()))?;
106+
let wei = BigNumberFormatter::value_as_u64(&input.value, 0).map_err(|err| SignerError::InvalidInput(err.to_string()))?;
108107

109108
let undelegate_request = TokenDelegate::new(delegation.validator.id.clone(), wei, true, nonce_incrementer.next_val());
110109
let undelegate_action = self.sign_token_delegate(undelegate_request, private_key)?;
@@ -438,7 +437,7 @@ mod tests {
438437
}
439438

440439
#[test]
441-
fn unstake_actions_have_unique_nonces() {
440+
fn unstake_uses_entered_amount_and_unique_nonces() {
442441
let signer = HyperCoreSigner;
443442
let asset = Asset::from_chain(Chain::HyperCore);
444443
let delegation = Delegation {
@@ -456,7 +455,7 @@ mod tests {
456455
price: None,
457456
};
458457
let input = TransactionLoadInput {
459-
value: "0".into(),
458+
value: "60000000".into(),
460459
sender_address: "0xsender".into(),
461460
destination_address: "".into(),
462461
..TransactionLoadInput::mock_with_input_type(TransactionInputType::Stake(asset, StakeType::Unstake(delegation)))
@@ -467,16 +466,19 @@ mod tests {
467466
let responses = signer.sign_stake_action(&input, &private_key).expect("should sign");
468467
assert_eq!(responses.len(), 2);
469468

470-
let nonces: Vec<u64> = responses
471-
.iter()
472-
.map(|payload| {
473-
let value: serde_json::Value = serde_json::from_str(payload).expect("valid json");
474-
value["action"]["nonce"].as_u64().expect("action nonce")
475-
})
476-
.collect();
469+
let undelegate: serde_json::Value = serde_json::from_str(&responses[0]).expect("json");
470+
let withdraw: serde_json::Value = serde_json::from_str(&responses[1]).expect("json");
471+
472+
assert_eq!(undelegate["action"]["type"], "tokenDelegate");
473+
assert_eq!(undelegate["action"]["isUndelegate"], true);
474+
assert_eq!(withdraw["action"]["type"], "cWithdraw");
475+
476+
assert_eq!(undelegate["action"]["wei"].as_u64().expect("undelegate wei"), 60000000);
477+
assert_eq!(withdraw["action"]["wei"].as_u64().expect("withdraw wei"), 60000000);
477478

478-
assert_eq!(nonces.len(), 2);
479-
assert!(nonces[0] < nonces[1], "unstake actions should advance nonce");
479+
let undelegate_nonce = undelegate["action"]["nonce"].as_u64().expect("nonce");
480+
let withdraw_nonce = withdraw["action"]["nonce"].as_u64().expect("nonce");
481+
assert!(undelegate_nonce < withdraw_nonce, "unstake actions should advance nonce");
480482
}
481483

482484
#[test]

‎crates/gem_tron/src/provider/staking.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ use super::staking_mapper::map_staking_validators;
1111
use crate::rpc::client::TronClient;
1212
use crate::rpc::constants::{GET_WITNESS_127_PAY_PER_BLOCK, GET_WITNESS_PAY_PER_BLOCK};
1313

14-
const SYSTEM_VALIDATOR_ID: &str = "system";
15-
1614
#[async_trait]
1715
impl<C: Client + Clone> ChainStaking for TronClient<C> {
1816
async fn get_staking_apy(&self) -> Result<Option<f64>, Box<dyn Error + Sync + Send>> {
@@ -70,7 +68,7 @@ impl<C: Client + Clone> ChainStaking for TronClient<C> {
7068
rewards: BigUint::from(0u32),
7169
completion_date: Some(completion_date),
7270
delegation_id: completion_date.timestamp().to_string(),
73-
validator_id: SYSTEM_VALIDATOR_ID.to_string(),
71+
validator_id: DelegationValidator::SYSTEM_ID.to_string(),
7472
});
7573
}
7674
}

‎crates/gem_tron/src/provider/staking_mapper.rs‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ use crate::address::TronAddress;
22
use crate::models::WitnessesList;
33
use primitives::{Address as _, Chain, DelegationValidator, StakeValidator};
44

5-
const SYSTEM_UNSTAKING_VALIDATOR_ID: &str = "system";
6-
const SYSTEM_UNSTAKING_VALIDATOR_NAME: &str = "Unstaking";
7-
85
pub fn map_validators(witnesses: WitnessesList) -> Vec<StakeValidator> {
96
witnesses.witnesses.into_iter().map(|x| StakeValidator::new(x.address, x.url)).collect()
107
}
@@ -26,14 +23,7 @@ pub fn map_staking_validators(witnesses: WitnessesList, apy: Option<f64>) -> Vec
2623
})
2724
.collect();
2825

29-
validators.push(DelegationValidator::stake(
30-
Chain::Tron,
31-
SYSTEM_UNSTAKING_VALIDATOR_ID.to_string(),
32-
SYSTEM_UNSTAKING_VALIDATOR_NAME.to_string(),
33-
true,
34-
0.0,
35-
default_apy,
36-
));
26+
validators.push(DelegationValidator::system(Chain::Tron));
3727

3828
validators
3929
}
@@ -79,8 +69,8 @@ mod tests {
7969
assert_eq!(validators[1].id, "TEqyWRKCzREYC2bK2fc3j7pp8XjAa6tJK1");
8070
assert!(!validators[1].is_active);
8171

82-
assert_eq!(validators[2].id, SYSTEM_UNSTAKING_VALIDATOR_ID);
83-
assert_eq!(validators[2].name, SYSTEM_UNSTAKING_VALIDATOR_NAME);
72+
assert_eq!(validators[2].id, DelegationValidator::SYSTEM_ID);
73+
assert_eq!(validators[2].name, DelegationValidator::SYSTEM_NAME);
8474
assert!(validators[2].is_active);
8575
}
8676
}

0 commit comments

Comments
 (0)