1use nautilus_core::{python::to_pyvalue_err, string::secret::SecretString};
17use nautilus_model::identifiers::{AccountId, InstrumentId};
18use nautilus_network::websocket::TransportBackend;
19use pyo3::{PyResult, pymethods};
20
21use crate::{
22 common::enums::{PolymarketSignatureType, PolymarketSignerType},
23 config::{
24 PolymarketDataClientConfig, PolymarketExecutionClientConfig,
25 PolymarketInstrumentProviderConfig, PolymarketUpDownEventSlugConfig,
26 },
27 providers::build_gamma_params_from_hashmap,
28};
29
30const PY_OPTION_U64_MISSING_SENTINEL: u64 = u64::MAX;
31
32fn resolve_optional_u64_arg(value: Option<u64>, default: Option<u64>) -> Option<u64> {
33 match value {
34 Some(PY_OPTION_U64_MISSING_SENTINEL) => default,
35 other => other,
36 }
37}
38
39#[pymethods]
40#[pyo3_stub_gen::derive::gen_stub_pymethods]
41impl PolymarketUpDownEventSlugConfig {
42 #[new]
49 #[pyo3(signature = (assets=None, interval_mins=None, periods=None, start_offset_periods=None))]
50 fn py_new(
51 assets: Option<Vec<String>>,
52 interval_mins: Option<u64>,
53 periods: Option<u64>,
54 start_offset_periods: Option<i64>,
55 ) -> Self {
56 let default = Self::default();
57 Self {
58 assets: assets.unwrap_or(default.assets),
59 interval_mins: interval_mins.unwrap_or(default.interval_mins),
60 periods: periods.unwrap_or(default.periods),
61 start_offset_periods: start_offset_periods.unwrap_or(default.start_offset_periods),
62 }
63 }
64
65 fn __repr__(&self) -> String {
66 format!("{self:?}")
67 }
68
69 fn __str__(&self) -> String {
70 format!("{self:?}")
71 }
72}
73
74#[pymethods]
75#[pyo3_stub_gen::derive::gen_stub_pymethods]
76impl PolymarketInstrumentProviderConfig {
77 #[new]
82 #[pyo3(signature = (load_all=None, load_ids=None, filters=None, event_slugs=None, market_slugs=None, event_slug_builder=None, log_warnings=None, use_gamma_markets=None, series_ids=None))]
83 #[expect(clippy::too_many_arguments)]
84 fn py_new(
85 load_all: Option<bool>,
86 load_ids: Option<Vec<InstrumentId>>,
87 filters: Option<std::collections::HashMap<String, String>>,
88 event_slugs: Option<Vec<String>>,
89 market_slugs: Option<Vec<String>>,
90 event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
91 log_warnings: Option<bool>,
92 use_gamma_markets: Option<bool>,
93 series_ids: Option<Vec<u64>>,
94 ) -> PyResult<Self> {
95 let default = Self::default();
96 let config = Self {
97 load_all: load_all.unwrap_or(default.load_all),
98 load_ids,
99 filters,
100 event_slugs,
101 market_slugs,
102 event_slug_builder,
103 series_ids,
104 log_warnings: log_warnings.unwrap_or(default.log_warnings),
105 use_gamma_markets: use_gamma_markets.unwrap_or(default.use_gamma_markets),
106 };
107
108 if let Some(filters) = config.filters.as_ref() {
109 build_gamma_params_from_hashmap(filters)
110 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket Gamma filters: {e}")))?;
111 }
112 Ok(config)
113 }
114
115 fn __repr__(&self) -> String {
116 format!("{self:?}")
117 }
118
119 fn __str__(&self) -> String {
120 format!("{self:?}")
121 }
122}
123
124#[pymethods]
125#[pyo3_stub_gen::derive::gen_stub_pymethods]
126impl PolymarketDataClientConfig {
127 #[new]
133 #[pyo3(signature = (instrument_config=None, base_url_http=None, base_url_ws=None, base_url_gamma=None, base_url_data_api=None, http_timeout_secs=None, ws_timeout_secs=None, ws_max_subscriptions=None, update_instruments_interval_mins=PY_OPTION_U64_MISSING_SENTINEL, subscribe_new_markets=None, auto_load_missing_instruments=None, auto_load_debounce_ms=None, auto_load_max_retries=None, auto_load_retry_delay_initial_secs=None, auto_load_retry_delay_max_secs=None, new_market_fetch_max_concurrency=None, resolve_poll_enabled=None, resolve_poll_interval_secs=None, resolve_poll_grace_secs=None, resolve_poll_max_wait_secs=None, base_url_rtds=None, transport_backend=None, drop_quotes_missing_side=None, proxy_url=None, compute_effective_deltas=None, book_snapshot_timeout_secs=None, book_stale_check_interval_secs=None, book_stale_threshold_secs=None))]
134 #[expect(clippy::too_many_arguments)]
135 fn py_new(
136 instrument_config: Option<PolymarketInstrumentProviderConfig>,
137 base_url_http: Option<String>,
138 base_url_ws: Option<String>,
139 base_url_gamma: Option<String>,
140 base_url_data_api: Option<String>,
141 http_timeout_secs: Option<u64>,
142 ws_timeout_secs: Option<u64>,
143 ws_max_subscriptions: Option<usize>,
144 update_instruments_interval_mins: Option<u64>,
145 subscribe_new_markets: Option<bool>,
146 auto_load_missing_instruments: Option<bool>,
147 auto_load_debounce_ms: Option<u64>,
148 auto_load_max_retries: Option<u32>,
149 auto_load_retry_delay_initial_secs: Option<f64>,
150 auto_load_retry_delay_max_secs: Option<f64>,
151 new_market_fetch_max_concurrency: Option<usize>,
152 resolve_poll_enabled: Option<bool>,
153 resolve_poll_interval_secs: Option<u64>,
154 resolve_poll_grace_secs: Option<u64>,
155 resolve_poll_max_wait_secs: Option<u64>,
156 base_url_rtds: Option<String>,
157 transport_backend: Option<TransportBackend>,
158 drop_quotes_missing_side: Option<bool>,
159 proxy_url: Option<String>,
160 compute_effective_deltas: Option<bool>,
161 book_snapshot_timeout_secs: Option<u64>,
162 book_stale_check_interval_secs: Option<u64>,
163 book_stale_threshold_secs: Option<u64>,
164 ) -> PyResult<Self> {
165 let default = Self::default();
166
167 let config = Self {
168 instrument_config,
169 filters: Vec::new(),
170 base_url_http,
171 base_url_ws,
172 base_url_rtds,
173 base_url_gamma,
174 base_url_data_api,
175 proxy_url: proxy_url.map(SecretString::from),
176 http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
177 ws_timeout_secs: ws_timeout_secs.unwrap_or(default.ws_timeout_secs),
178 ws_max_subscriptions: ws_max_subscriptions.unwrap_or(default.ws_max_subscriptions),
179 update_instruments_interval_mins: resolve_optional_u64_arg(
180 update_instruments_interval_mins,
181 default.update_instruments_interval_mins,
182 ),
183 subscribe_new_markets: subscribe_new_markets.unwrap_or(default.subscribe_new_markets),
184 new_market_filter: None,
185 new_market_fetch_max_concurrency: new_market_fetch_max_concurrency
186 .unwrap_or(default.new_market_fetch_max_concurrency),
187 drop_quotes_missing_side: drop_quotes_missing_side
188 .unwrap_or(default.drop_quotes_missing_side),
189 auto_load_missing_instruments: auto_load_missing_instruments
190 .unwrap_or(default.auto_load_missing_instruments),
191 auto_load_debounce_ms: auto_load_debounce_ms.unwrap_or(default.auto_load_debounce_ms),
192 auto_load_max_retries: auto_load_max_retries.unwrap_or(default.auto_load_max_retries),
193 auto_load_retry_delay_initial_secs: auto_load_retry_delay_initial_secs
194 .unwrap_or(default.auto_load_retry_delay_initial_secs),
195 auto_load_retry_delay_max_secs: auto_load_retry_delay_max_secs
196 .unwrap_or(default.auto_load_retry_delay_max_secs),
197 resolve_poll_enabled: resolve_poll_enabled.unwrap_or(default.resolve_poll_enabled),
198 resolve_poll_interval_secs: resolve_poll_interval_secs
199 .unwrap_or(default.resolve_poll_interval_secs),
200 resolve_poll_grace_secs: resolve_poll_grace_secs
201 .unwrap_or(default.resolve_poll_grace_secs),
202 resolve_poll_max_wait_secs: resolve_poll_max_wait_secs
203 .unwrap_or(default.resolve_poll_max_wait_secs),
204 transport_backend: transport_backend.unwrap_or(default.transport_backend),
205 compute_effective_deltas: compute_effective_deltas
206 .unwrap_or(default.compute_effective_deltas),
207 book_snapshot_timeout_secs: book_snapshot_timeout_secs
208 .unwrap_or(default.book_snapshot_timeout_secs),
209 book_stale_check_interval_secs: book_stale_check_interval_secs
210 .unwrap_or(default.book_stale_check_interval_secs),
211 book_stale_threshold_secs: book_stale_threshold_secs
212 .unwrap_or(default.book_stale_threshold_secs),
213 };
214 config
215 .validated_proxy_url()
216 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
217 Ok(config)
218 }
219
220 #[getter]
221 #[pyo3(name = "has_proxy_url")]
222 const fn py_has_proxy_url(&self) -> bool {
223 self.has_proxy_url()
224 }
225
226 fn __repr__(&self) -> String {
227 format!("{self:?}")
228 }
229
230 fn __str__(&self) -> String {
231 format!("{self:?}")
232 }
233}
234
235#[pymethods]
236#[pyo3_stub_gen::derive::gen_stub_pymethods]
237impl PolymarketExecutionClientConfig {
238 #[new]
240 #[expect(clippy::too_many_arguments)]
241 #[pyo3(signature = (account_id=None, private_key=None, api_key=None, api_secret=None, passphrase=None, funder=None, signature_type=None, base_url_http=None, base_url_ws=None, base_url_data_api=None, http_timeout_secs=None, max_retries=None, retry_delay_initial_ms=None, retry_delay_max_ms=None, heartbeat_enabled=None, transport_backend=None, proxy_url=None, instrument_config=None, signer_type=None))]
242 fn py_new(
243 account_id: Option<String>,
244 private_key: Option<String>,
245 api_key: Option<String>,
246 api_secret: Option<String>,
247 passphrase: Option<String>,
248 funder: Option<String>,
249 signature_type: Option<PolymarketSignatureType>,
250 base_url_http: Option<String>,
251 base_url_ws: Option<String>,
252 base_url_data_api: Option<String>,
253 http_timeout_secs: Option<u64>,
254 max_retries: Option<u32>,
255 retry_delay_initial_ms: Option<u64>,
256 retry_delay_max_ms: Option<u64>,
257 heartbeat_enabled: Option<bool>,
258 transport_backend: Option<TransportBackend>,
259 proxy_url: Option<String>,
260 instrument_config: Option<PolymarketInstrumentProviderConfig>,
261 signer_type: Option<PolymarketSignerType>,
262 ) -> PyResult<Self> {
263 let default = Self::default();
264 let config = Self {
265 account_id: account_id.map_or(default.account_id, |s| AccountId::from(s.as_str())),
266 private_key: private_key.map(SecretString::from),
267 api_key: api_key.map(SecretString::from),
268 api_secret: api_secret.map(SecretString::from),
269 passphrase: passphrase.map(SecretString::from),
270 funder,
271 signature_type: signature_type.unwrap_or(default.signature_type),
272 signer_type: signer_type.unwrap_or_default(),
273 base_url_http,
274 base_url_ws,
275 base_url_data_api,
276 proxy_url: proxy_url.map(SecretString::from),
277 http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
278 max_retries: max_retries.unwrap_or(default.max_retries),
279 retry_delay_initial_ms: retry_delay_initial_ms
280 .unwrap_or(default.retry_delay_initial_ms),
281 retry_delay_max_ms: retry_delay_max_ms.unwrap_or(default.retry_delay_max_ms),
282 heartbeat_enabled: heartbeat_enabled.unwrap_or(default.heartbeat_enabled),
283 transport_backend: transport_backend.unwrap_or(default.transport_backend),
284 instrument_config,
285 };
286
287 config.validate_signer().map_err(to_pyvalue_err)?;
288 config
289 .validated_proxy_url()
290 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
291 Ok(config)
292 }
293
294 #[getter]
295 #[pyo3(name = "has_proxy_url")]
296 const fn py_has_proxy_url(&self) -> bool {
297 self.has_proxy_url()
298 }
299
300 fn __repr__(&self) -> String {
301 format!("{self:?}")
302 }
303
304 fn __str__(&self) -> String {
305 format!("{self:?}")
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use pyo3::{
312 Bound, IntoPyObject, Python,
313 types::{PyAnyMethods, PyDict, PyDictMethods, PyTuple},
314 };
315 use rstest::rstest;
316
317 use super::*;
318
319 fn construct_data_client_config(
320 py: Python<'_>,
321 args: Option<&Bound<'_, PyTuple>>,
322 kwargs: Option<&Bound<'_, PyDict>>,
323 ) -> PolymarketDataClientConfig {
324 let cls = py.get_type::<PolymarketDataClientConfig>();
325
326 let config = match args {
327 Some(args) => cls.call(args, kwargs),
328 None => cls.call((), kwargs),
329 }
330 .expect("construct PolymarketDataClientConfig");
331
332 config
333 .extract::<PolymarketDataClientConfig>()
334 .expect("extract PolymarketDataClientConfig")
335 }
336
337 #[rstest]
338 fn direct_pyo3_constructor_preserves_none_update_interval() {
339 Python::initialize();
340 Python::attach(|py| {
341 let kwargs = PyDict::new(py);
342 kwargs
343 .set_item("update_instruments_interval_mins", py.None())
344 .unwrap();
345
346 let config = construct_data_client_config(py, None, Some(&kwargs));
347
348 assert_eq!(config.update_instruments_interval_mins, None);
349 });
350 }
351
352 #[rstest]
353 fn direct_pyo3_constructor_uses_default_update_interval_when_omitted() {
354 Python::initialize();
355 Python::attach(|py| {
356 let config = construct_data_client_config(py, None, None);
357
358 assert_eq!(
359 config.update_instruments_interval_mins,
360 PolymarketDataClientConfig::default().update_instruments_interval_mins,
361 );
362 assert!(config.drop_quotes_missing_side);
363 });
364 }
365
366 #[rstest]
367 fn direct_pyo3_constructor_preserves_none_update_interval_for_positional_args() {
368 Python::initialize();
369 Python::attach(|py| {
370 let args = PyTuple::new(
371 py,
372 [
373 py.None(),
374 py.None(),
375 py.None(),
376 py.None(),
377 py.None(),
378 py.None(),
379 py.None(),
380 py.None(),
381 py.None(),
382 py.None(),
383 ],
384 )
385 .expect("args");
386
387 let config = construct_data_client_config(py, Some(&args), None);
388
389 assert_eq!(config.update_instruments_interval_mins, None);
390 });
391 }
392
393 #[rstest]
394 fn direct_pyo3_constructor_sets_new_market_fetch_max_concurrency() {
395 Python::initialize();
396 Python::attach(|py| {
397 let kwargs = PyDict::new(py);
398 kwargs
399 .set_item("new_market_fetch_max_concurrency", 23)
400 .unwrap();
401
402 let config = construct_data_client_config(py, None, Some(&kwargs));
403
404 assert_eq!(config.new_market_fetch_max_concurrency, 23);
405 });
406 }
407
408 #[rstest]
409 fn direct_pyo3_constructor_sets_drop_quotes_missing_side() {
410 Python::initialize();
411 Python::attach(|py| {
412 let kwargs = PyDict::new(py);
413 kwargs.set_item("drop_quotes_missing_side", false).unwrap();
414
415 let config = construct_data_client_config(py, None, Some(&kwargs));
416
417 assert!(!config.drop_quotes_missing_side);
418 });
419 }
420
421 #[rstest]
422 fn direct_pyo3_constructor_sets_compute_effective_deltas() {
423 Python::initialize();
424 Python::attach(|py| {
425 let kwargs = PyDict::new(py);
426 kwargs.set_item("compute_effective_deltas", true).unwrap();
427
428 let config = construct_data_client_config(py, None, Some(&kwargs));
429
430 assert!(config.compute_effective_deltas);
431 });
432 }
433
434 #[rstest]
435 fn direct_pyo3_constructor_sets_base_url_rtds() {
436 Python::initialize();
437 Python::attach(|py| {
438 let kwargs = PyDict::new(py);
439 kwargs
440 .set_item("base_url_rtds", "wss://ws-live-data.example")
441 .unwrap();
442
443 let config = construct_data_client_config(py, None, Some(&kwargs));
444
445 assert_eq!(
446 config.base_url_rtds.as_deref(),
447 Some("wss://ws-live-data.example")
448 );
449 });
450 }
451
452 #[rstest]
453 fn direct_pyo3_constructor_preserves_existing_positional_order() {
454 Python::initialize();
455 Python::attach(|py| {
456 let args = PyTuple::new(
457 py,
458 [
459 py.None(),
460 "https://http.example"
461 .into_pyobject(py)
462 .unwrap()
463 .into_any()
464 .unbind(),
465 "wss://ws.example"
466 .into_pyobject(py)
467 .unwrap()
468 .into_any()
469 .unbind(),
470 "https://gamma.example"
471 .into_pyobject(py)
472 .unwrap()
473 .into_any()
474 .unbind(),
475 "https://data.example"
476 .into_pyobject(py)
477 .unwrap()
478 .into_any()
479 .unbind(),
480 41_u64.into_pyobject(py).unwrap().into_any().unbind(),
481 42_u64.into_pyobject(py).unwrap().into_any().unbind(),
482 512_usize.into_pyobject(py).unwrap().into_any().unbind(),
483 ],
484 )
485 .expect("args");
486
487 let config = construct_data_client_config(py, Some(&args), None);
488
489 assert_eq!(
490 config.base_url_http.as_deref(),
491 Some("https://http.example")
492 );
493 assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
494 assert_eq!(
495 config.base_url_gamma.as_deref(),
496 Some("https://gamma.example")
497 );
498 assert_eq!(
499 config.base_url_data_api.as_deref(),
500 Some("https://data.example")
501 );
502 assert_eq!(config.base_url_rtds, None);
503 assert_eq!(config.http_timeout_secs, 41);
504 assert_eq!(config.ws_timeout_secs, 42);
505 assert_eq!(config.ws_max_subscriptions, 512);
506 });
507 }
508
509 #[rstest]
510 fn direct_pyo3_constructor_preserves_base_url_rtds_positional_slot() {
511 Python::initialize();
512 Python::attach(|py| {
513 let args = PyTuple::new(
514 py,
515 [
516 py.None(),
517 py.None(),
518 py.None(),
519 py.None(),
520 py.None(),
521 py.None(),
522 py.None(),
523 py.None(),
524 py.None(),
525 py.None(),
526 py.None(),
527 py.None(),
528 py.None(),
529 py.None(),
530 py.None(),
531 py.None(),
532 py.None(),
533 py.None(),
534 py.None(),
535 py.None(),
536 "wss://ws-live-data.example"
537 .into_pyobject(py)
538 .unwrap()
539 .into_any()
540 .unbind(),
541 ],
542 )
543 .expect("args");
544
545 let config = construct_data_client_config(py, Some(&args), None);
546
547 assert_eq!(
548 config.base_url_rtds.as_deref(),
549 Some("wss://ws-live-data.example")
550 );
551 });
552 }
553
554 #[rstest]
555 fn direct_pyo3_data_config_propagates_proxy_without_raw_getter() {
556 const SECRET: &str = "data-python-proxy-secret";
557 Python::initialize();
558 Python::attach(|py| {
559 let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18083");
560 let kwargs = PyDict::new(py);
561 kwargs.set_item("proxy_url", &proxy_url).unwrap();
562 let cls = py.get_type::<PolymarketDataClientConfig>();
563 let obj = cls.call((), Some(&kwargs)).expect("construct data config");
564 let has_proxy_url = obj
565 .getattr("has_proxy_url")
566 .expect("has_proxy_url getter")
567 .extract::<bool>()
568 .expect("bool getter");
569 let repr = obj.repr().expect("data config repr").to_string();
570 let config = obj
571 .extract::<PolymarketDataClientConfig>()
572 .expect("extract data config");
573
574 assert_eq!(
575 config.proxy_url.as_ref().map(SecretString::expose_secret),
576 Some(proxy_url.as_str()),
577 );
578 assert!(has_proxy_url);
579 assert!(!obj.hasattr("proxy_url").unwrap());
580 assert!(!repr.contains(SECRET));
581 });
582 }
583
584 #[rstest]
585 fn direct_pyo3_exec_config_propagates_proxy_without_raw_getter() {
586 const SECRET: &str = "exec-python-proxy-secret";
587 Python::initialize();
588 Python::attach(|py| {
589 let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18084");
590 let kwargs = PyDict::new(py);
591 kwargs.set_item("proxy_url", &proxy_url).unwrap();
592 kwargs.set_item("heartbeat_enabled", true).unwrap();
593 let cls = py.get_type::<PolymarketExecutionClientConfig>();
594 let obj = cls
595 .call((), Some(&kwargs))
596 .expect("construct execution config");
597 let has_proxy_url = obj
598 .getattr("has_proxy_url")
599 .expect("has_proxy_url getter")
600 .extract::<bool>()
601 .expect("bool getter");
602 let repr = obj.repr().expect("execution config repr").to_string();
603 let heartbeat_enabled = obj
604 .getattr("heartbeat_enabled")
605 .expect("heartbeat_enabled getter")
606 .extract::<bool>()
607 .expect("bool getter");
608 let config = obj
609 .extract::<PolymarketExecutionClientConfig>()
610 .expect("extract execution config");
611
612 assert_eq!(
613 config.proxy_url.as_ref().map(SecretString::expose_secret),
614 Some(proxy_url.as_str()),
615 );
616 assert!(config.heartbeat_enabled);
617 assert!(has_proxy_url);
618 assert!(heartbeat_enabled);
619 assert!(!obj.hasattr("proxy_url").unwrap());
620 assert!(!repr.contains(SECRET));
621 });
622 }
623
624 #[rstest]
625 fn direct_pyo3_exec_config_wires_instrument_config_load_ids() {
626 Python::initialize();
627 Python::attach(|py| {
628 let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
629 let provider_kwargs = PyDict::new(py);
630 provider_kwargs.set_item("load_ids", vec![scoped]).unwrap();
631 let provider = py
632 .get_type::<PolymarketInstrumentProviderConfig>()
633 .call((), Some(&provider_kwargs))
634 .expect("construct provider config");
635 let kwargs = PyDict::new(py);
636 kwargs.set_item("instrument_config", &provider).unwrap();
637 let obj = py
638 .get_type::<PolymarketExecutionClientConfig>()
639 .call((), Some(&kwargs))
640 .expect("construct execution config");
641 let exposed = obj
642 .getattr("instrument_config")
643 .expect("instrument_config getter")
644 .extract::<PolymarketInstrumentProviderConfig>()
645 .expect("extract provider config");
646 let config = obj
647 .extract::<PolymarketExecutionClientConfig>()
648 .expect("extract execution config");
649
650 assert_eq!(exposed.load_ids.as_deref(), Some([scoped].as_slice()));
651 assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
652 });
653 }
654
655 #[rstest]
656 fn direct_pyo3_proxy_validation_error_redacts_credentials() {
657 const SECRET: &str = "invalid-python-proxy-secret";
658 Python::initialize();
659 Python::attach(|py| {
660 let kwargs = PyDict::new(py);
661 kwargs
662 .set_item("proxy_url", format!("http://proxy-user:{SECRET}@[::1"))
663 .unwrap();
664 let error = py
665 .get_type::<PolymarketDataClientConfig>()
666 .call((), Some(&kwargs))
667 .expect_err("malformed proxy URL should fail");
668 let message = error.to_string();
669
670 assert!(message.contains("Invalid Polymarket proxy URL"));
671 assert!(!message.contains(SECRET));
672 });
673 }
674}