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

Commit 7e9b6fd

Browse files
committed
Improve handling for unstake transaction on hypercore
1 parent 0c1a85b commit 7e9b6fd

5 files changed

Lines changed: 233 additions & 21 deletions

File tree

crates/gem_hypercore/src/models/user.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ pub struct LedgerUpdate {
4343
pub delta: LedgerDelta,
4444
}
4545

46+
#[derive(Debug, Clone, Deserialize)]
47+
#[serde(rename_all = "camelCase")]
48+
pub struct DelegatorHistoryUpdate {
49+
pub time: u64,
50+
pub hash: String,
51+
pub delta: DelegatorHistoryDelta,
52+
}
53+
54+
#[derive(Debug, Clone, Deserialize)]
55+
#[serde(rename_all = "camelCase")]
56+
pub struct DelegatorHistoryDelta {
57+
pub c_deposit: Option<DelegatorAmountDelta>,
58+
pub delegate: Option<DelegatorDelegateDelta>,
59+
pub withdrawal: Option<DelegatorWithdrawalDelta>,
60+
}
61+
62+
#[derive(Debug, Clone, Deserialize)]
63+
#[serde(rename_all = "camelCase")]
64+
pub struct DelegatorAmountDelta {
65+
pub amount: String,
66+
}
67+
68+
#[derive(Debug, Clone, Deserialize)]
69+
#[serde(rename_all = "camelCase")]
70+
pub struct DelegatorDelegateDelta {
71+
pub amount: String,
72+
pub is_undelegate: bool,
73+
}
74+
75+
#[derive(Debug, Clone, Deserialize)]
76+
#[serde(rename_all = "camelCase")]
77+
pub struct DelegatorWithdrawalDelta {
78+
pub amount: String,
79+
pub phase: String,
80+
}
81+
4682
#[derive(Debug, Clone, Deserialize)]
4783
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
4884
pub enum LedgerDelta {

crates/gem_hypercore/src/provider/transaction_state.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,59 @@ impl<C: Client> HyperCoreClient<C> {
3939
let fills = self.get_user_fills_by_time(&request.sender_address, start_time).await?;
4040
Ok(transaction_state_mapper::map_transaction_state_order_action(fills, *nonce, request.id.clone()))
4141
}
42-
HyperCoreActionId::Nonce(_) | HyperCoreActionId::CDeposit { .. } | HyperCoreActionId::CWithdraw { .. } | HyperCoreActionId::TokenDelegate { .. } => {
42+
HyperCoreActionId::CDeposit { .. } | HyperCoreActionId::CWithdraw { .. } | HyperCoreActionId::TokenDelegate { .. } => {
43+
let updates = self.get_delegator_history(&request.sender_address).await?;
44+
Ok(transaction_state_mapper::map_transaction_state_staking_action(updates, action_id, request.id.clone()))
45+
}
46+
HyperCoreActionId::Nonce(nonce) => {
4347
let updates = self
4448
.get_ledger_updates(
4549
&request.sender_address,
46-
action_id.nonce().saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64,
50+
nonce.saturating_sub(transaction_state_mapper::ACTION_HISTORY_QUERY_LOOKBACK_MS) as i64,
4751
)
4852
.await?;
4953
Ok(transaction_state_mapper::map_transaction_state_action(updates, action_id, request.id.clone()))
5054
}
5155
}
5256
}
5357
}
58+
59+
#[cfg(test)]
60+
mod tests {
61+
use super::*;
62+
use chrono::{TimeZone, Utc};
63+
use gem_client::testkit::MockClient;
64+
use primitives::{TransactionChange, TransactionState, TransactionStateRequest};
65+
66+
#[tokio::test]
67+
async fn test_transaction_state_uses_delegator_history_for_c_withdraw() {
68+
let client = HyperCoreClient::new(MockClient::new().with_post(|_, body| {
69+
let payload: serde_json::Value = serde_json::from_slice(body).unwrap();
70+
assert_eq!(payload["type"], "delegatorHistory");
71+
Ok(
72+
r#"[{"time":1780078270596,"hash":"0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da","delta":{"withdrawal":{"amount":"0.03001423","phase":"initiated"}}}]"#
73+
.as_bytes()
74+
.to_vec(),
75+
)
76+
}));
77+
let request_id = "action:cWithdraw:3001423:1780078264489".to_string();
78+
let update = client
79+
.transaction_state(TransactionStateRequest {
80+
id: request_id.clone(),
81+
sender_address: "0x9EdcF9Ff72088DB8130C2512E5B4D3b5F34cEaF4".to_string(),
82+
created_at: Utc.timestamp_millis_opt(1780078264489).unwrap(),
83+
block_number: 0,
84+
})
85+
.await
86+
.unwrap();
87+
88+
assert_eq!(update.state, TransactionState::Confirmed);
89+
assert_eq!(
90+
update.changes,
91+
vec![TransactionChange::HashChange {
92+
old: request_id,
93+
new: "0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da".to_string(),
94+
}]
95+
);
96+
}
97+
}

