sv1_api/methods/
client_to_server.rs

1use bitcoin_hashes::hex::ToHex;
2use serde_json::{
3    Value,
4    Value::{Array as JArrary, Null, Number as JNumber, String as JString},
5};
6use std::convert::{TryFrom, TryInto};
7
8use crate::{
9    error::Error,
10    json_rpc::{Message, Response, StandardRequest},
11    methods::ParsingMethodError,
12    utils::{Extranonce, HexU32Be},
13};
14
15#[cfg(test)]
16use quickcheck::{Arbitrary, Gen};
17
18#[cfg(test)]
19use quickcheck_macros;
20
21/// _mining.authorize("username", "password")_
22///
23/// The result from an authorize request is usually true (successful), or false.
24/// The password may be omitted if the server does not require passwords.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Authorize {
27    pub id: u64,
28    pub name: String,
29    pub password: String,
30}
31
32impl Authorize {
33    pub fn respond(self, is_ok: bool) -> Response {
34        // infallible
35        let result = serde_json::to_value(is_ok).unwrap();
36        Response {
37            id: self.id,
38            result,
39            error: None,
40        }
41    }
42}
43
44impl From<Authorize> for Message {
45    fn from(auth: Authorize) -> Self {
46        Message::StandardRequest(StandardRequest {
47            id: auth.id,
48            method: "mining.authorize".into(),
49            params: (&[auth.name, auth.password][..]).into(),
50        })
51    }
52}
53
54impl TryFrom<StandardRequest> for Authorize {
55    type Error = ParsingMethodError;
56
57    fn try_from(msg: StandardRequest) -> Result<Self, Self::Error> {
58        match msg.params.as_array() {
59            Some(params) => {
60                let (name, password) = match &params[..] {
61                    [JString(a), JString(b)] => (a.into(), b.into()),
62                    _ => return Err(ParsingMethodError::wrong_args_from_value(msg.params)),
63                };
64                let id = msg.id;
65                Ok(Self { id, name, password })
66            }
67            None => Err(ParsingMethodError::not_array_from_value(msg.params)),
68        }
69    }
70}
71
72#[cfg(test)]
73impl Arbitrary for Authorize {
74    fn arbitrary(g: &mut Gen) -> Self {
75        Authorize {
76            name: String::arbitrary(g),
77            password: String::arbitrary(g),
78            id: u64::arbitrary(g),
79        }
80    }
81}
82
83#[cfg(test)]
84#[quickcheck_macros::quickcheck]
85fn from_to_json_rpc(auth: Authorize) -> bool {
86    let message = Into::<Message>::into(auth.clone());
87    let request = match message {
88        Message::StandardRequest(s) => s,
89        _ => panic!(),
90    };
91    auth == TryInto::<Authorize>::try_into(request).unwrap()
92}
93
94// mining.capabilities (DRAFT) (incompatible with mining.configure)
95
96/// _mining.extranonce.subscribe()_
97/// Indicates to the server that the client supports the mining.set_extranonce method.
98/// https://en.bitcoin.it/wiki/BIP_0310
99#[derive(Debug, Clone, Copy)]
100pub struct ExtranonceSubscribe();
101
102// mining.get_transactions
103
104/// _mining.submit("username", "job id", "ExtraNonce2", "nTime", "nOnce")_
105///
106/// Miners submit shares using the method "mining.submit". Client submissions contain:
107///
108/// * Worker Name.
109/// * Job ID.
110/// * ExtraNonce2.
111/// * nTime.
112/// * nOnce.
113/// * version_bits (used by version-rolling extension)
114///
115/// Server response is result: true for accepted, false for rejected (or you may get an error with
116/// more details).
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Submit<'a> {
119    pub user_name: String,            // root
120    pub job_id: String,               // 6
121    pub extra_nonce2: Extranonce<'a>, // "8a.."
122    pub time: HexU32Be,               //string
123    pub nonce: HexU32Be,
124    pub version_bits: Option<HexU32Be>,
125    pub id: u64,
126}
127//"{"params": ["spotbtc1.m30s40x16", "2", "147a3f0000000000", "6436eddf", "41d5deb0", "00000000"],
128//"{"params": "id": 2196, "method": "mining.submit"}"
129
130impl Submit<'_> {
131    pub fn respond(self, is_ok: bool) -> Response {
132        // infallibel
133        let result = serde_json::to_value(is_ok).unwrap();
134        Response {
135            id: self.id,
136            result,
137            error: None,
138        }
139    }
140}
141
142impl From<Submit<'_>> for Message {
143    fn from(submit: Submit) -> Self {
144        let ex: String = submit.extra_nonce2.0.inner_as_ref().to_hex();
145        let mut params: Vec<Value> = vec![
146            submit.user_name.into(),
147            submit.job_id.into(),
148            ex.into(),
149            submit.time.into(),
150            submit.nonce.into(),
151        ];
152        if let Some(a) = submit.version_bits {
153            let a: String = a.into();
154            params.push(a.into());
155        };
156        Message::StandardRequest(StandardRequest {
157            id: submit.id,
158            method: "mining.submit".into(),
159            params: params.into(),
160        })
161    }
162}
163
164impl TryFrom<StandardRequest> for Submit<'_> {
165    type Error = ParsingMethodError;
166
167    #[allow(clippy::many_single_char_names)]
168    fn try_from(msg: StandardRequest) -> Result<Self, Self::Error> {
169        match msg.params.as_array() {
170            Some(params) => {
171                let (user_name, job_id, extra_nonce2, time, nonce, version_bits) = match &params[..]
172                {
173                    [JString(a), JString(b), JString(c), JNumber(d), JNumber(e), JString(f)] => (
174                        a.into(),
175                        b.into(),
176                        Extranonce::try_from(hex::decode(c)?)?,
177                        HexU32Be(d.as_u64().unwrap() as u32),
178                        HexU32Be(e.as_u64().unwrap() as u32),
179                        Some((f.as_str()).try_into()?),
180                    ),
181                    [JString(a), JString(b), JString(c), JString(d), JString(e), JString(f)] => (
182                        a.into(),
183                        b.into(),
184                        Extranonce::try_from(hex::decode(c)?)?,
185                        (d.as_str()).try_into()?,
186                        (e.as_str()).try_into()?,
187                        Some((f.as_str()).try_into()?),
188                    ),
189                    [JString(a), JString(b), JString(c), JNumber(d), JNumber(e)] => (
190                        a.into(),
191                        b.into(),
192                        Extranonce::try_from(hex::decode(c)?)?,
193                        HexU32Be(d.as_u64().unwrap() as u32),
194                        HexU32Be(e.as_u64().unwrap() as u32),
195                        None,
196                    ),
197                    [JString(a), JString(b), JString(c), JString(d), JString(e)] => (
198                        a.into(),
199                        b.into(),
200                        Extranonce::try_from(hex::decode(c)?)?,
201                        (d.as_str()).try_into()?,
202                        (e.as_str()).try_into()?,
203                        None,
204                    ),
205                    _ => return Err(ParsingMethodError::wrong_args_from_value(msg.params)),
206                };
207                let id = msg.id;
208                let res = crate::client_to_server::Submit {
209                    user_name,
210                    job_id,
211                    extra_nonce2,
212                    time,
213                    nonce,
214                    version_bits,
215                    id,
216                };
217                Ok(res)
218            }
219            None => Err(ParsingMethodError::not_array_from_value(msg.params)),
220        }
221    }
222}
223
224#[cfg(test)]
225impl Arbitrary for Submit<'static> {
226    fn arbitrary(g: &mut Gen) -> Self {
227        let mut extra = Vec::<u8>::arbitrary(g);
228        extra.resize(32, 0);
229        println!("\nEXTRA: {extra:?}\n");
230        let bits = Option::<u32>::arbitrary(g);
231        println!("\nBITS: {bits:?}\n");
232        let extra: Extranonce = extra.try_into().unwrap();
233        let bits = bits.map(HexU32Be);
234        println!("\nBITS: {bits:?}\n");
235        Submit {
236            user_name: String::arbitrary(g),
237            job_id: String::arbitrary(g),
238            extra_nonce2: extra,
239            time: HexU32Be(u32::arbitrary(g)),
240            nonce: HexU32Be(u32::arbitrary(g)),
241            version_bits: bits,
242            id: u64::arbitrary(g),
243        }
244    }
245}
246
247#[cfg(test)]
248#[quickcheck_macros::quickcheck]
249fn submit_from_to_json_rpc(submit: Submit<'static>) -> bool {
250    let message = Into::<Message>::into(submit.clone());
251    println!("\nMESSAGE: {message:?}\n");
252    let request = match message {
253        Message::StandardRequest(s) => s,
254        _ => panic!(),
255    };
256    println!("\nREQUEST: {request:?}\n");
257    submit == TryInto::<Submit>::try_into(request).unwrap()
258}
259
260/// _mining.subscribe("user agent/version", "extranonce1")_
261///
262/// extranonce1 specifies a [mining.notify][a] extranonce1 the client wishes to
263/// resume working with (possibly due to a dropped connection). If provided, a server MAY (at its
264/// option) issue the connection the same extranonce1. Note that the extranonce1 may be the same
265/// (allowing a resumed connection) even if the subscription id is changed!
266///
267/// [a]: crate::methods::server_to_client::Notify
268#[derive(Debug, Clone)]
269pub struct Subscribe<'a> {
270    pub id: u64,
271    pub agent_signature: String,
272    pub extranonce1: Option<Extranonce<'a>>,
273}
274
275impl<'a> Subscribe<'a> {
276    pub fn respond(
277        self,
278        subscriptions: Vec<(String, String)>,
279        extra_nonce1: Extranonce<'a>,
280        extra_nonce2_size: usize,
281    ) -> Response {
282        let response = crate::server_to_client::Subscribe {
283            subscriptions,
284            extra_nonce1,
285            extra_nonce2_size,
286            id: self.id,
287        };
288        match Message::from(response) {
289            Message::OkResponse(r) => r,
290            _ => unreachable!(),
291        }
292    }
293}
294
295impl<'a> TryFrom<Subscribe<'a>> for Message {
296    type Error = Error<'a>;
297
298    fn try_from(subscribe: Subscribe) -> Result<Self, Error> {
299        let params = match (subscribe.agent_signature, subscribe.extranonce1) {
300            (a, Some(b)) => vec![a, b.0.inner_as_ref().to_hex()],
301            (a, None) => vec![a],
302        };
303        Ok(Message::StandardRequest(StandardRequest {
304            id: subscribe.id,
305            method: "mining.subscribe".into(),
306            params: (&params[..]).into(),
307        }))
308    }
309}
310
311impl TryFrom<StandardRequest> for Subscribe<'_> {
312    type Error = ParsingMethodError;
313
314    fn try_from(msg: StandardRequest) -> Result<Self, Self::Error> {
315        match msg.params.as_array() {
316            Some(params) => {
317                let (agent_signature, extranonce1) = match &params[..] {
318                    // bosminer subscribe message
319                    [JString(a), Null, JString(_), Null] => (a.into(), None),
320                    // bosminer subscribe message
321                    [JString(a), Null] => (a.into(), None),
322                    [JString(a), JString(b)] => (a.into(), Some(Extranonce::try_from(b.as_str())?)),
323                    [JString(a)] => (a.into(), None),
324                    [] => ("".to_string(), None),
325                    _ => return Err(ParsingMethodError::wrong_args_from_value(msg.params)),
326                };
327                let id = msg.id;
328                let res = Subscribe {
329                    id,
330                    agent_signature,
331                    extranonce1,
332                };
333                Ok(res)
334            }
335            None => Err(ParsingMethodError::not_array_from_value(msg.params)),
336        }
337    }
338}
339
340#[derive(Debug, Clone)]
341pub struct Configure {
342    extensions: Vec<ConfigureExtension>,
343    id: u64,
344}
345
346impl Configure {
347    pub fn new(id: u64, mask: Option<HexU32Be>, min_bit_count: Option<HexU32Be>) -> Self {
348        let extension = ConfigureExtension::VersionRolling(VersionRollingParams {
349            mask,
350            min_bit_count,
351        });
352        Configure {
353            extensions: vec![extension],
354            id,
355        }
356    }
357
358    pub fn void(id: u64) -> Self {
359        Configure {
360            extensions: vec![],
361            id,
362        }
363    }
364
365    pub fn respond(
366        self,
367        version_rolling: Option<crate::server_to_client::VersionRollingParams>,
368        minimum_difficulty: Option<bool>,
369    ) -> Response {
370        let response = crate::server_to_client::Configure {
371            id: self.id,
372            version_rolling,
373            minimum_difficulty,
374        };
375        match Message::from(response) {
376            Message::OkResponse(r) => r,
377            _ => unreachable!(),
378        }
379    }
380
381    pub fn version_rolling_mask(&self) -> Option<HexU32Be> {
382        let mut res = None;
383        for ext in &self.extensions {
384            if let ConfigureExtension::VersionRolling(p) = ext {
385                res = Some(p.mask.clone().unwrap_or(HexU32Be(0x1FFFE000)));
386            };
387        }
388        res
389    }
390
391    pub fn version_rolling_min_bit_count(&self) -> Option<HexU32Be> {
392        let mut res = None;
393        for ext in &self.extensions {
394            if let ConfigureExtension::VersionRolling(p) = ext {
395                // TODO check if 0 is the right default value
396                res = Some(p.min_bit_count.clone().unwrap_or(HexU32Be(0)));
397            };
398        }
399        res
400    }
401}
402
403impl From<Configure> for Message {
404    fn from(conf: Configure) -> Self {
405        let mut params = serde_json::Map::new();
406        let extension_names: Vec<Value> = conf
407            .extensions
408            .iter()
409            .map(|x| x.get_extension_name())
410            .collect();
411        for parameter in conf.extensions {
412            let mut parameter: serde_json::Map<String, Value> = parameter.into();
413            params.append(&mut parameter);
414        }
415        Message::StandardRequest(StandardRequest {
416            id: conf.id,
417            method: "mining.configure".into(),
418            params: vec![JArrary(extension_names), params.into()].into(),
419        })
420    }
421}
422
423impl TryFrom<StandardRequest> for Configure {
424    type Error = ParsingMethodError;
425
426    fn try_from(msg: StandardRequest) -> Result<Self, Self::Error> {
427        let extensions = ConfigureExtension::from_value(&msg.params)?;
428        let id = msg.id;
429        Ok(Self { extensions, id })
430    }
431}
432
433#[derive(Debug, Clone)]
434pub enum ConfigureExtension {
435    VersionRolling(VersionRollingParams),
436    MinimumDifficulty(u64),
437    SubcribeExtraNonce,
438    Info(InfoParams),
439}
440
441#[allow(clippy::unnecessary_unwrap)]
442impl ConfigureExtension {
443    pub fn from_value(val: &Value) -> Result<Vec<ConfigureExtension>, ParsingMethodError> {
444        let mut res = vec![];
445        let root = val
446            .as_array()
447            .ok_or_else(|| ParsingMethodError::not_array_from_value(val.clone()))?;
448        if root.is_empty() {
449            return Err(ParsingMethodError::Todo);
450        };
451
452        let version_rolling_mask = val.pointer("/1/version-rolling.mask");
453        let version_rolling_min_bit = val.pointer("/1/version-rolling.min-bit-count");
454        let info_connection_url = val.pointer("/1/info.connection-url");
455        let info_hw_version = val.pointer("/1/info.hw-version");
456        let info_sw_version = val.pointer("/1/info.sw-version");
457        let info_hw_id = val.pointer("/1/info.hw-id");
458        let minimum_difficulty_value = val.pointer("/1/minimum-difficulty.value");
459
460        if root[0]
461            .as_array()
462            .ok_or_else(|| ParsingMethodError::not_array_from_value(root[0].clone()))?
463            .contains(&JString("subscribe-extranonce".to_string()))
464        {
465            res.push(ConfigureExtension::SubcribeExtraNonce)
466        }
467        let (mask, min_bit_count) = match (version_rolling_mask, version_rolling_min_bit) {
468            (None, None) => (None, None),
469            // WhatsMiner sent mask without min bit count
470            (Some(JString(mask)), None) => {
471                let mask: HexU32Be = mask.as_str().try_into()?;
472                (Some(mask), None)
473            }
474            // Min bit can be a string cpuminer
475            (Some(JString(mask)), Some(JString(min_bit))) => {
476                let mask: HexU32Be = mask.as_str().try_into()?;
477                let min_bit: HexU32Be = min_bit.as_str().try_into()?;
478                (Some(mask), Some(min_bit))
479            }
480            // Min bit can be a number s9, s19
481            (Some(JString(mask)), Some(JNumber(min_bit))) => {
482                let mask: HexU32Be = mask.as_str().try_into()?;
483                // min_bit is a json number checked above so as_u64 can not fail
484                let min_bit: HexU32Be = HexU32Be(min_bit.as_u64().unwrap() as u32);
485                (Some(mask), Some(min_bit))
486            }
487            // We can not have min bit count without a mask
488            (None, Some(_)) => return Err(ParsingMethodError::Todo),
489            // Mask need to be a JString
490            (Some(_), None) => return Err(ParsingMethodError::Todo),
491            // Min bit need to be a string or a number
492            (Some(_), Some(_)) => return Err(ParsingMethodError::Todo),
493        };
494        if mask.is_some() || min_bit_count.is_some() {
495            let params = VersionRollingParams {
496                mask,
497                min_bit_count,
498            };
499            res.push(ConfigureExtension::VersionRolling(params));
500        }
501
502        if let Some(minimum_difficulty_value) = minimum_difficulty_value {
503            let min_diff = match minimum_difficulty_value {
504                JNumber(a) => a
505                    .as_u64()
506                    .ok_or_else(|| ParsingMethodError::not_unsigned_from_value(a.clone()))?,
507                _ => {
508                    return Err(ParsingMethodError::unexpected_value_from_value(
509                        minimum_difficulty_value.clone(),
510                    ))
511                }
512            };
513
514            res.push(ConfigureExtension::MinimumDifficulty(min_diff));
515        };
516
517        if info_connection_url.is_some()
518            || info_hw_id.is_some()
519            || info_hw_version.is_some()
520            || info_sw_version.is_some()
521        {
522            let connection_url = if info_connection_url.is_some()
523                // infallible
524                && info_connection_url.unwrap().as_str().is_some()
525            {
526                // infallible
527                Some(info_connection_url.unwrap().as_str().unwrap().to_string())
528            } else if info_connection_url.is_some() {
529                return Err(ParsingMethodError::Todo);
530            } else {
531                None
532            };
533            // infallible
534            let hw_id = if info_hw_id.is_some() && info_hw_id.unwrap().as_str().is_some() {
535                // infallible
536                Some(info_hw_id.unwrap().as_str().unwrap().to_string())
537            } else if info_hw_id.is_some() {
538                return Err(ParsingMethodError::Todo);
539            } else {
540                None
541            };
542            // infallible
543            let hw_version =
544                if info_hw_version.is_some() && info_hw_version.unwrap().as_str().is_some() {
545                    // infallible
546                    Some(info_hw_version.unwrap().as_str().unwrap().to_string())
547                } else if info_hw_version.is_some() {
548                    return Err(ParsingMethodError::Todo);
549                } else {
550                    None
551                };
552            let sw_version =
553                // infallible
554                if info_sw_version.is_some() && info_sw_version.unwrap().as_str().is_some() {
555                    // infallible
556                    Some(info_sw_version.unwrap().as_str().unwrap().to_string())
557                } else if info_sw_version.is_some() {
558                    return Err(ParsingMethodError::Todo);
559                } else {
560                    None
561                };
562            let params = InfoParams {
563                connection_url,
564                hw_id,
565                hw_version,
566                sw_version,
567            };
568            res.push(ConfigureExtension::Info(params));
569        };
570        Ok(res)
571    }
572}
573
574impl ConfigureExtension {
575    pub fn get_extension_name(&self) -> Value {
576        match self {
577            ConfigureExtension::VersionRolling(_) => "version-rolling".into(),
578            ConfigureExtension::MinimumDifficulty(_) => "minimum-difficulty".into(),
579            ConfigureExtension::SubcribeExtraNonce => "subscribe-extranonce".into(),
580            ConfigureExtension::Info(_) => "info".into(),
581        }
582    }
583}
584
585impl From<ConfigureExtension> for serde_json::Map<String, Value> {
586    fn from(conf: ConfigureExtension) -> Self {
587        match conf {
588            ConfigureExtension::VersionRolling(a) => a.into(),
589            ConfigureExtension::SubcribeExtraNonce => serde_json::Map::new(),
590            ConfigureExtension::Info(a) => a.into(),
591            ConfigureExtension::MinimumDifficulty(a) => {
592                let mut map = serde_json::Map::new();
593                map.insert("minimum-difficulty".to_string(), a.into());
594                map
595            }
596        }
597    }
598}
599
600#[derive(Debug, Clone)]
601pub struct VersionRollingParams {
602    mask: Option<HexU32Be>,
603    min_bit_count: Option<HexU32Be>,
604}
605
606impl From<VersionRollingParams> for serde_json::Map<String, Value> {
607    fn from(conf: VersionRollingParams) -> Self {
608        let mut params = serde_json::Map::new();
609        match (conf.mask, conf.min_bit_count) {
610            (Some(mask), Some(min)) => {
611                let mask: String = mask.into();
612                let min: String = min.into();
613                params.insert("version-rolling.mask".to_string(), mask.into());
614                params.insert("version-rolling.min-bit-count".to_string(), min.into());
615            }
616            (Some(mask), None) => {
617                let mask: String = mask.into();
618                params.insert("version-rolling.mask".to_string(), mask.into());
619            }
620            (None, Some(min)) => {
621                let min: String = min.into();
622                params.insert("version-rolling.min-bit-count".to_string(), min.into());
623            }
624            (None, None) => (),
625        };
626        params
627    }
628}
629
630#[derive(Debug, Clone)]
631pub struct InfoParams {
632    connection_url: Option<String>,
633    #[allow(dead_code)]
634    hw_id: Option<String>,
635    #[allow(dead_code)]
636    hw_version: Option<String>,
637    #[allow(dead_code)]
638    sw_version: Option<String>,
639}
640
641impl From<InfoParams> for serde_json::Map<String, Value> {
642    fn from(info: InfoParams) -> Self {
643        let mut params = serde_json::Map::new();
644        if info.connection_url.is_some() {
645            params.insert(
646                "info.connection-url".to_string(),
647                // infallible
648                info.connection_url.unwrap().into(),
649            );
650        }
651        params
652    }
653}
654
655// mining.suggest_difficulty
656
657// mining.suggest_target
658
659// mining.minimum_difficulty (extension)
660#[test]
661fn test_version_extension_with_broken_bit_count() {
662    let client_message = r#"{"id":0,
663            "method": "mining.configure",
664            "params":[
665                ["version-rolling"],
666                {"version-rolling.mask":"1fffe000",
667                "version-rolling.min-bit-count":"16"}
668            ]
669        }"#;
670    let client_message: StandardRequest = serde_json::from_str(client_message).unwrap();
671    let server_configure = Configure::try_from(client_message).unwrap();
672    match &server_configure.extensions[0] {
673        ConfigureExtension::VersionRolling(params) => {
674            assert!(params.min_bit_count.as_ref().unwrap().0 == 0x16)
675        }
676        _ => panic!(),
677    };
678}
679#[test]
680fn test_version_extension_with_non_string_bit_count() {
681    let client_message = r#"{"id":0,
682            "method": "mining.configure",
683            "params":[
684                ["version-rolling"],
685                {"version-rolling.mask":"1fffe000",
686                "version-rolling.min-bit-count":16}
687            ]
688        }"#;
689    let client_message: StandardRequest = serde_json::from_str(client_message).unwrap();
690    let server_configure = Configure::try_from(client_message).unwrap();
691    match &server_configure.extensions[0] {
692        ConfigureExtension::VersionRolling(params) => {
693            assert!(params.min_bit_count.as_ref().unwrap().0 == 16)
694        }
695        _ => panic!(),
696    };
697}
698
699#[test]
700fn test_version_extension_with_no_bit_count() {
701    let client_message = r#"{"id":0,
702            "method": "mining.configure",
703            "params":[
704                ["version-rolling"],
705                {"version-rolling.mask":"ffffffff"}
706            ]
707        }"#;
708    let client_message: StandardRequest = serde_json::from_str(client_message).unwrap();
709    let server_configure = Configure::try_from(client_message).unwrap();
710    match &server_configure.extensions[0] {
711        ConfigureExtension::VersionRolling(params) => {
712            assert!(params.min_bit_count.as_ref().is_none());
713        }
714        _ => panic!(),
715    };
716}
717
718#[test]
719fn test_subscribe_with_odd_length_extranonce() {
720    // Test that odd-length hex strings (with leading zeroes) are handled correctly
721    let client_message = r#"{"id":1,
722            "method": "mining.subscribe",
723            "params":["test-agent", "abc"]
724        }"#;
725    let client_message: StandardRequest = serde_json::from_str(client_message).unwrap();
726    let subscribe = Subscribe::try_from(client_message).unwrap();
727
728    // Should successfully parse odd-length hex string by prepending "0"
729    assert_eq!(subscribe.agent_signature, "test-agent");
730    assert!(subscribe.extranonce1.is_some());
731    let extranonce = subscribe.extranonce1.unwrap();
732    assert_eq!(extranonce.0.inner_as_ref(), &[0x0a, 0xbc]); // "0abc" -> [10, 188]
733}
734
735#[test]
736fn test_subscribe_with_even_length_extranonce() {
737    // Test that even-length hex strings work as before
738    let client_message = r#"{"id":1,
739            "method": "mining.subscribe",
740            "params":["test-agent", "abcd"]
741        }"#;
742    let client_message: StandardRequest = serde_json::from_str(client_message).unwrap();
743    let subscribe = Subscribe::try_from(client_message).unwrap();
744
745    assert_eq!(subscribe.agent_signature, "test-agent");
746    assert!(subscribe.extranonce1.is_some());
747    let extranonce = subscribe.extranonce1.unwrap();
748    assert_eq!(extranonce.0.inner_as_ref(), &[0xab, 0xcd]); // "abcd" -> [171, 205]
749}