crates/gem_hypercore/src/provider/transaction_state_mapper.rs

Lines changed: 99 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::models::{
22
order::{FillDirection, UserFill},
33
transaction_id::HyperCoreActionId,
4-
user::{LedgerDelta, LedgerUpdate},
4+
user::{DelegatorHistoryDelta, DelegatorHistoryUpdate, LedgerDelta, LedgerUpdate},
55
};
66
use number_formatter::BigNumberFormatter;
77
use primitives::{
@@ -11,6 +11,7 @@ use primitives::{
1111

1212
pub const ACTION_HISTORY_QUERY_LOOKBACK_MS: u64 = 5_000;
1313
const ACTION_HISTORY_MATCH_WINDOW_MS: u64 = 5 * 60 * 1_000;
14+
const DELEGATOR_WITHDRAWAL_INITIATED: &str = "initiated";
1415

1516
fn perpetual_fill_type_and_direction(dir: &FillDirection) -> Option<(TransactionType, PerpetualDirection)> {
1617
match dir {
@@ -69,10 +70,7 @@ pub fn map_transaction_state_order(fills: Vec<UserFill>, oid: u64, request_id: S
6970
}
7071

7172
pub fn map_transaction_state_order_action(fills: Vec<UserFill>, nonce: u64, request_id: String) -> TransactionUpdate {
72-
match order_action_hash(&fills, nonce) {
73-
Some(hash) => confirmed_hash_change(request_id, hash),
74-
None => TransactionUpdate::new_state(TransactionState::Pending),
75-
}
73+
transaction_update_from_hash(order_action_hash(&fills, nonce), request_id)
7674
}
7775

7876
pub fn order_action_hash(fills: &[UserFill], nonce: u64) -> Option<String> {
@@ -84,10 +82,11 @@ pub fn order_action_hash(fills: &[UserFill], nonce: u64) -> Option<String> {
8482
}
8583

8684
pub fn map_transaction_state_action(updates: Vec<LedgerUpdate>, action_id: HyperCoreActionId, request_id: String) -> TransactionUpdate {
87-
match ledger_action_hash(&updates, &action_id) {
88-
Some(hash) => confirmed_hash_change(request_id, hash),
89-
None => TransactionUpdate::new_state(TransactionState::Pending),
90-
}
85+
transaction_update_from_hash(ledger_action_hash(&updates, &action_id), request_id)
86+
}
87+
88+
pub fn map_transaction_state_staking_action(updates: Vec<DelegatorHistoryUpdate>, action_id: HyperCoreActionId, request_id: String) -> TransactionUpdate {
89+
transaction_update_from_hash(delegator_history_action_hash(&updates, &action_id), request_id)
9190
}
9291

9392
pub fn ledger_action_hash(updates: &[LedgerUpdate], action_id: &HyperCoreActionId) -> Option<String> {
@@ -99,6 +98,15 @@ pub fn ledger_action_hash(updates: &[LedgerUpdate], action_id: &HyperCoreActionI
9998
.map(|(_, update)| update.hash.clone())
10099
}
101100

101+
fn delegator_history_action_hash(updates: &[DelegatorHistoryUpdate], action_id: &HyperCoreActionId) -> Option<String> {
102+
let nonce = action_id.nonce();
103+
updates
104+
.iter()
105+
.filter_map(|update| delegator_history_match_delta(update, action_id, nonce).map(|delta| (delta, update)))
106+
.min_by_key(|(delta, _)| *delta)
107+
.map(|(_, update)| update.hash.clone())
108+
}
109+
102110
fn ledger_match_delta(update: &LedgerUpdate, action_id: &HyperCoreActionId, nonce: u64) -> Option<u64> {
103111
match &update.delta {
104112
LedgerDelta::Send { nonce: update_nonce } | LedgerDelta::SpotTransfer { nonce: update_nonce } if *update_nonce == nonce => Some(0),
@@ -114,23 +122,44 @@ fn ledger_match_delta(update: &LedgerUpdate, action_id: &HyperCoreActionId, nonc
114122
return None;
115123
}
116124

117-
let Ok(update_wei) = BigNumberFormatter::value_from_amount(amount, HYPERCORE_HYPE.decimals as u32) else {
118-
return None;
119-
};
120-
121-
action_history_time_delta(update.time, nonce).filter(|_| update_wei == wei.to_string())
125+
action_history_time_delta(update.time, nonce).filter(|_| amount_matches_wei(amount, wei))
122126
}
123127
LedgerDelta::Send { .. } | LedgerDelta::SpotTransfer { .. } | LedgerDelta::Other => None,
124128
}
125129
}
126130

131+
fn delegator_history_match_delta(update: &DelegatorHistoryUpdate, action_id: &HyperCoreActionId, nonce: u64) -> Option<u64> {
132+
let matches_action = match (&update.delta, action_id) {
133+
(DelegatorHistoryDelta { c_deposit: Some(delta), .. }, HyperCoreActionId::CDeposit { wei, .. }) => amount_matches_wei(&delta.amount, *wei),
134+
(DelegatorHistoryDelta { delegate: Some(delta), .. }, HyperCoreActionId::TokenDelegate { wei, is_undelegate, .. }) => {
135+
delta.is_undelegate == *is_undelegate && amount_matches_wei(&delta.amount, *wei)
136+
}
137+
(DelegatorHistoryDelta { withdrawal: Some(delta), .. }, HyperCoreActionId::CWithdraw { wei, .. }) => {
138+
delta.phase == DELEGATOR_WITHDRAWAL_INITIATED && amount_matches_wei(&delta.amount, *wei)
139+
}
140+
_ => false,
141+
};
142+
143+
if matches_action { action_history_time_delta(update.time, nonce) } else { None }
144+
}
145+
146+
fn amount_matches_wei(amount: &str, wei: u64) -> bool {
147+
match BigNumberFormatter::value_from_amount(amount, HYPERCORE_HYPE.decimals as u32) {
148+
Ok(update_wei) => update_wei == wei.to_string(),
149+
Err(_) => false,
150+
}
151+
}
152+
127153
fn action_history_time_delta(time: u64, nonce: u64) -> Option<u64> {
128154
let delta = time.checked_sub(nonce)?;
129155
if delta <= ACTION_HISTORY_MATCH_WINDOW_MS { Some(delta) } else { None }
130156
}
131157

132-
fn confirmed_hash_change(request_id: String, hash: String) -> TransactionUpdate {
133-
TransactionUpdate::new(TransactionState::Confirmed, vec![TransactionChange::HashChange { old: request_id, new: hash }])
158+
fn transaction_update_from_hash(hash: Option<String>, request_id: String) -> TransactionUpdate {
159+
match hash {
160+
Some(hash) => TransactionUpdate::new(TransactionState::Confirmed, vec![TransactionChange::HashChange { old: request_id, new: hash }]),
161+
None => TransactionUpdate::new_state(TransactionState::Pending),
162+
}
134163
}
135164

136165
#[cfg(test)]
@@ -246,8 +275,7 @@ mod tests {
246275
#[test]
247276
fn test_map_transaction_state_action_without_matching_nonce_stays_pending() {
248277
let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_action_hash.json")).unwrap();
249-
let action_id = HyperCoreActionId::Nonce(1777960893093);
250-
let update = map_transaction_state_action(updates, action_id, "action:1777960893093".to_string());
278+
let update = map_transaction_state_action(updates, HyperCoreActionId::Nonce(1777960893093), "action:1777960893093".to_string());
251279

252280
assert_eq!(update.state, TransactionState::Pending);
253281
assert!(update.changes.is_empty());
@@ -334,6 +362,59 @@ mod tests {
334362
);
335363
}
336364

365+
#[test]
366+
fn test_map_transaction_state_staking_action_confirms_delegator_history_actions() {
367+
let updates: Vec<DelegatorHistoryUpdate> = serde_json::from_str(include_str!("../../testdata/delegator_history_staking_actions.json")).unwrap();
368+
369+
for (request_id, action_id, expected_hash) in [
370+
(
371+
"action:cDeposit:1000000:1780081714468",
372+
HyperCoreActionId::CDeposit {
373+
wei: 1_000_000,
374+
nonce: 1780081714468,
375+
},
376+
"0x945b910697cd885a95d5043c857c0d0201b300ec32c0a72c38243c5956c16245",
377+
),
378+
(
379+
"action:tokenDelegate:1000000:stake:1780081715280",
380+
HyperCoreActionId::TokenDelegate {
381+
wei: 1_000_000,
382+
is_undelegate: false,
383+
nonce: 1780081715280,
384+
},
385+
"0x0cfde0fb239ef8630e77043c857c1502025000e0be921735b0c68c4de292d24d",
386+
),
387+
(
388+
"action:cWithdraw:3001423:1780078264489",
389+
HyperCoreActionId::CWithdraw {
390+
wei: 3_001_423,
391+
nonce: 1780078264489,
392+
},
393+
"0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da",
394+
),
395+
(
396+
"action:tokenDelegate:3001423:unstake:1780078264488",
397+
HyperCoreActionId::TokenDelegate {
398+
wei: 3_001_423,
399+
is_undelegate: true,
400+
nonce: 1780078264488,
401+
},
402+
"0xc24f99bd90d6d68ac3c9043c84b8c90201c000a32bd9f55c661845104fdab075",
403+
),
404+
] {
405+
assert_eq!(
406+
map_transaction_state_staking_action(updates.clone(), action_id, request_id.to_string()),
407+
TransactionUpdate::new(
408+
TransactionState::Confirmed,
409+
vec![TransactionChange::HashChange {
410+
old: request_id.to_string(),
411+
new: expected_hash.to_string(),
412+
}]
413+
)
414+
);
415+
}
416+
}
417+
337418
#[test]
338419
fn test_map_transaction_state_action_without_matching_window_stays_pending() {
339420
let updates = serde_json::from_str(include_str!("../../testdata/user_non_funding_ledger_updates_c_staking_transfer.json")).unwrap();

crates/gem_hypercore/src/rpc/client.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::models::{
99
referral::Referral,
1010
response::ExplorerTransactionResponse,
1111
spot::{OrderbookResponse, SpotMeta},
12-
user::{AgentSession, LedgerUpdate, UserAbstractionMode, UserFee, UserRole},
12+
user::{AgentSession, DelegatorHistoryUpdate, LedgerUpdate, UserAbstractionMode, UserFee, UserRole},
1313
};
1414
use chain_traits::ChainTraits;
1515
use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType};
@@ -248,6 +248,14 @@ impl<C: Client> HyperCoreClient<C> {
248248
.await
249249
}
250250

251+
pub async fn get_delegator_history(&self, user: &str) -> Result<Vec<DelegatorHistoryUpdate>, Box<dyn Error + Send + Sync>> {
252+
self.info(json!({
253+
"type": "delegatorHistory",
254+
"user": user
255+
}))
256+
.await
257+
}
258+
251259
pub async fn get_open_orders(&self, user: &str) -> Result<Vec<OpenOrder>, Box<dyn Error + Send + Sync>> {
252260
self.info(json!({"type": "frontendOpenOrders", "user": user})).await
253261
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[
2+
{
3+
"time": 1780081714469,
4+
"hash": "0x945b910697cd885a95d5043c857c0d0201b300ec32c0a72c38243c5956c16245",
5+
"delta": {
6+
"cDeposit": {
7+
"amount": "0.01"
8+
}
9+
}
10+
},
11+
{
12+
"time": 1780081715281,
13+
"hash": "0x0cfde0fb239ef8630e77043c857c1502025000e0be921735b0c68c4de292d24d",
14+
"delta": {
15+
"delegate": {
16+
"validator": "0x3e5b2598a32ebf003ad5a7254faa3d04ff41d9fe",
17+
"amount": "0.01",
18+
"isUndelegate": false
19+
}
20+
}
21+
},
22+
{
23+
"time": 1780078270596,
24+
"hash": "0x7b435a1210afafef7cbd043c84b8d402064e00f7aba2cec11f0c0564cfa389da",
25+
"delta": {
26+
"withdrawal": {
27+
"amount": "0.03001423",
28+
"phase": "initiated"
29+
}
30+
}
31+
},
32+
{
33+
"time": 1780078269817,
34+
"hash": "0xc24f99bd90d6d68ac3c9043c84b8c90201c000a32bd9f55c661845104fdab075",
35+
"delta": {
36+
"delegate": {
37+
"validator": "0x000000000056f99d36b6f2e0c51fd41496bbacb8",
38+
"amount": "0.03001423",
39+
"isUndelegate": true
40+
}
41+
}
42+
}
43+
]

0 commit comments

Comments
 (0)