diff --git a/Cargo.lock b/Cargo.lock index 26a3032..0b45d9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2976,6 +2976,7 @@ dependencies = [ "dirs", "polymarket-client-sdk", "predicates", + "reqwest 0.13.2", "rust_decimal", "rust_decimal_macros", "rustyline", diff --git a/Cargo.toml b/Cargo.toml index a01bf05..667c59d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] } tabled = "0.17" rust_decimal = "1" anyhow = "1" +reqwest = { version = "0.13", features = ["socks"] } chrono = "0.4" dirs = "6" rustyline = "15" @@ -32,6 +33,9 @@ assert_cmd = "2" predicates = "3" rust_decimal_macros = "1" +[patch.crates-io] +polymarket-client-sdk = { path = "polymarket-client-sdk" } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/polymarket-client-sdk/.github/CODEOWNERS b/polymarket-client-sdk/.github/CODEOWNERS new file mode 100644 index 0000000..9afd6d1 --- /dev/null +++ b/polymarket-client-sdk/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# All PRs on any file must be reviewed by one of the following team members +# Wildcard (*) for all files +* @Polymarket/eng-platform diff --git a/polymarket-client-sdk/.github/CODE_OF_CONDUCT.md b/polymarket-client-sdk/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a872e16 --- /dev/null +++ b/polymarket-client-sdk/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socioeconomic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when +an individual is representing the project or its community in public spaces. +Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [`engineering@polymarket.com`](mailto:engineering@polymarket.com). All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/polymarket-client-sdk/.github/CONTRIBUTING.md b/polymarket-client-sdk/.github/CONTRIBUTING.md new file mode 100644 index 0000000..38034cf --- /dev/null +++ b/polymarket-client-sdk/.github/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contribution Guide + +All contributions to the `rs-clob-client` are welcome and greatly appreciated! This document serves to outline the process +for contributions and help you get set up. + +## Steps to get started + +1. Fork `Polymarket/rs-clob-client` +1. Clone your fork +1. Install [pre-commit](https://pre-commit.com/#intro) +1. Open pull requests with the [wip](https://github.com/Polymarket/rs-clob-client/issues/labels?q=label%3Awip) label + against the `main` branch and include a description of the intended change in the PR description. + +Before removing the `wip` label and submitting a PR for review, make sure that: + +- It passes all checks, including lints and tests +- Your fork is up to date with `main` + +## Branch structure & naming + +Our main branch, `main`, represents the current development state of the codebase. All pull requests should be opened +against `main`. + +Please follow the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) standard when +naming your PR. diff --git a/polymarket-client-sdk/.github/dependabot.yaml b/polymarket-client-sdk/.github/dependabot.yaml new file mode 100644 index 0000000..37072f7 --- /dev/null +++ b/polymarket-client-sdk/.github/dependabot.yaml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(cargo)" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(gha)" + open-pull-requests-limit: 10 diff --git a/polymarket-client-sdk/.github/workflows/ci.yml b/polymarket-client-sdk/.github/workflows/ci.yml new file mode 100644 index 0000000..7d912e4 --- /dev/null +++ b/polymarket-client-sdk/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + runs-on: ${{ matrix.os }}-latest + strategy: + matrix: + os: [ macos, windows ] + + steps: + - uses: actions/checkout@v6 + - run: cargo build --all-targets --all-features + - run: cargo test + + fmt-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + components: clippy + toolchain: '1.88' + + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + components: rustfmt + toolchain: 'nightly-2025-11-24' + + - name: Rustfmt (nightly) + # Run nightly formatting to allow group imports + run: cargo +nightly-2025-11-24 fmt --all -- --check + + - name: Clippy (All features) + run: cargo +1.88 clippy --all-targets --all-features -- -D warnings + + - name: Clippy + run: cargo +1.88 clippy --all-targets -- -D warnings + + cargo-sort: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install cargo-sort + uses: baptiste0928/cargo-install@v3 + with: + crate: 'cargo-sort' + version: 'v2.0.2' + + - name: Check Cargo.toml sorting + run: cargo sort --check + + test-coverage: + name: build-test (ubuntu) with coverage + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + toolchain: '1.88' + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: baptiste0928/cargo-install@v3 + with: + crate: cargo-llvm-cov + + - name: Test with coverage + run: | + cargo llvm-cov clean --workspace + cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + if: ${{ github.repository_owner == 'Polymarket' }} + with: + files: lcov.info + flags: rust + name: rust-llvm-cov + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/polymarket-client-sdk/.github/workflows/conventional-title.yml b/polymarket-client-sdk/.github/workflows/conventional-title.yml new file mode 100644 index 0000000..cae4fb6 --- /dev/null +++ b/polymarket-client-sdk/.github/workflows/conventional-title.yml @@ -0,0 +1,19 @@ +name: PR Conventional Commit Validation + +permissions: + contents: read + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: PR Conventional Commit Validation + uses: ytanikin/pr-conventional-commits@1.5.1 + with: + # https://www.conventionalcommits.org/en/v1.0.0/#specification + task_types: '["feat","fix","build","style","docs","test","ci","refactor","perf","chore","revert"]' + add_label: 'false' diff --git a/polymarket-client-sdk/.github/workflows/release-plz.yml b/polymarket-client-sdk/.github/workflows/release-plz.yml new file mode 100644 index 0000000..d381508 --- /dev/null +++ b/polymarket-client-sdk/.github/workflows/release-plz.yml @@ -0,0 +1,32 @@ +name: Release-plz + +permissions: + pull-requests: write + contents: write + id-token: write # Required for trusted publishing + +on: + push: + branches: + - main + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Polymarket' }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 + with: + toolchain: '1.88' + + - name: Run release-plz + uses: MarcoIeni/release-plz-action@e592230ad39e3ec735402572601fc621aa24355c # v0.5.124 + env: + GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} diff --git a/polymarket-client-sdk/.gitignore b/polymarket-client-sdk/.gitignore new file mode 100644 index 0000000..d5d279e --- /dev/null +++ b/polymarket-client-sdk/.gitignore @@ -0,0 +1,131 @@ +.DS_Store +.env + +# ------- ------- +# https://github.com/github/gitignore/blob/main/Rust.gitignore + +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ +# ------- ------- + +# ------- ------- +# https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# Since we use pre-commit we should ignore python enviroment +.venv/ +lib/ +include/ +bin/ +pyvenv.cfg + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ +.idea/sonarlint.xml # see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119 + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based HTTP Client +.idea/httpRequests +http-client.private.env.json + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# Apifox Helper cache +.idea/.cache/.Apifox_Helper +.idea/ApifoxUploaderProjectSetting.xml + +# Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215 +.idea/**/copilot.data.migration.*.xml +# ------- ------- + +# ------- ------- +# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +!*.code-workspace + +# Built Visual Studio Code Extensions +*.vsix +# ------- ------- diff --git a/polymarket-client-sdk/.pre-commit-config.yaml b/polymarket-client-sdk/.pre-commit-config.yaml new file mode 100644 index 0000000..bbf3e41 --- /dev/null +++ b/polymarket-client-sdk/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/DevinR528/cargo-sort + rev: v2.0.2 + hooks: + - id: cargo-sort + - repo: local + hooks: + - id: fmt + name: fmt + entry: cargo +nightly fmt --all -- --check + language: system + types: [ rust ] + pass_filenames: false + - id: clippy + name: clippy + entry: cargo clippy --workspace --all-features --all-targets -- -D warnings + language: system + types: [ rust ] + pass_filenames: false diff --git a/polymarket-client-sdk/CHANGELOG.md b/polymarket-client-sdk/CHANGELOG.md new file mode 100644 index 0000000..bc34f8b --- /dev/null +++ b/polymarket-client-sdk/CHANGELOG.md @@ -0,0 +1,259 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.4.2](https://github.com/Polymarket/rs-clob-client/compare/v0.4.1...v0.4.2) - 2026-01-31 + +### Added + +- *(clob)* add status to ws OrderMessage ([#219](https://github.com/Polymarket/rs-clob-client/pull/219)) +- add Serialize for MarketResponse and SimplifiedMarketResponse ([#217](https://github.com/Polymarket/rs-clob-client/pull/217)) +- expose API credentials ([#213](https://github.com/Polymarket/rs-clob-client/pull/213)) +- add dedicated types for trades function ([#203](https://github.com/Polymarket/rs-clob-client/pull/203)) +- *(rtds)* add unsubscribe support with reference counting ([#192](https://github.com/Polymarket/rs-clob-client/pull/192)) +- *(Bridge)* add status endpoint ([#198](https://github.com/Polymarket/rs-clob-client/pull/198)) +- *(ws)* add TickSizeChange typed stream + unsubscribe ([#195](https://github.com/Polymarket/rs-clob-client/pull/195)) + +### Fixed + +- *(clob)* serialize PriceHistoryRequest market as decimal token_id ([#224](https://github.com/Polymarket/rs-clob-client/pull/224)) +- MarketResolved event ([#212](https://github.com/Polymarket/rs-clob-client/pull/212)) +- *(ws)* tolerant batch parsing and forward-compatible message types ([#200](https://github.com/Polymarket/rs-clob-client/pull/200)) +- *(clob)* propagate non-HTTP errors in create_or_derive_api_key ([#193](https://github.com/Polymarket/rs-clob-client/pull/193)) +- *(ws)* add alias for matchtime field deserialization ([#196](https://github.com/Polymarket/rs-clob-client/pull/196)) + +### Other + +- *(cargo)* bump alloy from 1.4.3 to 1.5.2 ([#222](https://github.com/Polymarket/rs-clob-client/pull/222)) +- *(cargo)* bump uuid from 1.19.0 to 1.20.0 ([#221](https://github.com/Polymarket/rs-clob-client/pull/221)) +- *(gha)* bump MarcoIeni/release-plz-action from 0.5.121 to 0.5.124 ([#220](https://github.com/Polymarket/rs-clob-client/pull/220)) +- *(cargo)* bump rust_decimal_macros from 1.39.0 to 1.40.0 ([#208](https://github.com/Polymarket/rs-clob-client/pull/208)) +- *(cargo)* bump rust_decimal from 1.39.0 to 1.40.0 ([#206](https://github.com/Polymarket/rs-clob-client/pull/206)) +- *(cargo)* bump chrono from 0.4.42 to 0.4.43 ([#209](https://github.com/Polymarket/rs-clob-client/pull/209)) +- *(cargo)* bump aws-sdk-kms from 1.97.0 to 1.98.0 ([#207](https://github.com/Polymarket/rs-clob-client/pull/207)) +- *(cargo)* bump alloy from 1.4.0 to 1.4.3 ([#205](https://github.com/Polymarket/rs-clob-client/pull/205)) +- *(gha)* bump MarcoIeni/release-plz-action from 0.5.120 to 0.5.121 ([#204](https://github.com/Polymarket/rs-clob-client/pull/204)) +- *(ws)* use `rustls` instead of `native-tls` ([#194](https://github.com/Polymarket/rs-clob-client/pull/194)) + +## [0.4.1](https://github.com/Polymarket/rs-clob-client/compare/v0.4.0...v0.4.1) - 2026-01-14 + +### Added + +- *(clob)* add last_trade_price field to OrderBookSummaryResponse ([#174](https://github.com/Polymarket/rs-clob-client/pull/174)) + +### Fixed + +- *(ws)* prevent TOCTOU race in subscription unsubscribe ([#190](https://github.com/Polymarket/rs-clob-client/pull/190)) +- *(rtds)* prevent race condition in subscription check ([#191](https://github.com/Polymarket/rs-clob-client/pull/191)) +- *(ws)* preserve custom_feature_enabled flag on reconnect ([#186](https://github.com/Polymarket/rs-clob-client/pull/186)) +- *(clob)* usage of ampersand before and without question mark ([#189](https://github.com/Polymarket/rs-clob-client/pull/189)) +- *(data)* make Activity.condition_id optional ([#173](https://github.com/Polymarket/rs-clob-client/pull/173)) + +### Other + +- *(ws)* eliminate double JSON parsing in parse_if_interested ([#182](https://github.com/Polymarket/rs-clob-client/pull/182)) +- *(clob/ws)* use channel map for laziness instead of once_cell ([#183](https://github.com/Polymarket/rs-clob-client/pull/183)) +- *(cargo)* add release profile optimizations ([#180](https://github.com/Polymarket/rs-clob-client/pull/180)) +- *(clob)* optimize SignedOrder serialization ([#181](https://github.com/Polymarket/rs-clob-client/pull/181)) +- *(cargo)* bump alloy from 1.3.0 to 1.4.0 ([#178](https://github.com/Polymarket/rs-clob-client/pull/178)) +- *(cargo)* bump bon from 3.8.1 to 3.8.2 ([#177](https://github.com/Polymarket/rs-clob-client/pull/177)) +- *(cargo)* bump serde_json from 1.0.148 to 1.0.149 ([#179](https://github.com/Polymarket/rs-clob-client/pull/179)) +- *(cargo)* bump url from 2.5.7 to 2.5.8 ([#176](https://github.com/Polymarket/rs-clob-client/pull/176)) +- *(examples)* update WebSocket examples to use tracing ([#170](https://github.com/Polymarket/rs-clob-client/pull/170)) +- *(examples)* update RFQ examples to use tracing ([#169](https://github.com/Polymarket/rs-clob-client/pull/169)) +- *(examples)* update CLOB examples to use tracing ([#168](https://github.com/Polymarket/rs-clob-client/pull/168)) + +## [0.4.0](https://github.com/Polymarket/rs-clob-client/compare/v0.3.3...v0.4.0) - 2026-01-12 + +### Added + +- *(clob)* add cache setter methods to prewarm market data ([#153](https://github.com/Polymarket/rs-clob-client/pull/153)) +- *(bridge)* improve bridge type safety ([#151](https://github.com/Polymarket/rs-clob-client/pull/151)) +- *(gamma)* convert neg_risk_market_id and neg_risk_request_id to B256 ([#143](https://github.com/Polymarket/rs-clob-client/pull/143)) +- *(gamma)* convert question_id fields to B256 type ([#142](https://github.com/Polymarket/rs-clob-client/pull/142)) +- *(clob)* clob typed b256 address ([#139](https://github.com/Polymarket/rs-clob-client/pull/139)) +- *(clob)* add clob feature flag for optional CLOB compilation ([#135](https://github.com/Polymarket/rs-clob-client/pull/135)) +- *(tracing)* add serde_path_to_error for detailed deserialization on errors ([#140](https://github.com/Polymarket/rs-clob-client/pull/140)) +- *(data)* use typed Address and B256 for hex string fields, update data example ([#132](https://github.com/Polymarket/rs-clob-client/pull/132)) +- *(gamma)* use typed Address and B256 for hex string fields ([#126](https://github.com/Polymarket/rs-clob-client/pull/126)) +- *(ctf)* add CTF client/operations ([#82](https://github.com/Polymarket/rs-clob-client/pull/82)) +- add Unknown(String) variant to all enums for forward compatibility ([#124](https://github.com/Polymarket/rs-clob-client/pull/124)) +- add subscribe_last_trade_price websocket method ([#121](https://github.com/Polymarket/rs-clob-client/pull/121)) +- support post-only orders ([#115](https://github.com/Polymarket/rs-clob-client/pull/115)) +- *(heartbeats)* [**breaking**] add heartbeats ([#113](https://github.com/Polymarket/rs-clob-client/pull/113)) + +### Fixed + +- *(rfq)* url path fixes ([#162](https://github.com/Polymarket/rs-clob-client/pull/162)) +- *(gamma)* use repeated query params for array fields ([#148](https://github.com/Polymarket/rs-clob-client/pull/148)) +- *(rtds)* serialize Chainlink filters as JSON string ([#136](https://github.com/Polymarket/rs-clob-client/pull/136)) ([#137](https://github.com/Polymarket/rs-clob-client/pull/137)) +- add missing makerRebatesFeeShareBps field to Market struct ([#130](https://github.com/Polymarket/rs-clob-client/pull/130)) +- add MakerRebate enum option to ActivityType ([#127](https://github.com/Polymarket/rs-clob-client/pull/127)) +- suppress unused variable warnings in tracing cfg blocks ([#125](https://github.com/Polymarket/rs-clob-client/pull/125)) +- add Yield enum option to ActivityType ([#122](https://github.com/Polymarket/rs-clob-client/pull/122)) + +### Other + +- *(rtds)* [**breaking**] well-type RTDS structs ([#167](https://github.com/Polymarket/rs-clob-client/pull/167)) +- *(gamma)* [**breaking**] well-type structs ([#166](https://github.com/Polymarket/rs-clob-client/pull/166)) +- *(clob/rfq)* well-type structs ([#163](https://github.com/Polymarket/rs-clob-client/pull/163)) +- *(data)* well-type data types ([#159](https://github.com/Polymarket/rs-clob-client/pull/159)) +- *(gamma,rtds)* add Builder to non_exhaustive structs ([#160](https://github.com/Polymarket/rs-clob-client/pull/160)) +- *(ctf)* add Builder to non_exhaustive response structs ([#161](https://github.com/Polymarket/rs-clob-client/pull/161)) +- *(ws)* [**breaking**] well-type ws structs ([#156](https://github.com/Polymarket/rs-clob-client/pull/156)) +- add benchmarks for CLOB and WebSocket types/operations ([#155](https://github.com/Polymarket/rs-clob-client/pull/155)) +- *(clob)* [**breaking**] well-type requests/responses with U256 ([#150](https://github.com/Polymarket/rs-clob-client/pull/150)) +- update rustdocs ([#134](https://github.com/Polymarket/rs-clob-client/pull/134)) +- *(ws)* extract WsError to shared ws module ([#131](https://github.com/Polymarket/rs-clob-client/pull/131)) +- update license ([#128](https://github.com/Polymarket/rs-clob-client/pull/128)) +- update builder method doc comment ([#129](https://github.com/Polymarket/rs-clob-client/pull/129)) + +## [0.3.3](https://github.com/Polymarket/rs-clob-client/compare/v0.3.2...v0.3.3) - 2026-01-06 + +### Added + +- *(auth)* auto derive funder address ([#99](https://github.com/Polymarket/rs-clob-client/pull/99)) +- *(rfq)* add standalone RFQ API client ([#76](https://github.com/Polymarket/rs-clob-client/pull/76)) +- *(types)* re-export commonly used external types for API ergonomics ([#102](https://github.com/Polymarket/rs-clob-client/pull/102)) + +### Fixed + +- add missing cumulativeMarkets field to Event struct ([#108](https://github.com/Polymarket/rs-clob-client/pull/108)) + +### Other + +- *(cargo)* bump reqwest from 0.12.28 to 0.13.1 ([#103](https://github.com/Polymarket/rs-clob-client/pull/103)) +- *(ws)* common connection for clob ws and rtds ([#97](https://github.com/Polymarket/rs-clob-client/pull/97)) +- *(cargo)* bump tokio from 1.48.0 to 1.49.0 ([#104](https://github.com/Polymarket/rs-clob-client/pull/104)) +- *(examples)* improve approvals example with tracing ([#101](https://github.com/Polymarket/rs-clob-client/pull/101)) +- *(examples)* improve bridge example with tracing ([#100](https://github.com/Polymarket/rs-clob-client/pull/100)) +- *(examples)* improve rtds example with tracing and dynamic IDs ([#94](https://github.com/Polymarket/rs-clob-client/pull/94)) +- *(examples)* improve gamma example with tracing and dynamic IDs ([#93](https://github.com/Polymarket/rs-clob-client/pull/93)) + +## [0.3.2](https://github.com/Polymarket/rs-clob-client/compare/v0.3.1...v0.3.2) - 2026-01-04 + +### Added + +- add unknown field warnings for API responses ([#47](https://github.com/Polymarket/rs-clob-client/pull/47)) +- *(ws)* add custom feature message types and subscription support ([#79](https://github.com/Polymarket/rs-clob-client/pull/79)) + +### Fixed + +- *(ws)* defer WebSocket connection until first subscription ([#90](https://github.com/Polymarket/rs-clob-client/pull/90)) +- *(types)* improve type handling and API compatibility ([#92](https://github.com/Polymarket/rs-clob-client/pull/92)) +- add serde aliases for API response field variants ([#88](https://github.com/Polymarket/rs-clob-client/pull/88)) +- *(data)* add missing fields to Position and Holder types ([#85](https://github.com/Polymarket/rs-clob-client/pull/85)) +- *(gamma)* add missing fields to response types ([#87](https://github.com/Polymarket/rs-clob-client/pull/87)) +- *(deser_warn)* show full JSON values in unknown field warnings ([#86](https://github.com/Polymarket/rs-clob-client/pull/86)) +- handle order_type field in OpenOrderResponse ([#81](https://github.com/Polymarket/rs-clob-client/pull/81)) + +### Other + +- update README with new features and examples ([#80](https://github.com/Polymarket/rs-clob-client/pull/80)) + +## [0.3.1](https://github.com/Polymarket/rs-clob-client/compare/v0.3.0...v0.3.1) - 2025-12-31 + +### Added + +- *(ws)* add unsubscribe support with reference counting ([#70](https://github.com/Polymarket/rs-clob-client/pull/70)) +- *(auth)* add secret and passphrase accessors to Credentials ([#78](https://github.com/Polymarket/rs-clob-client/pull/78)) +- add RTDS (Real-Time Data Socket) client ([#56](https://github.com/Polymarket/rs-clob-client/pull/56)) + +### Fixed + +- *(clob)* align API implementation with OpenAPI spec ([#72](https://github.com/Polymarket/rs-clob-client/pull/72)) + +### Other + +- *(auth)* migrate from sec to secrecy crate ([#75](https://github.com/Polymarket/rs-clob-client/pull/75)) +- use re-exported types ([#74](https://github.com/Polymarket/rs-clob-client/pull/74)) + +## [0.3.0](https://github.com/Polymarket/rs-clob-client/compare/v0.2.1...v0.3.0) - 2025-12-31 + +### Added + +- *(auth)* add key() getter to Credentials ([#69](https://github.com/Polymarket/rs-clob-client/pull/69)) +- add geographic restrictions check ([#63](https://github.com/Polymarket/rs-clob-client/pull/63)) +- add bridge API client ([#55](https://github.com/Polymarket/rs-clob-client/pull/55)) + +### Fixed + +- *(gamma)* use repeated query params for clob_token_ids ([#65](https://github.com/Polymarket/rs-clob-client/pull/65)) +- correct data example required-features name ([#68](https://github.com/Polymarket/rs-clob-client/pull/68)) +- *(clob)* allow market orders to supply price ([#67](https://github.com/Polymarket/rs-clob-client/pull/67)) +- add CTF Exchange approval to approvals example ([#45](https://github.com/Polymarket/rs-clob-client/pull/45)) + +### Other + +- [**breaking**] ws types ([#52](https://github.com/Polymarket/rs-clob-client/pull/52)) +- consolidate request and query params ([#64](https://github.com/Polymarket/rs-clob-client/pull/64)) +- [**breaking**] rescope data types and rename feature ([#62](https://github.com/Polymarket/rs-clob-client/pull/62)) +- [**breaking**] rescope gamma types ([#61](https://github.com/Polymarket/rs-clob-client/pull/61)) +- [**breaking**] scope clob types into request/response ([#60](https://github.com/Polymarket/rs-clob-client/pull/60)) +- [**breaking**] WS cleanup ([#58](https://github.com/Polymarket/rs-clob-client/pull/58)) +- [**breaking**] minor cleanup ([#57](https://github.com/Polymarket/rs-clob-client/pull/57)) + +## [0.2.1](https://github.com/Polymarket/rs-clob-client/compare/v0.2.0...v0.2.1) - 2025-12-29 + +### Added + +- complete gamma client ([#40](https://github.com/Polymarket/rs-clob-client/pull/40)) +- add data-api client ([#39](https://github.com/Polymarket/rs-clob-client/pull/39)) + +### Fixed + +- use TryFrom for TickSize to avoid panic on unknown values ([#43](https://github.com/Polymarket/rs-clob-client/pull/43)) + +### Other + +- *(cargo)* bump tracing from 0.1.41 to 0.1.44 ([#49](https://github.com/Polymarket/rs-clob-client/pull/49)) +- *(cargo)* bump serde_json from 1.0.146 to 1.0.148 ([#51](https://github.com/Polymarket/rs-clob-client/pull/51)) +- *(cargo)* bump alloy from 1.1.3 to 1.2.1 ([#50](https://github.com/Polymarket/rs-clob-client/pull/50)) +- *(cargo)* bump reqwest from 0.12.27 to 0.12.28 ([#48](https://github.com/Polymarket/rs-clob-client/pull/48)) + +## [0.2.0](https://github.com/Polymarket/rs-clob-client/compare/v0.1.2...v0.2.0) - 2025-12-27 + +### Added + +- WebSocket client for real-time market and user data ([#26](https://github.com/Polymarket/rs-clob-client/pull/26)) + +### Other + +- [**breaking**] change from `derive_builder` to `bon` ([#41](https://github.com/Polymarket/rs-clob-client/pull/41)) + +## [0.1.2](https://github.com/Polymarket/rs-clob-client/compare/v0.1.1...v0.1.2) - 2025-12-23 + +### Added + +- add optional tracing instrumentation ([#38](https://github.com/Polymarket/rs-clob-client/pull/38)) +- add gamma client ([#31](https://github.com/Polymarket/rs-clob-client/pull/31)) +- support share-denominated market orders ([#29](https://github.com/Polymarket/rs-clob-client/pull/29)) + +### Fixed + +- mask salt for limit orders ([#30](https://github.com/Polymarket/rs-clob-client/pull/30)) +- mask salt to 53 bits ([#27](https://github.com/Polymarket/rs-clob-client/pull/27)) + +### Other + +- rescope clients with gamma feature ([#37](https://github.com/Polymarket/rs-clob-client/pull/37)) +- Replacing `status: String` to enum ([#36](https://github.com/Polymarket/rs-clob-client/pull/36)) +- *(cargo)* bump serde_json from 1.0.145 to 1.0.146 ([#34](https://github.com/Polymarket/rs-clob-client/pull/34)) +- *(cargo)* bump reqwest from 0.12.26 to 0.12.27 ([#33](https://github.com/Polymarket/rs-clob-client/pull/33)) +- *(gha)* bump dtolnay/rust-toolchain from 0b1efabc08b657293548b77fb76cc02d26091c7e to f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 ([#32](https://github.com/Polymarket/rs-clob-client/pull/32)) + +## [0.1.1](https://github.com/Polymarket/rs-clob-client/compare/v0.1.0...v0.1.1) - 2025-12-17 + +### Fixed + +- remove signer from Authenticated ([#22](https://github.com/Polymarket/rs-clob-client/pull/22)) + +### Other + +- enable release-plz ([#23](https://github.com/Polymarket/rs-clob-client/pull/23)) +- add crates.io badge ([#20](https://github.com/Polymarket/rs-clob-client/pull/20)) diff --git a/polymarket-client-sdk/Cargo.lock b/polymarket-client-sdk/Cargo.lock new file mode 100644 index 0000000..9eb62b4 --- /dev/null +++ b/polymarket-client-sdk/Cargo.lock @@ -0,0 +1,5981 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5033b86af2c64e1b29b8446b7b6c48a0094ccea0b5c408111b6d218c418894" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-serde", + "alloy-signer", + "alloy-signer-aws", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc32535569185cbcb6ad5fa64d989a47bccb9a08e27284b1f2a3ccf16e6d010" +dependencies = [ + "alloy-primitives", + "num_enum", + "strum", +] + +[[package]] +name = "alloy-consensus" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1958f0294ecc05ebe7b3c9a8662a3e221c2523b7f2bcd94c7a651efbd510bf" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.5", + "secp256k1", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f752e99497ddc39e22d547d7dfe516af10c979405a034ed90e69b914b7dddeae" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2140796bc79150b1b7375daeab99750f0ff5e27b1f8b0aa81ccde229c7f02a2" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-core" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca96214615ec8cf3fa2a54b32f486eb49100ca7fe7eb0b8c1137cd316e7250a" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdff496dd4e98a81f4861e66f7eaf5f2488971848bb42d9c892f871730245c8" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3231de68d5d6e75332b7489cfcc7f4dfabeba94d990a10e4b923af0e6623540" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eips" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813a67f87e56b38554d18b182616ee5006e8e2bf9df96a0df8bf29dff1d52e3f" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-json-abi" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5513d5e6bd1cba6bdcf5373470f559f320c05c8c59493b6e98912fbe6733943f" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2dd146b3de349a6ffaa4e4e319ab3a90371fb159fb0bddeb1c7bbe8b1792eff" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http 1.4.0", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c12278ffbb8872dfba3b2f17d8ea5e8503c2df5155d9bc5ee342794bde505c3" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833037c04917bc2031541a60e8249e4ab5500e24c637c1c62e95e963a655d66f" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355bf68a433e0fd7f7d33d5a9fc2583fde70bf5c530f63b80845f8da5505cf28" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "foldhash", + "hashbrown 0.16.1", + "indexmap 2.12.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.2", + "ruint", + "rustc-hash", + "serde", + "sha3", + "tiny-keccak", +] + +[[package]] +name = "alloy-provider" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eafa840b0afe01c889a3012bb2fde770a544f74eab2e2870303eb0a5fb869c48" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru", + "parking_lot", + "pin-project", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f70d83b765fdc080dbcd4f4db70d8d23fe4761f2f02ebfa9146b833900634b4" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12768ae6303ec764905a8a7cd472aea9072f9f9c980d18151e26913da8ae0123" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1cf5a093e437dfd62df48e480f24e1a3807632358aad6816d7a52875f1c04aa" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e97603095020543a019ab133e0e3dc38cd0819f19f19bdd70c642404a54751" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.13.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-serde" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "946a0d413dbb5cd9adba0de5f8a1a34d5b77deda9b69c1d7feed8fc875a1aa26" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7481dc8316768f042495eaf305d450c32defbc9bce09d8bf28afcd956895bb" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-signer-aws" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139b98be3eb3176f42f3bffafc4ef997b65b5b4c157ebc3013617054c0ef6964" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "aws-config", + "aws-sdk-kms", + "k256", + "spki", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "alloy-signer-local" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1259dac1f534a4c66c1d65237c89915d0010a2a91d6c3b0bada24dc5ee0fb917" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.5", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ce480400051b5217f19d6e9a82d9010cdde20f1ae9c00d53591e4a1afbb312" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d792e205ed3b72f795a8044c52877d2e6b6e9b1d13f431478121d8d4eaa9028" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.12.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.110", + "syn-solidity", + "tiny-keccak", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd1247a8f90b465ef3f1207627547ec16940c35597875cdc09c49d58b19693c" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.110", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "954d1b2533b9b2c7959652df3076954ecb1122a28cc740aa84e7b0a49f6ac0a9" +dependencies = [ + "serde", + "winnow", +] + +[[package]] +name = "alloy-sol-types" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70319350969a3af119da6fb3e9bddb1bce66c9ea933600cb297c8b1850ad2a3c" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f169b85eb9334871db986e7eaf59c58a03d86a30cc68b846573d47ed0656bb" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019821102e70603e2c141954418255bec539ef64ac4117f8e84fb493769acf73" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "reqwest 0.12.28", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428aa0f0e0658ff091f8f667c406e034b431cb10abd39de4f507520968acc499" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arrayvec", + "derive_more", + "nybbles", + "serde", + "smallvec", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45ceac797eb8a56bdf5ab1fab353072c17d472eab87645ca847afe720db3246d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-kms" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c74fef3d08159467cad98300f33a2e3bd1a985d527ad66ab0ea83c95e3a615" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.91.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.12", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1fcbefc7ece1d70dcce29e490f269695dfca2d2bacdeaf9e5c3f799e4e6a42" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efce7aaaf59ad53c5412f14fc19b2d5c6ab2c3ec688d272fd31f76ec12f44fb0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65f172bcb02424eb94425db8aed1b6d583b5104d4d5ddddf22402c661a320048" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version 0.4.1", + "tracing", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.16", + "instant", + "rand 0.8.5", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin-io" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "bon" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.110", +] + +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "c-kzg" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e00bf4b112b07b505472dbefd19e37e53307e2bfed5a79e0cc161d58ccd0e687" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "criterion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.110", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.5", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "511f510e9b1888d67f10bab4397f8b019d2a9b249a2c10acbce2d705b1b32e26" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-trait", + "base64", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "keccak-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "nybbles" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4b5ecbd0beec843101bffe848217f770e8b8da81d8355b7d6e226f2199b3dc" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polymarket-client-sdk" +version = "0.4.2" +dependencies = [ + "alloy", + "anyhow", + "async-stream", + "async-trait", + "aws-config", + "aws-sdk-kms", + "backoff", + "base64", + "bitflags", + "bon", + "chrono", + "criterion", + "dashmap", + "futures", + "futures-util", + "hmac", + "httpmock", + "phf", + "rand 0.9.2", + "reqwest 0.13.1", + "rust_decimal", + "rust_decimal_macros", + "secrecy", + "serde", + "serde_html_form", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "serde_repr", + "serde_with", + "sha2", + "strum_macros", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.110", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.35", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.35", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.5", + "rand 0.9.2", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.27", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.8", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_html_form" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0946d52b4b7e28823148aebbeceb901012c595ad737920d504fa8634bb099e6f" +dependencies = [ + "form_urlencoded", + "indexmap 2.12.1", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff790eb176cc81bb8936aed0f7b9f14fc4670069a2d371b3e3b0ecce908b2cb3" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.35", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zmij" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d" diff --git a/polymarket-client-sdk/Cargo.toml b/polymarket-client-sdk/Cargo.toml new file mode 100644 index 0000000..3eba44c --- /dev/null +++ b/polymarket-client-sdk/Cargo.toml @@ -0,0 +1,277 @@ +[package] +name = "polymarket-client-sdk" +description = "Polymarket CLOB (Central Limit Order Book) API client SDK" +version = "0.4.2" +authors = [ + "Polymarket Engineering ", + "Chaz Byrnes ", +] +readme = "README.md" +repository = "https://github.com/polymarket/rs-clob-client" +license = "MIT" +keywords = ["polymarket", "clob", "orderbook", "trading", "prediction-market"] +categories = [ + "api-bindings", + "cryptography::cryptocurrencies", + "finance", + "web-programming::http-client", +] +edition = "2024" +rust-version = "1.88.0" # MSRV + +[package.metadata.docs.rs] +all-features = true + +[features] +default = [] +clob = [] +data = [] +gamma = [] +bridge = [] +ctf = ["alloy/contract", "alloy/providers"] +rfq = [] +tracing = ["dep:tracing", "dep:serde_ignored", "dep:serde_path_to_error"] +ws = ["dep:backoff", "dep:bitflags", "dep:tokio", "dep:tokio-tungstenite"] +rtds = ["dep:backoff", "dep:tokio", "dep:tokio-tungstenite"] +heartbeats = ["dep:tokio", "dep:tokio-util"] + +[dependencies] +alloy = { version = "1.5.2", default-features = false, features = [ + "dyn-abi", + "reqwest", + "reqwest-rustls-tls", + "serde", + "signer-local", + "signers", + "sol-types" +] } +async-stream = "0.3.6" +async-trait = "0.1.89" +backoff = { version = "0.4.0", optional = true } +base64 = "0.22.1" +bitflags = { version = "2.10.0", optional = true } +bon = "3.8.2" +chrono = { version = "0.4.43", features = ["serde"] } +dashmap = "6.1.0" +futures = "0.3.31" +hmac = "0.12.1" +phf = { version = "0.13.1", features = ["macros"] } +rand = "0.9.2" +reqwest = { version = "0.13.1", features = ["json", "query", "rustls"] } +rust_decimal = { version = "1.40.0", features = ["serde"] } +rust_decimal_macros = "1.40.0" +secrecy = { version = "0.10", features = ["serde"] } +serde = "1.0.228" +serde_html_form = { version = "0.4" } +serde_ignored = { version = "0.1", optional = true } +serde_json = "1.0.149" +serde_path_to_error = { version = "0.1", optional = true } +serde_repr = "0.1.20" +serde_with = { version = "3.16.1", features = ["chrono_0_4", "json"] } +sha2 = "0.10.9" +strum_macros = "0.27.2" +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"], optional = true } +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"], optional = true } +tokio-util = { version = "0.7.18", optional = true } +tracing = { version = "0.1", optional = true } +url = "2.5.8" +uuid = { version = "1.20.0", features = ["serde", "v4", "v7"] } + +[dev-dependencies] +alloy = { version = "1.5.2", default-features = false, features = [ + "contract", + "providers", + "reqwest", + "signer-aws", + "signer-local", +] } +anyhow = "1.0.100" +aws-config = "1.8.12" +aws-sdk-kms = "1.98.0" +criterion = { version = "0.8.1", features = ["html_reports"] } +futures-util = "0.3.31" +httpmock = "0.8.2" +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[[example]] +name = "async" +path = "examples/clob/async.rs" +required-features = ["clob"] + +[[example]] +name = "authenticated" +path = "examples/clob/authenticated.rs" +required-features = ["clob"] + +[[example]] +name = "aws_authenticated" +path = "examples/clob/aws_authenticated.rs" +required-features = ["clob"] + +[[example]] +name = "builder_authenticated" +path = "examples/clob/builder_authenticated.rs" +required-features = ["clob"] + +[[example]] +name = "heartbeats" +path = "examples/clob/heartbeats.rs" +required-features = ["clob", "heartbeats", "tracing"] + +[[example]] +name = "streaming" +path = "examples/clob/streaming.rs" +required-features = ["clob", "tracing"] + +[[example]] +name = "unauthenticated" +path = "examples/clob/unauthenticated.rs" +required-features = ["clob", "tracing"] + +[[example]] +name = "approvals" +path = "examples/approvals.rs" +required-features = ["tracing"] + +[[example]] +name = "check_approvals" +path = "examples/check_approvals.rs" +required-features = ["tracing"] + +[[example]] +name = "ctf" +path = "examples/ctf.rs" +required-features = ["ctf", "tracing"] + +[[example]] +name = "data" +path = "examples/data.rs" +required-features = ["data", "tracing"] + +[[example]] +name = "gamma" +path = "examples/gamma/client.rs" +required-features = ["gamma", "tracing"] + +[[example]] +name = "gamma_streaming" +path = "examples/gamma/streaming.rs" +required-features = ["gamma", "tracing"] + +[[example]] +name = "bridge" +path = "examples/bridge.rs" +required-features = ["bridge", "tracing"] + +[[example]] +name = "websocket_orderbook" +path = "examples/clob/ws/orderbook.rs" +required-features = ["clob", "ws"] + +[[example]] +name = "websocket_user" +path = "examples/clob/ws/user.rs" +required-features = ["clob", "ws"] + +[[example]] +name = "rfq_quotes" +path = "examples/clob/rfq/quotes.rs" +required-features = ["clob", "rfq"] + +[[example]] +name = "rfq_requests" +path = "examples/clob/rfq/requests.rs" +required-features = ["clob", "rfq"] + +[[example]] +name = "rtds_crypto_prices" +path = "examples/rtds_crypto_prices.rs" +required-features = ["rtds", "tracing"] + +[[example]] +name = "websocket_unsubscribe" +path = "examples/clob/ws/unsubscribe.rs" +required-features = ["clob", "ws"] + +[[bench]] +name = "deserialize_clob" +harness = false +required-features = ["clob"] + +[[bench]] +name = "deserialize_websocket" +harness = false +required-features = ["clob", "ws"] + +[[bench]] +name = "clob_order_operations" +harness = false +required-features = ["clob"] + +# https://rust-lang.github.io/rust-clippy/master/index.html?versions=lte%3A88 +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } + +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +create_dir = "warn" +dbg_macro = "warn" +doc_include_without_cfg = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +exhaustive_enums = "warn" +exhaustive_structs = "warn" +exit = "warn" +filetype_is_file = "warn" +float_arithmetic = "warn" +get_unwrap = "warn" +if_then_some_else_none = "warn" +impl_trait_in_params = "warn" +infinite_loop = "warn" +large_include_file = "warn" +let_underscore_untyped = "warn" +map_err_ignore = "warn" +map_with_unused_argument_over_ranges = "warn" +missing_assert_message = "warn" +missing_errors_doc = "allow" +module_name_repetitions = "warn" +multiple_crate_versions = "allow" +multiple_inherent_impl = "warn" +mutex_atomic = "warn" +mutex_integer = "warn" +needless_raw_strings = "warn" +non_zero_suggestions = "warn" +pathbuf_init_then_push = "warn" +print_stderr = "warn" +print_stdout = "warn" +pub_without_shorthand = "warn" +rc_buffer = "warn" +redundant_test_prefix = "warn" +redundant_type_annotations = "warn" +ref_patterns = "warn" +renamed_function_params = "warn" +rest_pat_in_fully_bound_structs = "warn" +return_and_then = "warn" +same_name_method = "warn" +self_named_module_files = "warn" +similar_names = "allow" +single_char_lifetime_names = "warn" +str_to_string = "warn" +string_add = "warn" +string_slice = "warn" +todo = "warn" +too_many_lines = "allow" +try_err = "warn" +undocumented_unsafe_blocks = "warn" +unneeded_field_pattern = "warn" +unseparated_literal_suffix = "warn" +unused_trait_names = "warn" +unwrap_used = "warn" + +[profile.bench] +lto = "thin" # Link-Time Optimization: enables cross-crate inlining +codegen-units = 1 # Single codegen unit allows more aggressive optimizations diff --git a/polymarket-client-sdk/LICENSE b/polymarket-client-sdk/LICENSE new file mode 100644 index 0000000..7d13730 --- /dev/null +++ b/polymarket-client-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Polymarket + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/polymarket-client-sdk/README.md b/polymarket-client-sdk/README.md new file mode 100644 index 0000000..0a87291 --- /dev/null +++ b/polymarket-client-sdk/README.md @@ -0,0 +1,523 @@ +![Polymarket](assets/logo.png) + +# Polymarket Rust Client + +[![Crates.io](https://img.shields.io/crates/v/polymarket-client-sdk.svg)](https://crates.io/crates/polymarket-client-sdk) +[![CI](https://github.com/Polymarket/rs-clob-client/actions/workflows/ci.yml/badge.svg)](https://github.com/Polymarket/rs-clob-client/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/Polymarket/rs-clob-client/graph/badge.svg?token=FW1BYWWFJ2)](https://codecov.io/gh/Polymarket/rs-clob-client) + +An ergonomic Rust client for interacting with Polymarket services, primarily the Central Limit Order Book (CLOB). +This crate provides strongly typed request builders, authenticated endpoints, `alloy` support and more. + +## Table of Contents + +- [Overview](#overview) +- [Getting Started](#getting-started) +- [Feature Flags](#feature-flags) +- [Re-exported Types](#re-exported-types) +- [Examples](#examples) + - [CLOB Client](#clob-client) + - [WebSocket Streaming](#websocket-streaming) + - [Optional APIs](#optional-apis) +- [Additional CLOB Capabilities](#additional-clob-capabilities) +- [Setting Token Allowances](#token-allowances) +- [Minimum Supported Rust Version (MSRV)](#minimum-supported-rust-version-msrv) +- [Contributing](#contributing) +- [About Polymarket](#about-polymarket) + +## Overview + +- **Typed CLOB requests** (orders, trades, markets, balances, and more) +- **Dual authentication flows** + - Normal authenticated flow + - [Builder](https://docs.polymarket.com/developers/builders/builder-intro) authentication flow +- **Type-level state machine** + - Prevents using authenticated endpoints before authenticating + - Compile-time enforcement of correct transitions +- **Signer support** via `alloy::signers::Signer` + - Including remote signers, e.g. AWS KMS +- **Zero-cost abstractions** — no dynamic dispatch in hot paths +- **Order builders** for easy construction & signing +- **Full `serde` support** +- **Async-first design** with `reqwest` + + +## Getting started + +Add the crate to your `Cargo.toml`: + +```toml +[dependencies] +polymarket-client-sdk = "0.3" +``` + +or + +```bash +cargo add polymarket-client-sdk +``` + +Then run any of the examples +```bash +cargo run --example unauthenticated +``` + +## Feature Flags + +The crate is modular with optional features for different Polymarket APIs: + +| Feature | Description | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| `clob` | Core CLOB client for order placement, market data, and authentication | +| `tracing` | Structured logging via [`tracing`](https://docs.rs/tracing) for HTTP requests, auth flows, and caching | +| `ws` | WebSocket client for real-time orderbook, price, and user event streaming | +| `rtds` | Real-time data streams for crypto prices (Binance, Chainlink) and comments | +| `data` | Data API client for positions, trades, leaderboards, and analytics | +| `gamma` | Gamma API client for market/event discovery, search, and metadata | +| `bridge` | Bridge API client for cross-chain deposits (EVM, Solana, Bitcoin) | +| `rfq` | RFQ API (within CLOB) for submitting and querying quotes | +| `heartbeats` | Clob feature that automatically sends heartbeat messages to the Polymarket server, if the client disconnects all open orders will be cancelled | +| `ctf` | CTF API client to perform split/merge/redeem on binary and neg risk markets + +Enable features in your `Cargo.toml`: + +```toml +[dependencies] +polymarket-client-sdk = { version = "0.3", features = ["ws", "data"] } +``` + +## Re-exported Types + +This SDK re-exports commonly used types from external crates so you don't need to add them to your `Cargo.toml`: + +### From `types` module + +```rust +use polymarket_client_sdk::types::{ + Address, ChainId, Signature, address, // from alloy::primitives + DateTime, NaiveDate, Utc, // from chrono + Decimal, dec, // from rust_decimal + rust_decimal_macros +}; +``` + +### From `auth` module + +```rust +use polymarket_client_sdk::auth::{ + LocalSigner, Signer, // from alloy::signers (LocalSigner + trait) + Uuid, ApiKey, // from uuid (ApiKey = Uuid) + SecretString, ExposeSecret, // from secrecy + builder::Url, // from url (for remote builder config) +}; +``` + +### From `error` module + +```rust +use polymarket_client_sdk::error::{ + StatusCode, Method, // from reqwest (for error inspection) +}; +``` + +This allows you to work with the SDK without managing version compatibility for these common dependencies. + +## Examples + +See `examples/` for the complete set. Below are hand-picked examples for common use cases. + +### CLOB Client + +#### Unauthenticated client (read-only) +```rust,ignore +use polymarket_client_sdk::clob::Client; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = Client::default(); + + let ok = client.ok().await?; + println!("Ok: {ok}"); + + Ok(()) +} +``` + +#### Authenticated client + +Set `POLYMARKET_PRIVATE_KEY` as an environment variable with your private key. + +##### [EOA](https://www.binance.com/en/academy/glossary/externally-owned-account-eoa) wallets +If using MetaMask or hardware wallet, you must first set token allowances. See [Token Allowances](#token-allowances) section below. + +```rust,ignore +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use polymarket_client_sdk::clob::{Client, Config}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let ok = client.ok().await?; + println!("Ok: {ok}"); + + let api_keys = client.api_keys().await?; + println!("API keys: {api_keys:?}"); + + Ok(()) +} +``` + +##### Proxy/Safe wallets +For proxy/Safe wallets, the funder address is **automatically derived** using CREATE2 from your signer's EOA address: + +```rust,ignore +let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .signature_type(SignatureType::GnosisSafe) // Funder auto-derived via CREATE2 + .authenticate() + .await?; +``` + +The SDK computes the deterministic wallet address that Polymarket deploys for your EOA. This is the same address +shown on polymarket.com when you log in with a browser wallet. + +If you need to override the derived address (e.g., for advanced use cases), you can explicitly provide it: + +```rust,ignore +let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .funder(address!("")) + .signature_type(SignatureType::GnosisSafe) + .authenticate() + .await?; +``` + +You can also derive these addresses manually: + +```rust,ignore +use polymarket_client_sdk::{derive_safe_wallet, derive_proxy_wallet, POLYGON}; + +// For browser wallet users (GnosisSafe) +let safe_address = derive_safe_wallet(signer.address(), POLYGON); + +// For Magic/email wallet users (Proxy) +let proxy_address = derive_proxy_wallet(signer.address(), POLYGON); +``` + +##### Funder Address +The **funder address** is the actual address that holds your funds on Polymarket. When using proxy wallets (email wallets +like Magic or browser extension wallets), the signing key differs from the address holding the funds. The SDK automatically +derives the correct funder address using CREATE2 when you specify `SignatureType::Proxy` or `SignatureType::GnosisSafe`. +You can override this with `.funder(address)` if needed. + +##### Signature Types +The **signature_type** parameter tells the system how to verify your signatures: +- `signature_type=0` (default): Standard EOA (Externally Owned Account) signatures - includes MetaMask, hardware wallets, + and any wallet where you control the private key directly +- `signature_type=1`: Email/Magic wallet signatures (delegated signing) +- `signature_type=2`: Browser wallet proxy signatures (when using a proxy contract, not direct wallet connections) + +See [SignatureType](src/clob/types/mod.rs#L182) for more information. + +##### Place a market order + +```rust,ignore +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::clob::types::{Amount, OrderType, Side}; +use polymarket_client_sdk::types::Decimal; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let order = client + .market_order() + .token_id("") + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + let signed_order = client.sign(&signer, order).await?; + let response = client.post_order(signed_order).await?; + println!("Order response: {:?}", response); + + Ok(()) +} +``` + +##### Place a limit order + +```rust,ignore +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::clob::types::Side; +use polymarket_client_sdk::types::Decimal; +use rust_decimal_macros::dec; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let order = client + .limit_order() + .token_id("") + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .side(Side::Buy) + .build() + .await?; + let signed_order = client.sign(&signer, order).await?; + let response = client.post_order(signed_order).await?; + println!("Order response: {:?}", response); + + Ok(()) +} +``` + +#### Builder-authenticated client + +For institutional/third-party app integrations with remote signing: +```rust,ignore +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::auth::builder::Config as BuilderConfig; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::clob::types::SignatureType; +use polymarket_client_sdk::clob::types::request::TradesRequest; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + let builder_config = BuilderConfig::remote("http://localhost:3000/sign", None)?; // Or your signing server + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .signature_type(SignatureType::Proxy) // Funder auto-derived via CREATE2 + .authenticate() + .await?; + + let client = client.promote_to_builder(builder_config).await?; + + let ok = client.ok().await?; + println!("Ok: {ok}"); + + let api_keys = client.api_keys().await?; + println!("API keys: {api_keys:?}"); + + let builder_trades = client.builder_trades(&TradesRequest::default(), None).await?; + println!("Builder trades: {builder_trades:?}"); + + Ok(()) +} +``` + +### WebSocket Streaming + +Real-time orderbook and user event streaming. Requires the `ws` feature. + +```toml +polymarket-client-sdk = { version = "0.3", features = ["ws"] } +``` + +```rust,ignore +use futures::StreamExt as _; +use polymarket_client_sdk::clob::ws::Client; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = Client::default(); + + // Subscribe to orderbook updates for specific assets + let asset_ids = vec!["".to_owned()]; + let stream = client.subscribe_orderbook(asset_ids)?; + let mut stream = Box::pin(stream); + + while let Some(book_result) = stream.next().await { + let book = book_result?; + println!("Orderbook update for {}: {} bids, {} asks", + book.asset_id, book.bids.len(), book.asks.len()); + } + Ok(()) +} +``` + +Available streams: +- `subscribe_orderbook()` - Bid/ask levels for assets +- `subscribe_prices()` - Price change events +- `subscribe_midpoints()` - Calculated midpoint prices +- `subscribe_orders()` - User order updates (authenticated) +- `subscribe_trades()` - User trade executions (authenticated) + +See [`examples/clob/ws/`](examples/clob/ws/) for more WebSocket examples including authenticated user streams. + +### Optional APIs + +#### Data API +Trading analytics, positions, and leaderboards. Requires the `data` feature. + +```rust,ignore +use polymarket_client_sdk::data::Client; +use polymarket_client_sdk::data::types::request::PositionsRequest; +use polymarket_client_sdk::types::address; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = Client::default(); + let user = address!("0x0000000000000000000000000000000000000000"); // Your address + + let request = PositionsRequest::builder().user(user).limit(10)?.build(); + let positions = client.positions(&request).await?; + println!("Open positions: {:?}", positions); + Ok(()) +} +``` + +See [`examples/data.rs`](examples/data.rs) for trades, leaderboards, activity, and more. + +#### Gamma API +Market and event discovery. Requires the `gamma` feature. + +```rust,ignore +use polymarket_client_sdk::gamma::Client; +use polymarket_client_sdk::gamma::types::request::{EventsRequest, SearchRequest}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = Client::default(); + + // Find active events + let request = EventsRequest::builder().active(true).limit(5).build(); + let events = client.events(&request).await?; + println!("Found {} events", events.len()); + + // Search for markets + let search = SearchRequest::builder().q("bitcoin").build(); + let results = client.search(&search).await?; + println!("Search results: {:?}", results); + Ok(()) +} +``` + +See [`examples/gamma.rs`](examples/gamma/client.rs) for tags, series, comments, and sports endpoints. + +#### Bridge API +Cross-chain deposits from EVM chains, Solana, and Bitcoin. Requires the `bridge` feature. + +```rust,ignore +use polymarket_client_sdk::bridge::Client; +use polymarket_client_sdk::bridge::types::DepositRequest; +use polymarket_client_sdk::types::address; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let client = Client::default(); + + // Get deposit addresses for your wallet + let request = DepositRequest::builder() + .address(address!("0x0000000000000000000000000000000000000000")) // Your address + .build(); + let response = client.deposit(&request).await?; + + println!("EVM: {}", response.address.evm); + println!("Solana: {}", response.address.svm); + println!("Bitcoin: {}", response.address.btc); + Ok(()) +} +``` + +See [`examples/bridge.rs`](examples/bridge.rs) for supported assets and minimum deposits. + +## Additional CLOB Capabilities + +Beyond basic order placement, the CLOB client supports: + +- **Rewards & Earnings** - Query maker rewards, daily earnings, and reward percentages +- **Streaming Pagination** - `stream_data()` for iterating through large result sets +- **Batch Operations** - `post_orders()` and `cancel_orders()` for multiple orders at once +- **Order Scoring** - Check if orders qualify for maker rewards +- **Notifications** - Manage trading notifications +- **Balance Management** - Query and refresh balance/allowance caches +- **Geoblock Detection** - Check if trading is available in your region + +See [`examples/clob/authenticated.rs`](examples/clob/authenticated.rs) for comprehensive usage. + +## Token Allowances + +### Do I need to set allowances? +MetaMask and EOA users must set token allowances. +If you are using a proxy or [Safe](https://help.safe.global/en/articles/40869-what-is-safe)-type wallet, then you do not. + +### What are allowances? +Think of allowances as permissions. Before Polymarket can move your funds to execute trades, you need to give the +exchange contracts permission to access your USDC and conditional tokens. + +### Quick Setup +You need to approve two types of tokens: +1. **USDC** (for deposits and trading) +2. **Conditional Tokens** (the outcome tokens you trade) + +Each needs approval for the exchange contracts to work properly. + +### Setting Allowances +Use [examples/approvals.rs](examples/approvals.rs) to approve the right contracts. Run once to approve USDC. Then change +the `TOKEN_TO_APPROVE` and run for each conditional token. + +**Pro tip**: You only need to set these once per wallet. After that, you can trade freely. + +## Minimum Supported Rust Version (MSRV) + +**MSRV: Rust [1.88](https://releases.rs/docs/1.88.0/)** + +Older versions *may* compile, but are not supported. + +This project aims to maintain compatibility with a Rust version that is at least six months old. + +Version updates may occur more frequently than the policy guideline states if external forces require it. For example, +a CVE in a downstream dependency requiring an MSRV bump would be considered an acceptable reason to violate the six-month +guideline. + + +## Contributing +We encourage contributions from the community. Check out our [contributing guidelines](.github/CONTRIBUTING.md) for +instructions on how to contribute to this SDK. + + +## About Polymarket +[Polymarket](https://docs.polymarket.com/polymarket-learn/get-started/what-is-polymarket) is the world’s largest prediction market, allowing you to stay informed and profit from your knowledge by +betting on future events across various topics. +Studies show prediction markets are often more accurate than pundits because they combine news, polls, and expert +opinions into a single value that represents the market’s view of an event’s odds. Our markets reflect accurate, unbiased, +and real-time probabilities for the events that matter most to you. Markets seek truth. diff --git a/polymarket-client-sdk/SECURITY.md b/polymarket-client-sdk/SECURITY.md new file mode 100644 index 0000000..75d4edf --- /dev/null +++ b/polymarket-client-sdk/SECURITY.md @@ -0,0 +1,10 @@ +### Security + +If you believe you’ve found a security vulnerability, please email security@polymarket.com. Do not open a public issue. + +Include: +- A description of the issue and potential impact +- Steps to reproduce or a minimal proof of concept +- Any relevant logs or environment details + +We will acknowledge receipt, investigate, and provide guidance on next steps. diff --git a/polymarket-client-sdk/assets/logo.png b/polymarket-client-sdk/assets/logo.png new file mode 100644 index 0000000..aae9138 Binary files /dev/null and b/polymarket-client-sdk/assets/logo.png differ diff --git a/polymarket-client-sdk/benches/clob_order_operations.rs b/polymarket-client-sdk/benches/clob_order_operations.rs new file mode 100644 index 0000000..2dd2e81 --- /dev/null +++ b/polymarket-client-sdk/benches/clob_order_operations.rs @@ -0,0 +1,169 @@ +//! Benchmarks for CLOB order creation and signing operations +//! +//! This benchmark suite focuses on the hot path operations for creating and signing orders: +//! - Limit order building (price validation, decimal conversion, order struct creation) +//! - Order signing (EIP-712 domain construction and cryptographic signing) +//! - Order serialization (converting `SignedOrder` to JSON for API submission) + +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::PrivateKeySigner; +use criterion::{Criterion, criterion_group, criterion_main}; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::auth::Normal; +use polymarket_client_sdk::auth::state::Authenticated; +use polymarket_client_sdk::clob::Client; +use polymarket_client_sdk::clob::types::{OrderType, Side, TickSize}; +use polymarket_client_sdk::types::{Decimal, U256}; +use rust_decimal_macros::dec; + +const TOKEN_ID: &str = + "15871154585880608648532107628464183779895785213830018178010423617714102767076"; + +// Dummy private key for benchmarking (DO NOT USE IN PRODUCTION) +const BENCH_PRIVATE_KEY: &str = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + +/// Helper to create an authenticated client with cached tick size and fee rate +async fn setup_client() -> (Client>, PrivateKeySigner) { + let token_id = U256::from_str(TOKEN_ID).expect("valid token ID"); + let signer = PrivateKeySigner::from_str(BENCH_PRIVATE_KEY) + .expect("valid key") + .with_chain_id(Some(POLYGON)); + + let client = Client::default() + .authentication_builder(&signer) + .authenticate() + .await + .expect("authentication succeeds"); + + // Pre-cache tick size and fee rate to avoid HTTP requests during benchmarking + client.set_tick_size(token_id, TickSize::Hundredth); + client.set_fee_rate_bps(token_id, 0); + client.set_neg_risk(token_id, false); + + (client, signer) +} + +/// Benchmark limit order building +fn bench_order_building(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let (client, _) = runtime.block_on(setup_client()); + let token_id = U256::from_str(TOKEN_ID).expect("valid token ID"); + + let mut group = c.benchmark_group("clob_order_operations/order_building"); + + group.bench_function("BUY", |b| { + b.iter(|| { + runtime.block_on(async { + let order_builder = client + .limit_order() + .order_type(OrderType::GTC) + .token_id(token_id) + .side(Side::Buy) + .price(dec!(0.50)) + .size(Decimal::ONE_HUNDRED); + + std::hint::black_box(order_builder.build().await.expect("build succeeds")) + }) + }); + }); + + group.bench_function("SELL", |b| { + b.iter(|| { + runtime.block_on(async { + let order_builder = client + .limit_order() + .order_type(OrderType::GTC) + .token_id(token_id) + .side(Side::Sell) + .price(dec!(0.50)) + .size(Decimal::ONE_HUNDRED); + + std::hint::black_box(order_builder.build().await.expect("build succeeds")) + }) + }); + }); + + group.finish(); +} + +/// Benchmark order signing +fn bench_order_signing(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let (client, signer) = runtime.block_on(setup_client()); + let token_id = U256::from_str(TOKEN_ID).expect("valid token ID"); + + let mut group = c.benchmark_group("clob_order_operations/order_signing"); + + let signable_order = runtime.block_on(async { + client + .limit_order() + .token_id(token_id) + .side(Side::Buy) + .price(dec!(0.50)) + .size(dec!(100.0)) + .build() + .await + .expect("build succeeds") + }); + + group.bench_function("limit_order", |b| { + b.iter(|| { + runtime.block_on(async { + std::hint::black_box( + client + .sign(&signer, signable_order.clone()) + .await + .expect("sign succeeds"), + ) + }) + }); + }); + + group.finish(); +} + +/// Benchmark order serialization +fn bench_order_serializing(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let token_id = U256::from_str(TOKEN_ID).expect("valid token ID"); + + let mut group = c.benchmark_group("clob_order_operations/order_serializing"); + + let signed_order = runtime.block_on(async { + let (client, signer) = setup_client().await; + + let signable = client + .limit_order() + .token_id(token_id) + .side(Side::Buy) + .price(dec!(0.50)) + .size(dec!(100.0)) + .build() + .await + .expect("build succeeds"); + + client.sign(&signer, signable).await.expect("sign succeeds") + }); + + group.bench_function("to_json", |b| { + b.iter(|| { + let json = serde_json::to_string(std::hint::black_box(&signed_order)) + .expect("serialization succeeds"); + std::hint::black_box(json) + }); + }); + + group.finish(); +} + +criterion_group!( + order_operations_benches, + bench_order_building, + bench_order_signing, + bench_order_serializing, +); + +criterion_main!(order_operations_benches); diff --git a/polymarket-client-sdk/benches/deserialize_clob.rs b/polymarket-client-sdk/benches/deserialize_clob.rs new file mode 100644 index 0000000..b8c278e --- /dev/null +++ b/polymarket-client-sdk/benches/deserialize_clob.rs @@ -0,0 +1,406 @@ +/// Comprehensive benchmarks for CLOB API deserialization. +/// +/// This module benchmarks ALL deserialization types for the Central Limit Order Book API, +/// with special focus on hot trading paths: order placement, orderbook updates, trades, +/// and cancellations. +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use polymarket_client_sdk::clob::types::response::{ + ApiKeysResponse, BalanceAllowanceResponse, BanStatusResponse, CancelOrdersResponse, + FeeRateResponse, LastTradePriceResponse, MarketResponse, MidpointResponse, NegRiskResponse, + NotificationResponse, OpenOrderResponse, OrderBookSummaryResponse, PostOrderResponse, + PriceHistoryResponse, PriceResponse, SpreadResponse, TickSizeResponse, TradeResponse, +}; + +fn bench_orderbook(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/orderbook"); + + let orderbook_small = r#"{ + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "timestamp": "1234567890123", + "bids": [{"price": "0.55", "size": "100.0"}], + "asks": [{"price": "0.56", "size": "150.0"}], + "min_order_size": "10.0", + "neg_risk": false, + "tick_size": "0.01" + }"#; + + let orderbook_medium = r#"{ + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "timestamp": "1234567890123", + "hash": "abc123def456", + "bids": [ + {"price": "0.55", "size": "100.0"}, + {"price": "0.54", "size": "200.0"}, + {"price": "0.53", "size": "300.0"}, + {"price": "0.52", "size": "400.0"}, + {"price": "0.51", "size": "500.0"}, + {"price": "0.50", "size": "600.0"}, + {"price": "0.49", "size": "700.0"}, + {"price": "0.48", "size": "800.0"}, + {"price": "0.47", "size": "900.0"}, + {"price": "0.46", "size": "1000.0"} + ], + "asks": [ + {"price": "0.56", "size": "150.0"}, + {"price": "0.57", "size": "175.0"}, + {"price": "0.58", "size": "200.0"}, + {"price": "0.59", "size": "225.0"}, + {"price": "0.60", "size": "250.0"}, + {"price": "0.61", "size": "275.0"}, + {"price": "0.62", "size": "300.0"}, + {"price": "0.63", "size": "325.0"}, + {"price": "0.64", "size": "350.0"}, + {"price": "0.65", "size": "375.0"} + ], + "min_order_size": "10.0", + "neg_risk": false, + "tick_size": "0.01" + }"#; + + // Benchmark with different orderbook depths + for (name, json) in [ + ("small_1_level", orderbook_small), + ("medium_10_levels", orderbook_medium), + ] { + group.throughput(Throughput::Bytes(json.len() as u64)); + group.bench_with_input( + BenchmarkId::new("OrderBookSummaryResponse", name), + &json, + |b, json| { + b.iter(|| { + let _: OrderBookSummaryResponse = + serde_json::from_str(std::hint::black_box(json)) + .expect("Deserialization should succeed"); + }); + }, + ); + } + + group.finish(); +} + +fn bench_orders(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/orders"); + + let post_order = r#"{ + "makingAmount": "100.5", + "takingAmount": "55.275", + "orderID": "0x1234567890abcdef", + "status": "LIVE", + "success": true, + "transactionsHashes": ["0x0000000000000000000000000000000000000000000000000000000000000001"], + "trade_ids": ["trade_123", "trade_456"] + }"#; + group.throughput(Throughput::Bytes(post_order.len() as u64)); + group.bench_function("PostOrderResponse", |b| { + b.iter(|| { + let _: PostOrderResponse = serde_json::from_str(std::hint::black_box(post_order)) + .expect("Deserialization should succeed"); + }); + }); + + let open_order = r#"{ + "id": "0x1234567890abcdef", + "status": "LIVE", + "owner": "550e8400-e29b-41d4-a716-446655440000", + "maker_address": "0x1234567890123456789012345678901234567890", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "original_size": "100.0", + "size_matched": "25.0", + "price": "0.55", + "associate_trades": ["trade_123"], + "outcome": "Yes", + "created_at": 1234567890, + "expiration": "1234567890", + "order_type": "GTC" + }"#; + group.throughput(Throughput::Bytes(open_order.len() as u64)); + group.bench_function("OpenOrderResponse", |b| { + b.iter(|| { + let _: OpenOrderResponse = serde_json::from_str(std::hint::black_box(open_order)) + .expect("Deserialization should succeed"); + }); + }); + + let trade = r#"{ + "id": "trade_123", + "taker_order_id": "0xabcdef1234567890", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "size": "25.0", + "fee_rate_bps": "25", + "price": "0.55", + "status": "MATCHED", + "match_time": "1234567890", + "last_update": "1234567891", + "outcome": "Yes", + "bucket_index": 5, + "owner": "550e8400-e29b-41d4-a716-446655440000", + "maker_address": "0x1234567890123456789012345678901234567890", + "maker_orders": [ + { + "order_id": "0x111", + "owner": "550e8400-e29b-41d4-a716-446655440000", + "maker_address": "0x1234567890123456789012345678901234567890", + "matched_amount": "0.2", + "price": "0.55", + "fee_rate_bps": "1", + "asset_id": "123456789", + "outcome": "Yes", + "side": "BUY" + }, + { + "order_id": "0x222", + "owner": "550e8400-e29b-41d4-a716-446655440000", + "maker_address": "0x1234567890123456789012345678901234567890", + "matched_amount": "0.2", + "price": "0.55", + "fee_rate_bps": "1", + "asset_id": "123456789", + "outcome": "Yes", + "side": "BUY" + } + ], + "transaction_hash": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "trader_side": "TAKER" + }"#; + group.throughput(Throughput::Bytes(trade.len() as u64)); + group.bench_function("TradeResponse", |b| { + b.iter(|| { + let _: TradeResponse = serde_json::from_str(std::hint::black_box(trade)) + .expect("Deserialization should succeed"); + }); + }); + + let cancel = r#"{ + "canceled": ["0x123", "0x456", "0x789"], + "notCanceled": { + "0xabc": "Order already filled", + "0xdef": "Order not found" + } + }"#; + group.throughput(Throughput::Bytes(cancel.len() as u64)); + group.bench_function("CancelOrdersResponse", |b| { + b.iter(|| { + let _: CancelOrdersResponse = serde_json::from_str(std::hint::black_box(cancel)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_market_data(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/market_data"); + + let market = r#"{ + "enable_order_book": true, + "active": true, + "closed": false, + "archived": false, + "accepting_orders": true, + "accepting_order_timestamp": null, + "minimum_order_size": "1.0", + "minimum_tick_size": "0.01", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "question_id": "0x0000000000000000000000000000000000000000000000000000000000000002", + "question": "Will X happen?", + "description": "Test market for benchmarking", + "market_slug": "test-market-2024", + "end_date_iso": "2024-12-31T23:59:59Z", + "game_start_time": null, + "seconds_delay": 0, + "fpmm": "0x1234567890123456789012345678901234567890", + "maker_base_fee": "0.001", + "taker_base_fee": "0.002", + "notifications_enabled": true, + "neg_risk": false, + "neg_risk_market_id": "", + "neg_risk_request_id": "", + "icon": "https://polymarket.com/icon.png", + "image": "https://polymarket.com/image.png", + "rewards": {"rates": [], "min_size": "0", "max_spread": "0"}, + "is_50_50_outcome": true, + "tokens": [ + {"token_id": "123456789", "outcome": "Yes", "price": "0.55", "winner": false}, + {"token_id": "987654321", "outcome": "No", "price": "0.45", "winner": false} + ], + "tags": ["politics", "2024"] + }"#; + group.throughput(Throughput::Bytes(market.len() as u64)); + group.bench_function("MarketResponse", |b| { + b.iter(|| { + let _: MarketResponse = serde_json::from_str(std::hint::black_box(market)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_pricing(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/pricing"); + + let midpoint = r#"{"mid": "0.55"}"#; + group.bench_function("MidpointResponse", |b| { + b.iter(|| { + let _: MidpointResponse = serde_json::from_str(std::hint::black_box(midpoint)) + .expect("Deserialization should succeed"); + }); + }); + + let price = r#"{"price": "0.60"}"#; + group.bench_function("PriceResponse", |b| { + b.iter(|| { + let _: PriceResponse = serde_json::from_str(std::hint::black_box(price)) + .expect("Deserialization should succeed"); + }); + }); + + let spread = r#"{"spread": "0.05"}"#; + group.bench_function("SpreadResponse", |b| { + b.iter(|| { + let _: SpreadResponse = serde_json::from_str(std::hint::black_box(spread)) + .expect("Deserialization should succeed"); + }); + }); + + let tick_size = r#"{"minimum_tick_size": "0.01"}"#; + group.bench_function("TickSizeResponse", |b| { + b.iter(|| { + let _: TickSizeResponse = serde_json::from_str(std::hint::black_box(tick_size)) + .expect("Deserialization should succeed"); + }); + }); + + let neg_risk = r#"{"neg_risk": false}"#; + group.bench_function("NegRiskResponse", |b| { + b.iter(|| { + let _: NegRiskResponse = serde_json::from_str(std::hint::black_box(neg_risk)) + .expect("Deserialization should succeed"); + }); + }); + + let fee_rate = r#"{"base_fee": 25}"#; + group.bench_function("FeeRateResponse", |b| { + b.iter(|| { + let _: FeeRateResponse = serde_json::from_str(std::hint::black_box(fee_rate)) + .expect("Deserialization should succeed"); + }); + }); + + let last_trade_price = r#"{"price": "0.55", "side": "BUY"}"#; + group.bench_function("LastTradePriceResponse", |b| { + b.iter(|| { + let _: LastTradePriceResponse = + serde_json::from_str(std::hint::black_box(last_trade_price)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_account_data(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/account"); + + let balance = r#"{"balance": "1000.50", "allowance": "500.25"}"#; + group.bench_function("BalanceAllowanceResponse", |b| { + b.iter(|| { + let _: BalanceAllowanceResponse = serde_json::from_str(std::hint::black_box(balance)) + .expect("Deserialization should succeed"); + }); + }); + + let api_keys = r#"{"api_keys": ["key1", "key2", "key3"]}"#; + group.bench_function("ApiKeysResponse", |b| { + b.iter(|| { + let _: ApiKeysResponse = serde_json::from_str(std::hint::black_box(api_keys)) + .expect("Deserialization should succeed"); + }); + }); + + let ban_status = r#"{ + "closed_only": true + }"#; + group.bench_function("BanStatusResponse", |b| { + b.iter(|| { + let _: BanStatusResponse = serde_json::from_str(std::hint::black_box(ban_status)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_additional_types(c: &mut Criterion) { + let mut group = c.benchmark_group("clob/additional"); + + let notification = r#"{ + "type": 1, + "owner": "550e8400-e29b-41d4-a716-446655440000", + "payload": { + "asset_id": "123456789", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "eventSlug": "test-event", + "icon": "https://polymarket.com/icon.png", + "image": "https://polymarket.com/image.png", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "market_slug": "test-market", + "matched_size": "25.0", + "order_id": "0x123", + "original_size": "100.0", + "outcome": "Yes", + "outcome_index": 0, + "owner": "550e8400-e29b-41d4-a716-446655440000", + "price": "0.55", + "question": "Will X happen?", + "remaining_size": "75.0", + "seriesSlug": "test-series", + "side": "BUY", + "trade_id": "trade_123", + "transaction_hash": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "type": "GTC" + } + }"#; + group.bench_function("NotificationResponse", |b| { + b.iter(|| { + let _: NotificationResponse = serde_json::from_str(std::hint::black_box(notification)) + .expect("Deserialization should succeed"); + }); + }); + + let price_history = r#"{ + "history": [ + {"t": 1234567890000, "p": "0.55"}, + {"t": 1234567891000, "p": "0.56"}, + {"t": 1234567892000, "p": "0.54"}, + {"t": 1234567893000, "p": "0.57"}, + {"t": 1234567894000, "p": "0.55"} + ] + }"#; + group.bench_function("PriceHistoryResponse", |b| { + b.iter(|| { + let _: PriceHistoryResponse = serde_json::from_str(std::hint::black_box(price_history)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +criterion_group!( + clob_benches, + bench_orderbook, + bench_orders, + bench_market_data, + bench_pricing, + bench_account_data, + bench_additional_types +); +criterion_main!(clob_benches); diff --git a/polymarket-client-sdk/benches/deserialize_websocket.rs b/polymarket-client-sdk/benches/deserialize_websocket.rs new file mode 100644 index 0000000..4a73419 --- /dev/null +++ b/polymarket-client-sdk/benches/deserialize_websocket.rs @@ -0,0 +1,454 @@ +/// Comprehensive benchmarks for CLOB WebSocket message deserialization. +/// +/// This module benchmarks ALL WebSocket message types with special focus on the MOST CRITICAL +/// hot paths for live trading: orderbook updates, trade notifications, and order status updates. +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use polymarket_client_sdk::clob::ws::types::response::OrderBookLevel; +use polymarket_client_sdk::clob::ws::{ + BestBidAsk, BookUpdate, LastTradePrice, MakerOrder, MarketResolved, MidpointUpdate, NewMarket, + OrderMessage, PriceChange, TickSizeChange, TradeMessage, WsMessage, +}; + +fn bench_ws_message(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/ws_message"); + + let book_msg = r#"{ + "event_type": "book", + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "bids": [{"price": "0.55", "size": "100.0"}], + "asks": [{"price": "0.56", "size": "150.0"}] + }"#; + group.throughput(Throughput::Bytes(book_msg.len() as u64)); + group.bench_function("WsMessage::Book", |b| { + b.iter(|| { + let _: WsMessage = serde_json::from_str(std::hint::black_box(book_msg)) + .expect("Deserialization should succeed"); + }); + }); + + let price_change_msg = r#"{ + "event_type": "price_change", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "price_changes": [{ + "asset_id": "123456789", + "price": "0.65", + "side": "BUY" + }] + }"#; + group.throughput(Throughput::Bytes(price_change_msg.len() as u64)); + group.bench_function("WsMessage::PriceChange", |b| { + b.iter(|| { + let _: WsMessage = serde_json::from_str(std::hint::black_box(price_change_msg)) + .expect("Deserialization should succeed"); + }); + }); + + let trade_msg = r#"{ + "event_type": "trade", + "id": "trade_123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "size": "25.0", + "price": "0.55", + "status": "MATCHED", + "maker_orders": [] + }"#; + group.throughput(Throughput::Bytes(trade_msg.len() as u64)); + group.bench_function("WsMessage::Trade", |b| { + b.iter(|| { + let _: WsMessage = serde_json::from_str(std::hint::black_box(trade_msg)) + .expect("Deserialization should succeed"); + }); + }); + + let order_msg = r#"{ + "event_type": "order", + "id": "0x123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "price": "0.55" + }"#; + group.throughput(Throughput::Bytes(order_msg.len() as u64)); + group.bench_function("WsMessage::Order", |b| { + b.iter(|| { + let _: WsMessage = serde_json::from_str(std::hint::black_box(order_msg)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_book_update(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/book_update"); + + // BookUpdate - MOST CRITICAL HOT PATH + // This is the highest frequency message in live trading + // Deserialized on every orderbook tick (can be 10-100+ per second) + + let book_small = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "bids": [{"price": "0.55", "size": "100.0"}], + "asks": [{"price": "0.56", "size": "150.0"}] + }"#; + + let book_medium = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "hash": "abc123", + "bids": [ + {"price": "0.55", "size": "100.0"}, + {"price": "0.54", "size": "200.0"}, + {"price": "0.53", "size": "300.0"}, + {"price": "0.52", "size": "400.0"}, + {"price": "0.51", "size": "500.0"} + ], + "asks": [ + {"price": "0.56", "size": "150.0"}, + {"price": "0.57", "size": "175.0"}, + {"price": "0.58", "size": "200.0"}, + {"price": "0.59", "size": "225.0"}, + {"price": "0.60", "size": "250.0"} + ] + }"#; + + let book_large = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "hash": "abc123", + "bids": [ + {"price": "0.55", "size": "100.0"}, {"price": "0.54", "size": "200.0"}, + {"price": "0.53", "size": "300.0"}, {"price": "0.52", "size": "400.0"}, + {"price": "0.51", "size": "500.0"}, {"price": "0.50", "size": "600.0"}, + {"price": "0.49", "size": "700.0"}, {"price": "0.48", "size": "800.0"}, + {"price": "0.47", "size": "900.0"}, {"price": "0.46", "size": "1000.0"}, + {"price": "0.45", "size": "1100.0"}, {"price": "0.44", "size": "1200.0"}, + {"price": "0.43", "size": "1300.0"}, {"price": "0.42", "size": "1400.0"}, + {"price": "0.41", "size": "1500.0"}, {"price": "0.40", "size": "1600.0"}, + {"price": "0.39", "size": "1700.0"}, {"price": "0.38", "size": "1800.0"}, + {"price": "0.37", "size": "1900.0"}, {"price": "0.36", "size": "2000.0"} + ], + "asks": [ + {"price": "0.56", "size": "150.0"}, {"price": "0.57", "size": "175.0"}, + {"price": "0.58", "size": "200.0"}, {"price": "0.59", "size": "225.0"}, + {"price": "0.60", "size": "250.0"}, {"price": "0.61", "size": "275.0"}, + {"price": "0.62", "size": "300.0"}, {"price": "0.63", "size": "325.0"}, + {"price": "0.64", "size": "350.0"}, {"price": "0.65", "size": "375.0"}, + {"price": "0.66", "size": "400.0"}, {"price": "0.67", "size": "425.0"}, + {"price": "0.68", "size": "450.0"}, {"price": "0.69", "size": "475.0"}, + {"price": "0.70", "size": "500.0"}, {"price": "0.71", "size": "525.0"}, + {"price": "0.72", "size": "550.0"}, {"price": "0.73", "size": "575.0"}, + {"price": "0.74", "size": "600.0"}, {"price": "0.75", "size": "625.0"} + ] + }"#; + + for (name, json) in [ + ("1_level", book_small), + ("5_levels", book_medium), + ("20_levels", book_large), + ] { + group.throughput(Throughput::Bytes(json.len() as u64)); + group.bench_with_input(BenchmarkId::new("BookUpdate", name), &json, |b, json| { + b.iter(|| { + let _: BookUpdate = serde_json::from_str(std::hint::black_box(json)) + .expect("Deserialization should succeed"); + }); + }); + } + + group.finish(); +} + +fn bench_user_messages(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/user_messages"); + + let trade_minimal = r#"{ + "id": "trade_123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "size": "25.0", + "price": "0.55", + "status": "MATCHED", + "maker_orders": [] + }"#; + + let trade_full = r#"{ + "id": "trade_123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "size": "25.0", + "price": "0.55", + "status": "CONFIRMED", + "type": "TRADE", + "last_update": "1704110400000", + "matchtime": "1704110400000", + "timestamp": "1704110400000", + "outcome": "Yes", + "owner": "550e8400-e29b-41d4-a716-446655440000", + "trade_owner": "550e8400-e29b-41d4-a716-446655440000", + "taker_order_id": "0xabcdef", + "maker_orders": [ + { + "order_id": "0x111", + "asset_id": "123456789", + "outcome": "Yes", + "price": "0.55", + "matched_amount": "10.0", + "owner": "550e8400-e29b-41d4-a716-446655440000" + }, + { + "order_id": "0x222", + "asset_id": "123456789", + "outcome": "Yes", + "price": "0.55", + "matched_amount": "15.0", + "owner": "550e8400-e29b-41d4-a716-446655440000" + } + ], + "fee_rate_bps": "25", + "transaction_hash": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "trader_side": "TAKER" + }"#; + + for (name, json) in [("minimal", trade_minimal), ("full", trade_full)] { + group.throughput(Throughput::Bytes(json.len() as u64)); + group.bench_with_input(BenchmarkId::new("TradeMessage", name), &json, |b, json| { + b.iter(|| { + let _: TradeMessage = serde_json::from_str(std::hint::black_box(json)) + .expect("Deserialization should succeed"); + }); + }); + } + + let order_minimal = r#"{ + "id": "0x123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "price": "0.55" + }"#; + + let order_full = r#"{ + "id": "0x123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "123456789", + "side": "BUY", + "price": "0.55", + "type": "PLACEMENT", + "outcome": "Yes", + "owner": "550e8400-e29b-41d4-a716-446655440000", + "order_owner": "550e8400-e29b-41d4-a716-446655440000", + "original_size": "100.0", + "size_matched": "25.0", + "timestamp": "1704110400000", + "associate_trades": ["trade_123", "trade_456"] + }"#; + + for (name, json) in [("minimal", order_minimal), ("full", order_full)] { + group.throughput(Throughput::Bytes(json.len() as u64)); + group.bench_with_input(BenchmarkId::new("OrderMessage", name), &json, |b, json| { + b.iter(|| { + let _: OrderMessage = serde_json::from_str(std::hint::black_box(json)) + .expect("Deserialization should succeed"); + }); + }); + } + + group.finish(); +} + +fn bench_market_data_updates(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/market_data"); + + let price_change_single = r#"{ + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "price_changes": [{ + "asset_id": "123456789", + "price": "0.65", + "side": "BUY" + }] + }"#; + + let price_change_batch = r#"{ + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "price_changes": [ + {"asset_id": "123456789", "price": "0.65", "side": "BUY", "hash": "abc1", "best_bid": "0.64", "best_ask": "0.66"}, + {"asset_id": "987654321", "price": "0.35", "side": "SELL", "hash": "abc2", "best_bid": "0.34", "best_ask": "0.36"}, + {"asset_id": "555555555", "price": "0.50", "side": "BUY", "hash": "abc3", "best_bid": "0.49", "best_ask": "0.51"} + ] + }"#; + + for (name, json) in [ + ("single", price_change_single), + ("batch_3", price_change_batch), + ] { + group.throughput(Throughput::Bytes(json.len() as u64)); + group.bench_with_input(BenchmarkId::new("PriceChange", name), &json, |b, json| { + b.iter(|| { + let _: PriceChange = serde_json::from_str(std::hint::black_box(json)) + .expect("Deserialization should succeed"); + }); + }); + } + + let last_trade_price = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "price": "0.55", + "side": "BUY" + }"#; + group.throughput(Throughput::Bytes(last_trade_price.len() as u64)); + group.bench_function("LastTradePrice", |b| { + b.iter(|| { + let _: LastTradePrice = serde_json::from_str(std::hint::black_box(last_trade_price)) + .expect("Deserialization should succeed"); + }); + }); + + let best_bid_ask = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "best_bid": "0.54", + "best_ask": "0.56", + "spread": "0.02" + }"#; + group.throughput(Throughput::Bytes(best_bid_ask.len() as u64)); + group.bench_function("BestBidAsk", |b| { + b.iter(|| { + let _: BestBidAsk = serde_json::from_str(std::hint::black_box(best_bid_ask)) + .expect("Deserialization should succeed"); + }); + }); + + let tick_size_change = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "old_tick_size": "0.01", + "new_tick_size": "0.001", + "timestamp": "1" + }"#; + group.throughput(Throughput::Bytes(tick_size_change.len() as u64)); + group.bench_function("TickSizeChange", |b| { + b.iter(|| { + let _: TickSizeChange = serde_json::from_str(std::hint::black_box(tick_size_change)) + .expect("Deserialization should succeed"); + }); + }); + + let midpoint_update = r#"{ + "asset_id": "123456789", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890123", + "midpoint": "0.55" + }"#; + group.throughput(Throughput::Bytes(midpoint_update.len() as u64)); + group.bench_function("MidpointUpdate", |b| { + b.iter(|| { + let _: MidpointUpdate = serde_json::from_str(std::hint::black_box(midpoint_update)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_market_events(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/market_events"); + + let new_market = r#"{ + "id": "1", + "question": "Will X happen?", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "slug": "test-market-2024", + "description": "Test market for benchmarking", + "assets_ids": ["123456789", "987654321"], + "outcomes": ["Yes", "No"], + "timestamp": "1704110400000" + }"#; + group.throughput(Throughput::Bytes(new_market.len() as u64)); + group.bench_function("NewMarket", |b| { + b.iter(|| { + let _: NewMarket = serde_json::from_str(std::hint::black_box(new_market)) + .expect("Deserialization should succeed"); + }); + }); + + let market_resolved = r#"{ + "id": "1", + "question": "Will X happen?", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "slug": "test-market-2024", + "description": "Test market for benchmarking", + "assets_ids": ["123456789", "987654321"], + "outcomes": ["Yes", "No"], + "winning_asset_id": "123456789", + "winning_outcome": "Yes", + "timestamp": "1704110400000" + }"#; + group.throughput(Throughput::Bytes(market_resolved.len() as u64)); + group.bench_function("MarketResolved", |b| { + b.iter(|| { + let _: MarketResolved = serde_json::from_str(std::hint::black_box(market_resolved)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +fn bench_orderbook_level(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket/primitives"); + + let level = r#"{"price": "0.55", "size": "100.0"}"#; + group.throughput(Throughput::Bytes(level.len() as u64)); + group.bench_function("OrderBookLevel", |b| { + b.iter(|| { + let _: OrderBookLevel = serde_json::from_str(std::hint::black_box(level)) + .expect("Deserialization should succeed"); + }); + }); + + let maker_order = r#"{ + "order_id": "0x123", + "asset_id": "123456789", + "outcome": "Yes", + "price": "0.55", + "matched_amount": "10.0", + "owner": "550e8400-e29b-41d4-a716-446655440000" + }"#; + group.throughput(Throughput::Bytes(maker_order.len() as u64)); + group.bench_function("MakerOrder", |b| { + b.iter(|| { + let _: MakerOrder = serde_json::from_str(std::hint::black_box(maker_order)) + .expect("Deserialization should succeed"); + }); + }); + + group.finish(); +} + +criterion_group!( + websocket_benches, + bench_ws_message, + bench_book_update, + bench_user_messages, + bench_market_data_updates, + bench_market_events, + bench_orderbook_level +); +criterion_main!(websocket_benches); diff --git a/polymarket-client-sdk/clippy.toml b/polymarket-client-sdk/clippy.toml new file mode 100644 index 0000000..154626e --- /dev/null +++ b/polymarket-client-sdk/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/polymarket-client-sdk/examples/approvals.rs b/polymarket-client-sdk/examples/approvals.rs new file mode 100644 index 0000000..e23a416 --- /dev/null +++ b/polymarket-client-sdk/examples/approvals.rs @@ -0,0 +1,215 @@ +#![allow(clippy::exhaustive_enums, reason = "Generated by sol! macro")] +#![allow(clippy::exhaustive_structs, reason = "Generated by sol! macro")] +#![allow(clippy::unwrap_used, reason = "Examples use unwrap for brevity")] + +//! Token approval example for Polymarket CLOB trading. +//! +//! This example demonstrates how to set the required token allowances for trading on Polymarket. +//! You must approve three contracts: +//! +//! 1. **CTF Exchange** (`config.exchange`) - Standard market trading +//! 2. **Neg Risk CTF Exchange** (`neg_risk_config.exchange`) - Neg-risk market trading +//! 3. **Neg Risk Adapter** (`neg_risk_config.neg_risk_adapter`) - Token minting/splitting for neg-risk +//! +//! Each contract needs two approvals: +//! - ERC-20 approval for USDC (collateral token) +//! - ERC-1155 approval for Conditional Tokens (outcome tokens) +//! +//! You only need to run these approvals once per wallet. +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example approvals --features tracing +//! ``` +//! +//! Dry run (no transactions executed): +//! ```sh +//! RUST_LOG=info cargo run --example approvals --features tracing -- --dry-run +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=approvals.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example approvals --features tracing +//! ``` + +use std::env; +use std::fs::File; +use std::str::FromStr as _; + +use alloy::primitives::U256; +use alloy::providers::ProviderBuilder; +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use alloy::sol; +use polymarket_client_sdk::types::{Address, address}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR, contract_config}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +const RPC_URL: &str = "https://polygon-rpc.com"; + +const USDC_ADDRESS: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); +const TOKEN_TO_APPROVE: Address = USDC_ADDRESS; + +sol! { + #[sol(rpc)] + interface IERC20 { + function approve(address spender, uint256 value) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + } + + #[sol(rpc)] + interface IERC1155 { + function setApprovalForAll(address operator, bool approved) external; + function isApprovedForAll(address account, address operator) external view returns (bool); + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let args: Vec = env::args().collect(); + let dry_run = args.iter().any(|arg| arg == "--dry-run"); + + let chain = POLYGON; + let config = contract_config(chain, false).unwrap(); + let neg_risk_config = contract_config(chain, true).unwrap(); + + // Collect all contracts that need approval + let mut targets: Vec<(&str, Address)> = vec![ + ("CTF Exchange", config.exchange), + ("Neg Risk CTF Exchange", neg_risk_config.exchange), + ]; + + // Add the Neg Risk Adapter if available + if let Some(adapter) = neg_risk_config.neg_risk_adapter { + targets.push(("Neg Risk Adapter", adapter)); + } + + if dry_run { + info!(mode = "dry_run", "showing approvals without executing"); + for (name, target) in &targets { + info!(contract = name, address = %target, "would receive approval"); + } + info!(total = targets.len(), "contracts would be approved"); + return Ok(()); + } + + let private_key = env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(chain)); + + let provider = ProviderBuilder::new() + .wallet(signer.clone()) + .connect(RPC_URL) + .await?; + + let owner = signer.address(); + info!(address = %owner, "wallet loaded"); + + let token = IERC20::new(TOKEN_TO_APPROVE, provider.clone()); + let ctf = IERC1155::new(config.conditional_tokens, provider.clone()); + + info!(phase = "checking", "querying current allowances"); + + for (name, target) in &targets { + match check_allowance(&token, owner, *target).await { + Ok(allowance) => info!(contract = name, usdc_allowance = %allowance), + Err(e) => error!(contract = name, error = ?e, "failed to check USDC allowance"), + } + + match check_approval_for_all(&ctf, owner, *target).await { + Ok(approved) => info!(contract = name, ctf_approved = approved), + Err(e) => error!(contract = name, error = ?e, "failed to check CTF approval"), + } + } + + info!(phase = "approving", "setting approvals"); + + for (name, target) in &targets { + info!(contract = name, address = %target, "approving"); + + match approve(&token, *target, U256::MAX).await { + Ok(tx_hash) => info!(contract = name, tx = %tx_hash, "USDC approved"), + Err(e) => error!(contract = name, error = ?e, "USDC approve failed"), + } + + match set_approval_for_all(&ctf, *target, true).await { + Ok(tx_hash) => info!(contract = name, tx = %tx_hash, "CTF approved"), + Err(e) => error!(contract = name, error = ?e, "CTF setApprovalForAll failed"), + } + } + + info!(phase = "verifying", "confirming approvals"); + + for (name, target) in &targets { + match check_allowance(&token, owner, *target).await { + Ok(allowance) => info!(contract = name, usdc_allowance = %allowance, "verified"), + Err(e) => error!(contract = name, error = ?e, "verification failed"), + } + + match check_approval_for_all(&ctf, owner, *target).await { + Ok(approved) => info!(contract = name, ctf_approved = approved, "verified"), + Err(e) => error!(contract = name, error = ?e, "verification failed"), + } + } + + info!("all approvals complete"); + + Ok(()) +} + +async fn check_allowance( + token: &IERC20::IERC20Instance

, + owner: Address, + spender: Address, +) -> anyhow::Result { + let allowance = token.allowance(owner, spender).call().await?; + Ok(allowance) +} + +async fn check_approval_for_all( + ctf: &IERC1155::IERC1155Instance

, + account: Address, + operator: Address, +) -> anyhow::Result { + let approved = ctf.isApprovedForAll(account, operator).call().await?; + Ok(approved) +} + +async fn approve( + usdc: &IERC20::IERC20Instance

, + spender: Address, + amount: U256, +) -> anyhow::Result> { + let tx_hash = usdc.approve(spender, amount).send().await?.watch().await?; + Ok(tx_hash) +} + +async fn set_approval_for_all( + ctf: &IERC1155::IERC1155Instance

, + operator: Address, + approved: bool, +) -> anyhow::Result> { + let tx_hash = ctf + .setApprovalForAll(operator, approved) + .send() + .await? + .watch() + .await?; + Ok(tx_hash) +} diff --git a/polymarket-client-sdk/examples/bridge.rs b/polymarket-client-sdk/examples/bridge.rs new file mode 100644 index 0000000..e42fdfb --- /dev/null +++ b/polymarket-client-sdk/examples/bridge.rs @@ -0,0 +1,90 @@ +//! Bridge API example demonstrating deposit and supported assets endpoints. +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example bridge --features bridge,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=bridge.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example bridge --features bridge,tracing +//! ``` + +use std::fs::File; + +use polymarket_client_sdk::bridge::Client; +use polymarket_client_sdk::bridge::types::{DepositRequest, StatusRequest}; +use polymarket_client_sdk::types::address; +use tracing::{debug, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + + match client.supported_assets().await { + Ok(response) => { + info!( + endpoint = "supported_assets", + count = response.supported_assets.len() + ); + for asset in &response.supported_assets { + info!( + endpoint = "supported_assets", + name = %asset.token.name, + symbol = %asset.token.symbol, + chain = %asset.chain_name, + chain_id = asset.chain_id, + min_usd = %asset.min_checkout_usd + ); + } + } + Err(e) => debug!(endpoint = "supported_assets", error = %e), + } + + let request = DepositRequest::builder() + .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + + match client.deposit(&request).await { + Ok(response) => { + info!( + endpoint = "deposit", + evm = %response.address.evm, + svm = %response.address.svm, + btc = %response.address.btc, + note = ?response.note + ); + } + Err(e) => debug!(endpoint = "deposit", error = %e), + } + + let status_request = StatusRequest::builder() + .address("bc1qs82vw5pczv9uj44n4npscldkdjgfjqu7x9mlna") + .build(); + + match client.status(&status_request).await { + Ok(response) => { + info!(endpoint = "status", count = response.transactions.len()); + } + Err(e) => debug!(endpoint = "status", error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/check_approvals.rs b/polymarket-client-sdk/examples/check_approvals.rs new file mode 100644 index 0000000..23daecf --- /dev/null +++ b/polymarket-client-sdk/examples/check_approvals.rs @@ -0,0 +1,164 @@ +#![allow(clippy::exhaustive_enums, reason = "Generated by sol! macro")] +#![allow(clippy::exhaustive_structs, reason = "Generated by sol! macro")] +#![allow(clippy::print_stderr, reason = "Usage message to stderr")] +#![allow(clippy::unwrap_used, reason = "Examples use unwrap for brevity")] + +//! Read-only example to check current token approvals for Polymarket CLOB trading. +//! +//! This example queries the blockchain to show which contracts are approved +//! for a given wallet address. No private key or gas required. +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example check_approvals --features tracing -- +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=check_approvals.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example check_approvals --features tracing -- +//! ``` +//! +//! Example: +//! ```sh +//! RUST_LOG=info cargo run --example check_approvals --features tracing -- 0x1234...abcd +//! ``` + +use std::env; +use std::fs::File; + +use alloy::primitives::U256; +use alloy::providers::ProviderBuilder; +use alloy::sol; +use polymarket_client_sdk::types::{Address, address}; +use polymarket_client_sdk::{POLYGON, contract_config}; +use tracing::{debug, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +const RPC_URL: &str = "https://polygon-rpc.com"; + +const USDC_ADDRESS: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + +sol! { + #[sol(rpc)] + interface IERC20 { + function allowance(address owner, address spender) external view returns (uint256); + } + + #[sol(rpc)] + interface IERC1155 { + function isApprovedForAll(address account, address operator) external view returns (bool); + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let args: Vec = env::args().collect(); + + if args.len() != 2 { + debug!( + args = args.len(), + "invalid arguments - expected wallet address" + ); + eprintln!("Usage: cargo run --example check_approvals -- "); + eprintln!( + "Example: cargo run --example check_approvals -- 0x1234567890abcdef1234567890abcdef12345678" + ); + std::process::exit(1); + } + + let wallet_address: Address = args[1].parse()?; + + info!(wallet = %wallet_address, chain = "Polygon Mainnet (137)", "checking approvals"); + + let provider = ProviderBuilder::new().connect(RPC_URL).await?; + + let config = contract_config(POLYGON, false).unwrap(); + let neg_risk_config = contract_config(POLYGON, true).unwrap(); + + let usdc = IERC20::new(USDC_ADDRESS, provider.clone()); + let ctf = IERC1155::new(config.conditional_tokens, provider.clone()); + + // All contracts that need approval for full CLOB trading + let mut targets: Vec<(&str, Address)> = vec![ + ("CTF Exchange", config.exchange), + ("Neg Risk CTF Exchange", neg_risk_config.exchange), + ]; + + if let Some(adapter) = neg_risk_config.neg_risk_adapter { + targets.push(("Neg Risk Adapter", adapter)); + } + + let mut all_approved = true; + + for (name, target) in &targets { + let usdc_result = usdc.allowance(wallet_address, *target).call().await; + let ctf_result = ctf.isApprovedForAll(wallet_address, *target).call().await; + + match (&usdc_result, &ctf_result) { + (Ok(usdc_allowance), Ok(ctf_approved)) => { + let usdc_ok = *usdc_allowance > U256::ZERO; + let ctf_ok = *ctf_approved; + + if !usdc_ok || !ctf_ok { + all_approved = false; + } + + info!( + contract = name, + address = %target, + usdc_allowance = %format_allowance(*usdc_allowance), + usdc_approved = usdc_ok, + ctf_approved = ctf_ok, + ); + } + (Err(e), _) => { + debug!(contract = name, error = %e, "failed to check USDC allowance"); + all_approved = false; + } + (_, Err(e)) => { + debug!(contract = name, error = %e, "failed to check CTF approval"); + all_approved = false; + } + } + } + + if all_approved { + info!(status = "ready", "all contracts properly approved"); + } else { + info!( + status = "incomplete", + "some approvals missing - run: cargo run --example approvals" + ); + } + + Ok(()) +} + +fn format_allowance(allowance: U256) -> String { + if allowance == U256::MAX { + "MAX (unlimited)".to_owned() + } else if allowance == U256::ZERO { + "0".to_owned() + } else { + // USDC has 6 decimals + let usdc_decimals = U256::from(1_000_000); + let whole = allowance / usdc_decimals; + format!("{whole} USDC") + } +} diff --git a/polymarket-client-sdk/examples/clob/async.rs b/polymarket-client-sdk/examples/clob/async.rs new file mode 100644 index 0000000..f7b68ae --- /dev/null +++ b/polymarket-client-sdk/examples/clob/async.rs @@ -0,0 +1,151 @@ +//! Demonstrates async concurrency patterns with the CLOB client. +//! +//! This example shows how to: +//! 1. Run multiple unauthenticated API calls concurrently +//! 2. Run multiple authenticated API calls concurrently +//! 3. Spawn background tasks that share the client +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example async --features clob,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=async.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example async --features clob,tracing +//! ``` +//! +//! For authenticated endpoints, set the `POLY_PRIVATE_KEY` environment variable. + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::U256; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tokio::join; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let (unauthenticated, authenticated) = join!(unauthenticated(), authenticated()); + unauthenticated?; + authenticated +} + +async fn unauthenticated() -> anyhow::Result<()> { + let client = Client::new("https://clob.polymarket.com", Config::default())?; + let client_clone = client.clone(); + + let token_id = U256::from_str( + "42334954850219754195241248003172889699504912694714162671145392673031415571339", + )?; + + let thread = tokio::spawn(async move { + let (ok_result, tick_result, neg_risk_result) = join!( + client_clone.ok(), + client_clone.tick_size(token_id), + client_clone.neg_risk(token_id) + ); + + match ok_result { + Ok(s) => info!(endpoint = "ok", thread = true, result = %s), + Err(e) => error!(endpoint = "ok", thread = true, error = %e), + } + + match tick_result { + Ok(t) => info!(endpoint = "tick_size", thread = true, tick_size = ?t.minimum_tick_size), + Err(e) => error!(endpoint = "tick_size", thread = true, error = %e), + } + + match neg_risk_result { + Ok(n) => info!(endpoint = "neg_risk", thread = true, neg_risk = n.neg_risk), + Err(e) => error!(endpoint = "neg_risk", thread = true, error = %e), + } + + anyhow::Ok(()) + }); + + match client.ok().await { + Ok(s) => info!(endpoint = "ok", result = %s), + Err(e) => error!(endpoint = "ok", error = %e), + } + + match client.tick_size(token_id).await { + Ok(t) => { + info!(endpoint = "tick_size", token_id = %token_id, tick_size = ?t.minimum_tick_size); + } + Err(e) => error!(endpoint = "tick_size", token_id = %token_id, error = %e), + } + + match client.neg_risk(token_id).await { + Ok(n) => info!(endpoint = "neg_risk", token_id = %token_id, neg_risk = n.neg_risk), + Err(e) => error!(endpoint = "neg_risk", token_id = %token_id, error = %e), + } + + thread.await? +} + +async fn authenticated() -> anyhow::Result<()> { + let Ok(private_key) = std::env::var(PRIVATE_KEY_VAR) else { + info!( + endpoint = "authenticated", + "skipped - POLY_PRIVATE_KEY not set" + ); + return Ok(()); + }; + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + let client_clone = client.clone(); + + let thread = tokio::spawn(async move { + let (ok_result, api_keys_result) = join!(client_clone.ok(), client_clone.api_keys()); + + match ok_result { + Ok(s) => info!(endpoint = "ok", thread = true, authenticated = true, result = %s), + Err(e) => error!(endpoint = "ok", thread = true, authenticated = true, error = %e), + } + + match api_keys_result { + Ok(keys) => info!(endpoint = "api_keys", thread = true, result = ?keys), + Err(e) => error!(endpoint = "api_keys", thread = true, error = %e), + } + + anyhow::Ok(()) + }); + + match client.ok().await { + Ok(s) => info!(endpoint = "ok", authenticated = true, result = %s), + Err(e) => error!(endpoint = "ok", authenticated = true, error = %e), + } + + match client.api_keys().await { + Ok(keys) => info!(endpoint = "api_keys", result = ?keys), + Err(e) => error!(endpoint = "api_keys", error = %e), + } + + thread.await? +} diff --git a/polymarket-client-sdk/examples/clob/authenticated.rs b/polymarket-client-sdk/examples/clob/authenticated.rs new file mode 100644 index 0000000..d79396a --- /dev/null +++ b/polymarket-client-sdk/examples/clob/authenticated.rs @@ -0,0 +1,216 @@ +//! Comprehensive authenticated CLOB API endpoint explorer. +//! +//! This example tests authenticated CLOB API endpoints including: +//! 1. API key management and account status +//! 2. Market and limit order creation +//! 3. Order management (fetch, cancel) +//! 4. Balance and allowance operations +//! 5. Trades and rewards queries +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example authenticated --features clob,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=authenticated.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example authenticated --features clob,tracing +//! ``` +//! +//! Requires `POLY_PRIVATE_KEY` environment variable to be set. + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use chrono::{TimeDelta, Utc}; +use polymarket_client_sdk::clob::types::request::{ + BalanceAllowanceRequest, OrdersRequest, TradesRequest, UpdateBalanceAllowanceRequest, + UserRewardsEarningRequest, +}; +use polymarket_client_sdk::clob::types::{Amount, OrderType, Side}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::{Decimal, U256}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use rust_decimal_macros::dec; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let token_id = U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076", + )?; + + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need POLY_PRIVATE_KEY"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let config = Config::builder().use_server_time(true).build(); + let client = Client::new("https://clob.polymarket.com", config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + match client.api_keys().await { + Ok(keys) => info!(endpoint = "api_keys", result = ?keys), + Err(e) => error!(endpoint = "api_keys", error = %e), + } + + match client.closed_only_mode().await { + Ok(status) => info!( + endpoint = "closed_only_mode", + closed_only = status.closed_only + ), + Err(e) => error!(endpoint = "closed_only_mode", error = %e), + } + + // Market order + let market_order = client + .market_order() + .token_id(token_id) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + let signed_order = client.sign(&signer, market_order).await?; + match client.post_order(signed_order).await { + Ok(r) => { + info!(endpoint = "post_order", order_type = "market", order_id = %r.order_id, success = r.success); + } + Err(e) => error!(endpoint = "post_order", order_type = "market", error = %e), + } + + // Limit order + let limit_order = client + .limit_order() + .token_id(token_id) + .order_type(OrderType::GTD) + .expiration(Utc::now() + TimeDelta::days(2)) + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .side(Side::Buy) + .build() + .await?; + let signed_order = client.sign(&signer, limit_order).await?; + match client.post_order(signed_order).await { + Ok(r) => { + info!(endpoint = "post_order", order_type = "limit", order_id = %r.order_id, success = r.success); + } + Err(e) => error!(endpoint = "post_order", order_type = "limit", error = %e), + } + + match client.notifications().await { + Ok(n) => info!(endpoint = "notifications", count = n.len()), + Err(e) => error!(endpoint = "notifications", error = %e), + } + + match client + .balance_allowance(BalanceAllowanceRequest::default()) + .await + { + Ok(b) => info!(endpoint = "balance_allowance", result = ?b), + Err(e) => error!(endpoint = "balance_allowance", error = %e), + } + + match client + .update_balance_allowance(UpdateBalanceAllowanceRequest::default()) + .await + { + Ok(b) => info!(endpoint = "update_balance_allowance", result = ?b), + Err(e) => error!(endpoint = "update_balance_allowance", error = %e), + } + + let order_id = "0xa1449ec0831c7d62f887c4653d0917f2445783ff30f0ca713d99c667fef17f2c"; + match client.order(order_id).await { + Ok(o) => info!(endpoint = "order", order_id = %order_id, status = ?o.status), + Err(e) => error!(endpoint = "order", order_id = %order_id, error = %e), + } + + match client.orders(&OrdersRequest::default(), None).await { + Ok(orders) => info!(endpoint = "orders", count = orders.data.len()), + Err(e) => error!(endpoint = "orders", error = %e), + } + + match client.cancel_order(order_id).await { + Ok(r) => info!(endpoint = "cancel_order", order_id = %order_id, result = ?r), + Err(e) => error!(endpoint = "cancel_order", order_id = %order_id, error = %e), + } + + match client.cancel_orders(&[order_id]).await { + Ok(r) => info!(endpoint = "cancel_orders", result = ?r), + Err(e) => error!(endpoint = "cancel_orders", error = %e), + } + + match client.cancel_all_orders().await { + Ok(r) => info!(endpoint = "cancel_all_orders", result = ?r), + Err(e) => error!(endpoint = "cancel_all_orders", error = %e), + } + + match client.orders(&OrdersRequest::default(), None).await { + Ok(orders) => info!( + endpoint = "orders", + after_cancel = true, + count = orders.data.len() + ), + Err(e) => error!(endpoint = "orders", after_cancel = true, error = %e), + } + + match client.trades(&TradesRequest::default(), None).await { + Ok(trades) => info!(endpoint = "trades", count = trades.data.len()), + Err(e) => error!(endpoint = "trades", error = %e), + } + + match client + .earnings_for_user_for_day(Utc::now().date_naive(), None) + .await + { + Ok(e) => info!(endpoint = "earnings_for_user_for_day", result = ?e), + Err(e) => error!(endpoint = "earnings_for_user_for_day", error = %e), + } + + let request = UserRewardsEarningRequest::builder() + .date(Utc::now().date_naive() - TimeDelta::days(30)) + .build(); + match client + .user_earnings_and_markets_config(&request, None) + .await + { + Ok(e) => info!(endpoint = "user_earnings_and_markets_config", result = ?e), + Err(e) => error!(endpoint = "user_earnings_and_markets_config", error = %e), + } + + match client.reward_percentages().await { + Ok(r) => info!(endpoint = "reward_percentages", result = ?r), + Err(e) => error!(endpoint = "reward_percentages", error = %e), + } + + match client.current_rewards(None).await { + Ok(r) => info!(endpoint = "current_rewards", result = ?r), + Err(e) => error!(endpoint = "current_rewards", error = %e), + } + + let market_id = "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1"; + match client.raw_rewards_for_market(market_id, None).await { + Ok(r) => info!(endpoint = "raw_rewards_for_market", market_id = %market_id, result = ?r), + Err(e) => error!(endpoint = "raw_rewards_for_market", market_id = %market_id, error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/aws_authenticated.rs b/polymarket-client-sdk/examples/clob/aws_authenticated.rs new file mode 100644 index 0000000..47ee92e --- /dev/null +++ b/polymarket-client-sdk/examples/clob/aws_authenticated.rs @@ -0,0 +1,69 @@ +//! Demonstrates AWS KMS-based authentication with the CLOB client. +//! +//! This example shows how to: +//! 1. Configure AWS SDK and KMS client +//! 2. Create an `AwsSigner` using a KMS key +//! 3. Authenticate with the CLOB API using the AWS signer +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example aws_authenticated --features clob,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=aws_authenticated.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example aws_authenticated --features clob,tracing +//! ``` +//! +//! Requires AWS credentials configured and a valid KMS key ID. + +use std::fs::File; + +use alloy::signers::Signer as _; +use alloy::signers::aws::AwsSigner; +use aws_config::BehaviorVersion; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::clob::{Client, Config}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let config = aws_config::load_defaults(BehaviorVersion::latest()).await; + let kms_client = aws_sdk_kms::Client::new(&config); + + let key_id = "".to_owned(); + info!(endpoint = "aws_signer", key_id = %key_id, "creating AWS KMS signer"); + + let alloy_signer = AwsSigner::new(kms_client, key_id, Some(POLYGON)) + .await? + .with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&alloy_signer) + .authenticate() + .await?; + + match client.api_keys().await { + Ok(keys) => info!(endpoint = "api_keys", result = ?keys), + Err(e) => error!(endpoint = "api_keys", error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/builder_authenticated.rs b/polymarket-client-sdk/examples/clob/builder_authenticated.rs new file mode 100644 index 0000000..8a695a6 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/builder_authenticated.rs @@ -0,0 +1,92 @@ +//! Demonstrates builder API authentication with the CLOB client. +//! +//! This example shows how to: +//! 1. Authenticate as a regular user +//! 2. Create builder API credentials +//! 3. Promote the client to a builder client +//! 4. Access builder-specific endpoints +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example builder_authenticated --features clob,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=builder_authenticated.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example builder_authenticated --features clob,tracing +//! ``` +//! +//! Requires `POLY_PRIVATE_KEY` environment variable to be set. + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::auth::builder::Config as BuilderConfig; +use polymarket_client_sdk::clob::types::request::TradesRequest; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::U256; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need POLY_PRIVATE_KEY"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + // Create builder credentials and promote to builder client + let builder_credentials = client.create_builder_api_key().await?; + info!( + endpoint = "create_builder_api_key", + "created builder credentials" + ); + + let config = BuilderConfig::local(builder_credentials); + let client = client.promote_to_builder(config).await?; + info!( + endpoint = "promote_to_builder", + "promoted to builder client" + ); + + match client.builder_api_keys().await { + Ok(keys) => info!(endpoint = "builder_api_keys", count = keys.len()), + Err(e) => error!(endpoint = "builder_api_keys", error = %e), + } + + let token_id = U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076", + )?; + let request = TradesRequest::builder().asset_id(token_id).build(); + + match client.builder_trades(&request, None).await { + Ok(trades) => { + info!(endpoint = "builder_trades", token_id = %token_id, count = trades.data.len()); + } + Err(e) => error!(endpoint = "builder_trades", token_id = %token_id, error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/heartbeats.rs b/polymarket-client-sdk/examples/clob/heartbeats.rs new file mode 100644 index 0000000..6fd1e88 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/heartbeats.rs @@ -0,0 +1,38 @@ +//! Shows how heartbeats are sent automatically when the corresponding feature flag is enabled. +//! +//! Run with: +//! ```sh +//! RUST_LOG=debug,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example heartbeats --features heartbeats,tracing +//! ``` +//! +use std::str::FromStr as _; +use std::time::Duration; + +use polymarket_client_sdk::auth::{LocalSigner, Signer as _}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let config = Config::builder() + .use_server_time(true) + .heartbeat_interval(Duration::from_secs(1)) + .build(); + let client = Client::new("https://clob.polymarket.com", config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + tokio::time::sleep(Duration::from_secs(5)).await; + + drop(client); + + tokio::time::sleep(Duration::from_secs(2)).await; + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/rfq/quotes.rs b/polymarket-client-sdk/examples/clob/rfq/quotes.rs new file mode 100644 index 0000000..1ed601a --- /dev/null +++ b/polymarket-client-sdk/examples/clob/rfq/quotes.rs @@ -0,0 +1,83 @@ +//! Demonstrates fetching RFQ quotes from the CLOB API. +//! +//! This example shows how to: +//! 1. Authenticate with the CLOB API +//! 2. Build an RFQ quotes request with filters +//! 3. Fetch and display paginated quote results +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example rfq_quotes --features clob,rfq,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=rfq_quotes.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example rfq_quotes --features clob,rfq,tracing +//! ``` +//! +//! Requires `POLY_PRIVATE_KEY` environment variable to be set. + +#![cfg(feature = "rfq")] + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::clob::types::{RfqQuotesRequest, RfqSortBy, RfqSortDir, RfqState}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tracing::{debug, error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need POLY_PRIVATE_KEY"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let request = RfqQuotesRequest::builder() + .state(RfqState::Active) + .limit(10) + .offset("MA==") + .sort_by(RfqSortBy::Price) + .sort_dir(RfqSortDir::Asc) + .build(); + + match client.quotes(&request, None).await { + Ok(quotes) => { + info!( + endpoint = "quotes", + count = quotes.count, + data_len = quotes.data.len(), + next_cursor = %quotes.next_cursor + ); + for quote in "es.data { + debug!(endpoint = "quotes", quote = ?quote); + } + } + Err(e) => error!(endpoint = "quotes", error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/rfq/requests.rs b/polymarket-client-sdk/examples/clob/rfq/requests.rs new file mode 100644 index 0000000..99ee329 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/rfq/requests.rs @@ -0,0 +1,83 @@ +//! Demonstrates fetching RFQ requests from the CLOB API. +//! +//! This example shows how to: +//! 1. Authenticate with the CLOB API +//! 2. Build an RFQ requests query with filters +//! 3. Fetch and display paginated request results +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example rfq_requests --features clob,rfq,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=rfq_requests.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example rfq_requests --features clob,rfq,tracing +//! ``` +//! +//! Requires `POLY_PRIVATE_KEY` environment variable to be set. + +#![cfg(feature = "rfq")] + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use polymarket_client_sdk::clob::types::{RfqRequestsRequest, RfqSortBy, RfqSortDir, RfqState}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tracing::{debug, error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need POLY_PRIVATE_KEY"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let request = RfqRequestsRequest::builder() + .state(RfqState::Active) + .limit(10) + .offset("MA==") + .sort_by(RfqSortBy::Created) + .sort_dir(RfqSortDir::Desc) + .build(); + + match client.requests(&request, None).await { + Ok(requests) => { + info!( + endpoint = "requests", + count = requests.count, + data_len = requests.data.len(), + next_cursor = %requests.next_cursor + ); + for req in &requests.data { + debug!(endpoint = "requests", request = ?req); + } + } + Err(e) => error!(endpoint = "requests", error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/streaming.rs b/polymarket-client-sdk/examples/clob/streaming.rs new file mode 100644 index 0000000..ee5dc23 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/streaming.rs @@ -0,0 +1,157 @@ +//! CLOB API streaming endpoint explorer. +//! +//! This example demonstrates streaming data from CLOB API endpoints by: +//! 1. Streaming `sampling_markets` (unauthenticated) to discover market data +//! 2. Streaming trades (authenticated) if credentials are available +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example streaming --features tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=streaming.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example streaming --features tracing +//! ``` +//! +//! For authenticated streaming, set the `POLY_PRIVATE_KEY` environment variable: +//! ```sh +//! POLY_PRIVATE_KEY=0x... RUST_LOG=info cargo run --example streaming --features tracing +//! ``` + +use std::fs::File; +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use futures::{StreamExt as _, future}; +use polymarket_client_sdk::clob::types::request::TradesRequest; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tokio::join; +use tracing::{debug, info, warn}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let (unauthenticated, authenticated) = join!(unauthenticated(), authenticated()); + unauthenticated?; + authenticated +} + +async fn unauthenticated() -> anyhow::Result<()> { + let client = Client::new("https://clob.polymarket.com", Config::default())?; + + info!( + stream = "sampling_markets", + "starting unauthenticated stream" + ); + + let mut stream = client + .stream_data(Client::sampling_markets) + .filter_map(|d| future::ready(d.ok())) + .boxed(); + + let mut count = 0_u32; + + while let Some(market) = stream.next().await { + count += 1; + + // Log every 100th market to avoid flooding logs + if count % 100 == 1 { + if let Some(cid) = &market.condition_id { + info!( + stream = "sampling_markets", + count = count, + condition_id = %cid, + question = %market.question, + active = market.active + ); + } else { + info!( + stream = "sampling_markets", + count = count, + question = %market.question, + active = market.active + ); + } + } + } + + info!( + stream = "sampling_markets", + total_markets = count, + "stream completed" + ); + + Ok(()) +} + +async fn authenticated() -> anyhow::Result<()> { + let Ok(private_key) = std::env::var(PRIVATE_KEY_VAR) else { + warn!( + stream = "trades", + "skipping authenticated stream - {} not set", PRIVATE_KEY_VAR + ); + return Ok(()); + }; + + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); + + let client = Client::new("https://clob.polymarket.com", Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + info!(stream = "trades", "starting authenticated stream"); + + let request = TradesRequest::builder().build(); + let mut stream = client + .stream_data(|c, cursor| c.trades(&request, cursor)) + .boxed(); + + let mut count = 0_u32; + + while let Some(result) = stream.next().await { + match result { + Ok(trade) => { + count += 1; + + // Log every 100th trade to avoid flooding logs + if count % 100 == 1 { + info!( + stream = "trades", + count = count, + market = %trade.market, + side = ?trade.side, + size = %trade.size, + price = %trade.price + ); + } + } + Err(e) => { + debug!(stream = "trades", error = %e, "stream error"); + } + } + } + + info!(stream = "trades", total_trades = count, "stream completed"); + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/unauthenticated.rs b/polymarket-client-sdk/examples/clob/unauthenticated.rs new file mode 100644 index 0000000..3d78f9d --- /dev/null +++ b/polymarket-client-sdk/examples/clob/unauthenticated.rs @@ -0,0 +1,306 @@ +//! Comprehensive CLOB API endpoint explorer (unauthenticated). +//! +//! This example dynamically tests all unauthenticated CLOB API endpoints by: +//! 1. Fetching markets to discover real token IDs and condition IDs +//! 2. Using those IDs for subsequent price, orderbook, and trade queries +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example unauthenticated --features tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=clob.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example unauthenticated --features tracing +//! ``` + +use std::collections::HashMap; +use std::fs::File; + +use futures_util::StreamExt as _; +use polymarket_client_sdk::clob::types::Side; +use polymarket_client_sdk::clob::types::request::{ + LastTradePriceRequest, MidpointRequest, OrderBookSummaryRequest, PriceRequest, SpreadRequest, +}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::{B256, Decimal, U256}; +use tracing::{error, info, warn}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +/// Finds a market with an active orderbook by streaming through all markets. +/// +/// Returns a tuple of (`token_id`, `condition_id`) from a market that: +/// - Has orderbook enabled (`enable_order_book` = true) +/// - Is active and not closed +/// - Is accepting orders +/// - Has tokens with non-zero prices +/// +/// This ensures subsequent price/midpoint/orderbook API calls will succeed. +async fn find_market_with_orderbook(client: &Client) -> anyhow::Result<(U256, B256)> { + info!("Searching for a market with an active orderbook..."); + + let mut stream = Box::pin(client.stream_data(Client::markets)); + + while let Some(maybe_market) = stream.next().await { + match maybe_market { + Ok(market) => { + if market.enable_order_book + && market.active + && !market.closed + && !market.archived + && market.accepting_orders + && !market.tokens.is_empty() + && market.tokens.iter().any(|t| t.price > Decimal::ZERO) + { + let condition_id = market + .condition_id + .ok_or_else(|| anyhow::anyhow!("Market missing condition_id"))?; + let token_id = market + .tokens + .first() + .map(|t| t.token_id) + .ok_or_else(|| anyhow::anyhow!("Market has no tokens"))?; + + let request = MidpointRequest::builder().token_id(token_id).build(); + if client.midpoint(&request).await.is_ok() { + info!( + condition_id = %condition_id, + token_id = %token_id, + question = %market.question, + "Found market with active orderbook" + ); + + return Ok((token_id, condition_id)); + } + } + } + Err(e) => { + error!(error = ?e, "Error fetching market"); + } + } + } + + Err(anyhow::anyhow!( + "No active markets with orderbooks found after searching all markets" + )) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::new("https://clob.polymarket.com", Config::default())?; + + // Health check endpoints + match client.ok().await { + Ok(_) => info!(endpoint = "ok", status = "healthy"), + Err(e) => error!(endpoint = "ok", error = %e), + } + + match client.server_time().await { + Ok(time) => info!(endpoint = "server_time", timestamp = %time), + Err(e) => error!(endpoint = "server_time", error = %e), + } + + let (token_id, condition_id) = match find_market_with_orderbook(&client).await { + Ok((tid, cid)) => (Some(tid), Some(cid)), + Err(e) => { + error!("Failed to find market with orderbook: {}", e); + (None, None) + } + }; + + if let Some(cid) = &condition_id { + match client.market(&cid.to_string()).await { + Ok(market) => info!( + endpoint = "market", + condition_id = %cid, + question = %market.question, + active = market.active + ), + Err(e) => error!(endpoint = "market", condition_id = %cid, error = %e), + } + } + + match client.sampling_markets(None).await { + Ok(page) => info!( + endpoint = "sampling_markets", + count = page.data.len(), + has_next = !page.next_cursor.is_empty() + ), + Err(e) => error!(endpoint = "sampling_markets", error = %e), + } + + match client.simplified_markets(None).await { + Ok(page) => info!( + endpoint = "simplified_markets", + count = page.data.len(), + has_next = !page.next_cursor.is_empty() + ), + Err(e) => error!(endpoint = "simplified_markets", error = %e), + } + + match client.sampling_simplified_markets(None).await { + Ok(page) => info!( + endpoint = "sampling_simplified_markets", + count = page.data.len(), + has_next = !page.next_cursor.is_empty() + ), + Err(e) => error!(endpoint = "sampling_simplified_markets", error = %e), + } + + if let Some(token_id) = token_id { + let midpoint_request = MidpointRequest::builder().token_id(token_id).build(); + match client.midpoint(&midpoint_request).await { + Ok(midpoint) => info!(endpoint = "midpoint", token_id = %token_id, mid = %midpoint.mid), + Err(e) => error!(endpoint = "midpoint", token_id = %token_id, error = %e), + } + + match client.midpoints(&[midpoint_request]).await { + Ok(midpoints) => info!(endpoint = "midpoints", count = midpoints.midpoints.len()), + Err(e) => error!(endpoint = "midpoints", error = %e), + } + + let buy_price_request = PriceRequest::builder() + .token_id(token_id) + .side(Side::Buy) + .build(); + match client.price(&buy_price_request).await { + Ok(price) => info!( + endpoint = "price", + token_id = %token_id, + side = "buy", + price = %price.price + ), + Err(e) => error!(endpoint = "price", token_id = %token_id, side = "buy", error = %e), + } + + let sell_price_request = PriceRequest::builder() + .token_id(token_id) + .side(Side::Sell) + .build(); + match client.price(&sell_price_request).await { + Ok(price) => info!( + endpoint = "price", + token_id = %token_id, + side = "sell", + price = %price.price + ), + Err(e) => error!(endpoint = "price", token_id = %token_id, side = "sell", error = %e), + } + + match client + .prices(&[buy_price_request, sell_price_request]) + .await + { + Ok(prices) => info!( + endpoint = "prices", + count = prices.prices.as_ref().map_or(0, HashMap::len) + ), + Err(e) => error!(endpoint = "prices", error = %e), + } + + let spread_request = SpreadRequest::builder().token_id(token_id).build(); + match client.spread(&spread_request).await { + Ok(spread) => info!( + endpoint = "spread", + token_id = %token_id, + spread = %spread.spread + ), + Err(e) => error!(endpoint = "spread", token_id = %token_id, error = %e), + } + + match client.spreads(&[spread_request]).await { + Ok(spreads) => info!( + endpoint = "spreads", + count = spreads.spreads.as_ref().map_or(0, HashMap::len) + ), + Err(e) => error!(endpoint = "spreads", error = %e), + } + + match client.tick_size(token_id).await { + Ok(tick_size) => info!( + endpoint = "tick_size", + token_id = %token_id, + tick_size = %tick_size.minimum_tick_size + ), + Err(e) => error!(endpoint = "tick_size", token_id = %token_id, error = %e), + } + + match client.neg_risk(token_id).await { + Ok(neg_risk) => info!( + endpoint = "neg_risk", + token_id = %token_id, + neg_risk = neg_risk.neg_risk + ), + Err(e) => error!(endpoint = "neg_risk", token_id = %token_id, error = %e), + } + + match client.fee_rate_bps(token_id).await { + Ok(fee_rate) => info!( + endpoint = "fee_rate_bps", + token_id = %token_id, + base_fee = fee_rate.base_fee + ), + Err(e) => error!(endpoint = "fee_rate_bps", token_id = %token_id, error = %e), + } + + let order_book_request = OrderBookSummaryRequest::builder() + .token_id(token_id) + .build(); + match client.order_book(&order_book_request).await { + Ok(book) => { + let hash = book.hash().unwrap_or_default(); + info!( + endpoint = "order_book", + token_id = %token_id, + bids = book.bids.len(), + asks = book.asks.len(), + hash = %hash + ); + } + Err(e) => error!(endpoint = "order_book", token_id = %token_id, error = %e), + } + + match client.order_books(&[order_book_request]).await { + Ok(books) => info!(endpoint = "order_books", count = books.len()), + Err(e) => error!(endpoint = "order_books", error = %e), + } + + let last_trade_request = LastTradePriceRequest::builder().token_id(token_id).build(); + match client.last_trade_price(&last_trade_request).await { + Ok(last_trade) => info!( + endpoint = "last_trade_price", + token_id = %token_id, + price = %last_trade.price + ), + Err(e) => error!(endpoint = "last_trade_price", token_id = %token_id, error = %e), + } + + match client.last_trades_prices(&[last_trade_request]).await { + Ok(prices) => info!(endpoint = "last_trade_prices", count = prices.len()), + Err(e) => error!(endpoint = "last_trade_prices", error = %e), + } + } else { + warn!( + endpoint = "price_queries", + "skipped - no token_id discovered" + ); + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/ws/orderbook.rs b/polymarket-client-sdk/examples/clob/ws/orderbook.rs new file mode 100644 index 0000000..1e07ba2 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/ws/orderbook.rs @@ -0,0 +1,106 @@ +//! Demonstrates subscribing to real-time orderbook updates via WebSocket. +//! +//! This example shows how to: +//! 1. Connect to the CLOB WebSocket API +//! 2. Subscribe to orderbook updates for multiple assets +//! 3. Process and display bid/ask updates in real-time +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_orderbook --features ws,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=websocket_orderbook.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_orderbook --features ws,tracing +//! ``` + +use std::fs::File; +use std::str::FromStr as _; + +use futures::StreamExt as _; +use polymarket_client_sdk::clob::ws::Client; +use polymarket_client_sdk::types::U256; +use tracing::{debug, error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + info!(endpoint = "websocket", "connected to CLOB WebSocket API"); + + let asset_ids = vec![ + U256::from_str( + "92703761682322480664976766247614127878023988651992837287050266308961660624165", + )?, + U256::from_str( + "34551606549875928972193520396544368029176529083448203019529657908155427866742", + )?, + ]; + + let stream = client.subscribe_orderbook(asset_ids.clone())?; + let mut stream = Box::pin(stream); + info!( + endpoint = "subscribe_orderbook", + asset_count = asset_ids.len(), + "subscribed to orderbook updates" + ); + + while let Some(book_result) = stream.next().await { + match book_result { + Ok(book) => { + info!( + endpoint = "orderbook", + asset_id = %book.asset_id, + market = %book.market, + timestamp = %book.timestamp, + bids = book.bids.len(), + asks = book.asks.len() + ); + + for (i, bid) in book.bids.iter().take(5).enumerate() { + debug!( + endpoint = "orderbook", + side = "bid", + rank = i + 1, + size = %bid.size, + price = %bid.price + ); + } + + for (i, ask) in book.asks.iter().take(5).enumerate() { + debug!( + endpoint = "orderbook", + side = "ask", + rank = i + 1, + size = %ask.size, + price = %ask.price + ); + } + + if let Some(hash) = &book.hash { + debug!(endpoint = "orderbook", hash = %hash); + } + } + Err(e) => error!(endpoint = "orderbook", error = %e), + } + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/ws/unsubscribe.rs b/polymarket-client-sdk/examples/clob/ws/unsubscribe.rs new file mode 100644 index 0000000..f0e3bb4 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/ws/unsubscribe.rs @@ -0,0 +1,168 @@ +//! Demonstrates WebSocket subscribe/unsubscribe and multiplexing behavior. +//! +//! This example shows how to: +//! 1. Subscribe multiple streams to the same asset (multiplexing) +//! 2. Unsubscribe streams while others remain active +//! 3. Verify reference counting works correctly +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_unsubscribe --features ws,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=websocket_unsubscribe.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_unsubscribe --features ws,tracing +//! ``` +//! +//! With debug level, you can see subscribe/unsubscribe wire messages: +//! ```sh +//! RUST_LOG=debug,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_unsubscribe --features ws,tracing +//! ``` + +use std::fs::File; +use std::str::FromStr as _; +use std::time::Duration; + +use futures::StreamExt as _; +use polymarket_client_sdk::clob::ws::Client; +use polymarket_client_sdk::types::U256; +use tokio::time::timeout; +use tracing::{debug, error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + info!(endpoint = "websocket", "connected to CLOB WebSocket API"); + + let asset_ids = vec![U256::from_str( + "92703761682322480664976766247614127878023988651992837287050266308961660624165", + )?]; + + // === FIRST SUBSCRIPTION === + info!( + step = 1, + "first subscription - should send 'subscribe' to server" + ); + let stream1 = client.subscribe_orderbook(asset_ids.clone())?; + let mut stream1 = Box::pin(stream1); + + match timeout(Duration::from_secs(10), stream1.next()).await { + Ok(Some(Ok(book))) => { + info!( + step = 1, + endpoint = "orderbook", + bids = book.bids.len(), + asks = book.asks.len(), + "received update on stream1" + ); + } + Ok(Some(Err(e))) => error!(step = 1, error = %e), + Ok(None) => error!(step = 1, "stream ended"), + Err(_) => error!(step = 1, "timeout"), + } + + // === SECOND SUBSCRIPTION (same asset - should multiplex) === + info!( + step = 2, + "second subscription (same asset) - should NOT send message (multiplexing)" + ); + let stream2 = client.subscribe_orderbook(asset_ids.clone())?; + let mut stream2 = Box::pin(stream2); + + match timeout(Duration::from_secs(10), stream2.next()).await { + Ok(Some(Ok(book))) => { + info!( + step = 2, + endpoint = "orderbook", + bids = book.bids.len(), + asks = book.asks.len(), + "received update on stream2" + ); + } + Ok(Some(Err(e))) => error!(step = 2, error = %e), + Ok(None) => error!(step = 2, "stream ended"), + Err(_) => error!(step = 2, "timeout"), + } + + // === FIRST UNSUBSCRIBE === + info!( + step = 3, + "first unsubscribe - should NOT send message (refcount still 1)" + ); + client.unsubscribe_orderbook(&asset_ids)?; + drop(stream1); + info!(step = 3, "stream1 unsubscribed and dropped"); + + // stream2 should still work + match timeout(Duration::from_secs(10), stream2.next()).await { + Ok(Some(Ok(book))) => { + info!( + step = 3, + endpoint = "orderbook", + bids = book.bids.len(), + asks = book.asks.len(), + "stream2 still receiving updates" + ); + } + Ok(Some(Err(e))) => error!(step = 3, error = %e), + Ok(None) => error!(step = 3, "stream ended"), + Err(_) => error!(step = 3, "timeout"), + } + + // === SECOND UNSUBSCRIBE === + info!( + step = 4, + "second unsubscribe - should send 'unsubscribe' (refcount now 0)" + ); + client.unsubscribe_orderbook(&asset_ids)?; + drop(stream2); + info!(step = 4, "stream2 unsubscribed and dropped"); + + // === RE-SUBSCRIBE (proves unsubscribe worked) === + info!( + step = 5, + "re-subscribe - should send 'subscribe' (proves unsubscribe worked)" + ); + let stream3 = client.subscribe_orderbook(asset_ids)?; + let mut stream3 = Box::pin(stream3); + + match timeout(Duration::from_secs(10), stream3.next()).await { + Ok(Some(Ok(book))) => { + info!( + step = 5, + endpoint = "orderbook", + bids = book.bids.len(), + asks = book.asks.len(), + "stream3 receiving updates" + ); + } + Ok(Some(Err(e))) => error!(step = 5, error = %e), + Ok(None) => error!(step = 5, "stream ended"), + Err(_) => error!(step = 5, "timeout"), + } + + info!("example complete"); + debug!( + "with debug logging, you should see subscribe/unsubscribe wire messages at steps 1, 4, and 5" + ); + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/clob/ws/user.rs b/polymarket-client-sdk/examples/clob/ws/user.rs new file mode 100644 index 0000000..771eaa7 --- /dev/null +++ b/polymarket-client-sdk/examples/clob/ws/user.rs @@ -0,0 +1,120 @@ +//! Demonstrates subscribing to authenticated user WebSocket channels. +//! +//! This example shows how to: +//! 1. Build credentials for authenticated WebSocket access +//! 2. Subscribe to user-specific order and trade events +//! 3. Process real-time order updates and trade notifications +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_user --features ws,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=websocket_user.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example websocket_user --features ws,tracing +//! ``` +//! +//! Requires the following environment variables: +//! - `POLYMARKET_API_KEY` +//! - `POLYMARKET_API_SECRET` +//! - `POLYMARKET_API_PASSPHRASE` +//! - `POLYMARKET_ADDRESS` + +use std::fs::File; +use std::str::FromStr as _; + +use futures::StreamExt as _; +use polymarket_client_sdk::auth::Credentials; +use polymarket_client_sdk::clob::ws::{Client, WsMessage}; +use polymarket_client_sdk::types::{Address, B256}; +use tracing::{debug, error, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let api_key = Uuid::parse_str(&std::env::var("POLYMARKET_API_KEY")?)?; + let api_secret = std::env::var("POLYMARKET_API_SECRET")?; + let api_passphrase = std::env::var("POLYMARKET_API_PASSPHRASE")?; + let address = Address::from_str(&std::env::var("POLYMARKET_ADDRESS")?)?; + + let credentials = Credentials::new(api_key, api_secret, api_passphrase); + + let client = Client::default().authenticate(credentials, address)?; + info!( + endpoint = "websocket", + authenticated = true, + "connected to authenticated WebSocket" + ); + + // Provide specific market IDs, or leave empty for all events + let markets: Vec = Vec::new(); + let mut stream = std::pin::pin!(client.subscribe_user_events(markets)?); + info!( + endpoint = "subscribe_user_events", + "subscribed to user events" + ); + + while let Some(event) = stream.next().await { + match event { + Ok(WsMessage::Order(order)) => { + info!( + endpoint = "user_events", + event_type = "order", + order_id = %order.id, + market = %order.market, + msg_type = ?order.msg_type, + side = ?order.side, + price = %order.price + ); + if let Some(size) = &order.original_size { + debug!(endpoint = "user_events", original_size = %size); + } + if let Some(matched) = &order.size_matched { + debug!(endpoint = "user_events", size_matched = %matched); + } + } + Ok(WsMessage::Trade(trade)) => { + info!( + endpoint = "user_events", + event_type = "trade", + trade_id = %trade.id, + market = %trade.market, + status = ?trade.status, + side = ?trade.side, + size = %trade.size, + price = %trade.price + ); + if let Some(trader_side) = &trade.trader_side { + debug!(endpoint = "user_events", trader_side = ?trader_side); + } + } + Ok(other) => { + debug!(endpoint = "user_events", event = ?other); + } + Err(e) => { + error!(endpoint = "user_events", error = %e); + break; + } + } + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/ctf.rs b/polymarket-client-sdk/examples/ctf.rs new file mode 100644 index 0000000..7ea1352 --- /dev/null +++ b/polymarket-client-sdk/examples/ctf.rs @@ -0,0 +1,225 @@ +#![allow(clippy::exhaustive_enums, reason = "Fine for examples")] +#![allow(clippy::exhaustive_structs, reason = "Fine for examples")] + +//! CTF (Conditional Token Framework) example. +//! +//! This example demonstrates how to interact with the CTF contract to: +//! - Calculate condition IDs, collection IDs, and position IDs +//! - Split USDC collateral into outcome tokens (YES/NO) +//! - Merge outcome tokens back into USDC +//! - Redeem winning tokens after market resolution +//! +//! ## Usage +//! +//! For read-only operations (ID calculations): +//! ```sh +//! cargo run --example ctf --features ctf +//! ``` +//! +//! For write operations (split, merge, redeem), you need a private key: +//! ```sh +//! export POLYMARKET_PRIVATE_KEY="your_private_key" +//! cargo run --example ctf --features ctf -- --write +//! ``` + +use std::env; +use std::str::FromStr as _; + +use alloy::primitives::{B256, U256}; +use alloy::providers::ProviderBuilder; +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use anyhow::Result; +use polymarket_client_sdk::ctf::Client; +use polymarket_client_sdk::ctf::types::{ + CollectionIdRequest, ConditionIdRequest, MergePositionsRequest, PositionIdRequest, + RedeemPositionsRequest, SplitPositionRequest, +}; +use polymarket_client_sdk::types::address; +use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +use tracing::{error, info}; + +const RPC_URL: &str = "https://polygon-rpc.com"; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args: Vec = env::args().collect(); + let write_mode = args.iter().any(|arg| arg == "--write"); + + let chain = POLYGON; + info!("=== CTF (Conditional Token Framework) Example ==="); + + // For read-only operations, we don't need a wallet + let provider = ProviderBuilder::new().connect(RPC_URL).await?; + let client = Client::new(provider, chain)?; + + info!("Connected to Polygon {chain}"); + info!("CTF contract: 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"); + + // Example: Calculate a condition ID + info!("--- Calculating Condition ID ---"); + let oracle = address!("0x0000000000000000000000000000000000000001"); + let question_id = B256::ZERO; + let outcome_slot_count = U256::from(2); + + let condition_req = ConditionIdRequest::builder() + .oracle(oracle) + .question_id(question_id) + .outcome_slot_count(outcome_slot_count) + .build(); + + let condition_resp = client.condition_id(&condition_req).await?; + info!("Oracle: {oracle}"); + info!("Question ID: {question_id}"); + info!("Outcome Slots: {outcome_slot_count}"); + info!("→ Condition ID: {}", condition_resp.condition_id); + + // Example: Calculate collection IDs for YES and NO tokens + info!("--- Calculating Collection IDs ---"); + let parent_collection_id = B256::ZERO; + + // Collection ID for YES token (index set = 0b01 = 1) + let yes_collection_req = CollectionIdRequest::builder() + .parent_collection_id(parent_collection_id) + .condition_id(condition_resp.condition_id) + .index_set(U256::from(1)) + .build(); + + let yes_collection_resp = client.collection_id(&yes_collection_req).await?; + info!("YES token (index set = 1):"); + info!("→ Collection ID: {}", yes_collection_resp.collection_id); + + // Collection ID for NO token (index set = 0b10 = 2) + let no_collection_req = CollectionIdRequest::builder() + .parent_collection_id(parent_collection_id) + .condition_id(condition_resp.condition_id) + .index_set(U256::from(2)) + .build(); + + let no_collection_resp = client.collection_id(&no_collection_req).await?; + info!("NO token (index set = 2):"); + info!("→ Collection ID: {}", no_collection_resp.collection_id); + + // Example: Calculate position IDs (ERC1155 token IDs) + info!("--- Calculating Position IDs ---"); + let usdc = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + + let yes_position_req = PositionIdRequest::builder() + .collateral_token(usdc) + .collection_id(yes_collection_resp.collection_id) + .build(); + + let yes_position_resp = client.position_id(&yes_position_req).await?; + info!( + "YES position (ERC1155 token ID): {}", + yes_position_resp.position_id + ); + + let no_position_req = PositionIdRequest::builder() + .collateral_token(usdc) + .collection_id(no_collection_resp.collection_id) + .build(); + + let no_position_resp = client.position_id(&no_position_req).await?; + info!( + "NO position (ERC1155 token ID): {}", + no_position_resp.position_id + ); + + // Write operations require a wallet + if write_mode { + info!("--- Write Operations (requires wallet) ---"); + + let private_key = + env::var(PRIVATE_KEY_VAR).expect("Need a private key for write operations"); + let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(chain)); + + let provider = ProviderBuilder::new() + .wallet(signer.clone()) + .connect(RPC_URL) + .await?; + + let client = Client::new(provider, chain)?; + let wallet_address = signer.address(); + + info!("Using wallet: {wallet_address:?}"); + + // Example: Split 1 USDC into YES and NO tokens (using convenience method) + info!("--- Splitting Position (Binary Market) ---"); + info!("This will split 1 USDC into 1 YES and 1 NO token"); + info!("Note: You must approve the CTF contract to spend your USDC first!"); + + // Using the convenience method for binary markets + let split_req = SplitPositionRequest::for_binary_market( + usdc, + condition_resp.condition_id, + U256::from(1_000_000), // 1 USDC (6 decimals) + ); + + match client.split_position(&split_req).await { + Ok(split_resp) => { + info!("✓ Split transaction successful!"); + info!(" Transaction hash: {}", split_resp.transaction_hash); + info!(" Block number: {}", split_resp.block_number); + } + Err(e) => { + error!("✗ Split failed: {e}"); + error!(" Make sure you have approved the CTF contract and have sufficient USDC"); + } + } + + // Example: Merge YES and NO tokens back into USDC (using convenience method) + info!("--- Merging Positions (Binary Market) ---"); + info!("This will merge 1 YES and 1 NO token back into 1 USDC"); + + // Using the convenience method for binary markets + let merge_req = MergePositionsRequest::for_binary_market( + usdc, + condition_resp.condition_id, + U256::from(1_000_000), // 1 full set + ); + + match client.merge_positions(&merge_req).await { + Ok(merge_resp) => { + info!("✓ Merge transaction successful!"); + info!(" Transaction hash: {}", merge_resp.transaction_hash); + info!(" Block number: {}", merge_resp.block_number); + } + Err(e) => { + error!("✗ Merge failed: {e}"); + error!(" Make sure you have sufficient YES and NO tokens"); + } + } + + // Example: Redeem winning tokens + info!("--- Redeeming Positions ---"); + info!("This redeems winning tokens after market resolution"); + + // Using the convenience method for binary markets (redeems both YES and NO tokens) + let redeem_req = + RedeemPositionsRequest::for_binary_market(usdc, condition_resp.condition_id); + + match client.redeem_positions(&redeem_req).await { + Ok(redeem_resp) => { + info!("✓ Redeem transaction successful!"); + info!(" Transaction hash: {}", redeem_resp.transaction_hash); + info!(" Block number: {}", redeem_resp.block_number); + } + Err(e) => { + error!("✗ Redeem failed: {e}"); + error!(" Make sure the condition is resolved and you have winning tokens"); + } + } + } else { + info!("--- Write Operations ---"); + info!("To test write operations (split, merge, redeem), run with --write flag:"); + info!(" export POLYMARKET_PRIVATE_KEY=\"your_private_key\""); + info!(" cargo run --example ctf --features ctf -- --write"); + } + + info!("=== Example Complete ==="); + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/data.rs b/polymarket-client-sdk/examples/data.rs new file mode 100644 index 0000000..206b31b --- /dev/null +++ b/polymarket-client-sdk/examples/data.rs @@ -0,0 +1,337 @@ +//! Comprehensive Data API endpoint explorer. +//! +//! This example dynamically tests all Data API endpoints by: +//! 1. Fetching leaderboard data to discover real trader addresses +//! 2. Using those addresses for user-specific queries +//! 3. Extracting market IDs from positions for holder lookups +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example data --features data,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=data.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example data --features data,tracing +//! ``` + +use std::fs::File; + +use polymarket_client_sdk::data::Client; +use polymarket_client_sdk::data::types::request::{ + ActivityRequest, BuilderLeaderboardRequest, BuilderVolumeRequest, ClosedPositionsRequest, + HoldersRequest, LiveVolumeRequest, OpenInterestRequest, PositionsRequest, TradedRequest, + TraderLeaderboardRequest, TradesRequest, ValueRequest, +}; +use polymarket_client_sdk::data::types::{LeaderboardCategory, TimePeriod}; +use polymarket_client_sdk::types::{Address, B256, address, b256}; +use tracing::{debug, error, info, warn}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + + // Fallback test data when dynamic discovery fails + let fallback_user = address!("56687bf447db6ffa42ffe2204a05edaa20f55839"); + let fallback_market = b256!("dd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"); + + // Health check + match client.health().await { + Ok(status) => info!(endpoint = "health", status = %status.data), + Err(e) => error!(endpoint = "health", error = %e), + } + + // Fetch leaderboard to get real trader addresses + let leaderboard_result = client + .leaderboard( + &TraderLeaderboardRequest::builder() + .category(LeaderboardCategory::Overall) + .time_period(TimePeriod::Week) + .limit(10)? + .build(), + ) + .await; + + let user: Option

= match &leaderboard_result { + Ok(traders) => { + info!(endpoint = "leaderboard", count = traders.len()); + if let Some(trader) = traders.first() { + info!( + endpoint = "leaderboard", + rank = %trader.rank, + address = %trader.proxy_wallet, + pnl = %trader.pnl, + volume = %trader.vol + ); + Some(trader.proxy_wallet) + } else { + None + } + } + Err(e) => { + warn!(endpoint = "leaderboard", error = %e, "using fallback user"); + Some(fallback_user) + } + }; + + // Fetch positions for the discovered user + let market_id: Option = if let Some(user) = user { + let positions_result = client + .positions(&PositionsRequest::builder().user(user).limit(10)?.build()) + .await; + + match &positions_result { + Ok(positions) => { + info!(endpoint = "positions", user = %user, count = positions.len()); + if let Some(pos) = positions.first() { + info!( + endpoint = "positions", + market = %pos.condition_id, + size = %pos.size, + value = %pos.current_value + ); + Some(pos.condition_id) + } else { + // No positions found, use fallback market + warn!( + endpoint = "positions", + "no positions, using fallback market" + ); + Some(fallback_market) + } + } + Err(e) => { + warn!(endpoint = "positions", user = %user, error = %e, "using fallback market"); + Some(fallback_market) + } + } + } else { + debug!(endpoint = "positions", "skipped - no user address found"); + Some(fallback_market) + }; + + // Fetch holders for the discovered market + if let Some(market) = market_id { + match client + .holders( + &HoldersRequest::builder() + .markets(vec![market]) + .limit(5)? + .build(), + ) + .await + { + Ok(meta_holders) => { + info!(endpoint = "holders", market = %market, tokens = meta_holders.len()); + if let Some(meta) = meta_holders.first() { + info!( + endpoint = "holders", + token = %meta.token, + holders_count = meta.holders.len() + ); + if let Some(holder) = meta.holders.first() { + info!( + endpoint = "holders", + address = %holder.proxy_wallet, + amount = %holder.amount + ); + } + } + } + Err(e) => error!(endpoint = "holders", market = %market, error = %e), + } + } + + // User activity, value, closed positions, and traded count + if let Some(user) = user { + match client + .activity(&ActivityRequest::builder().user(user).limit(5)?.build()) + .await + { + Ok(activities) => { + info!(endpoint = "activity", user = %user, count = activities.len()); + if let Some(act) = activities.first() { + info!( + endpoint = "activity", + activity_type = ?act.activity_type, + transaction = %act.transaction_hash + ); + } + } + Err(e) => error!(endpoint = "activity", user = %user, error = %e), + } + + match client + .value(&ValueRequest::builder().user(user).build()) + .await + { + Ok(values) => { + info!(endpoint = "value", user = %user, count = values.len()); + if let Some(value) = values.first() { + info!( + endpoint = "value", + user = %value.user, + total = %value.value + ); + } + } + Err(e) => error!(endpoint = "value", user = %user, error = %e), + } + + match client + .closed_positions( + &ClosedPositionsRequest::builder() + .user(user) + .limit(5)? + .build(), + ) + .await + { + Ok(positions) => { + info!(endpoint = "closed_positions", user = %user, count = positions.len()); + if let Some(pos) = positions.first() { + info!( + endpoint = "closed_positions", + market = %pos.condition_id, + realized_pnl = %pos.realized_pnl + ); + } + } + Err(e) => error!(endpoint = "closed_positions", user = %user, error = %e), + } + + match client + .traded(&TradedRequest::builder().user(user).build()) + .await + { + Ok(traded) => { + info!( + endpoint = "traded", + user = %user, + markets_traded = traded.traded + ); + } + Err(e) => error!(endpoint = "traded", user = %user, error = %e), + } + } + + // Trades - global trade feed + match client.trades(&TradesRequest::default()).await { + Ok(trades) => { + info!(endpoint = "trades", count = trades.len()); + if let Some(trade) = trades.first() { + info!( + endpoint = "trades", + market = %trade.condition_id, + side = ?trade.side, + size = %trade.size, + price = %trade.price + ); + } + } + Err(e) => error!(endpoint = "trades", error = %e), + } + + // Open interest + match client.open_interest(&OpenInterestRequest::default()).await { + Ok(oi_list) => { + info!(endpoint = "open_interest", count = oi_list.len()); + if let Some(oi) = oi_list.first() { + info!( + endpoint = "open_interest", + market = ?oi.market, + value = %oi.value + ); + } + } + Err(e) => error!(endpoint = "open_interest", error = %e), + } + + // Live volume (using event ID 1 as example) + match client + .live_volume(&LiveVolumeRequest::builder().id(1).build()) + .await + { + Ok(volumes) => { + info!( + endpoint = "live_volume", + event_id = 1, + count = volumes.len() + ); + if let Some(vol) = volumes.first() { + info!( + endpoint = "live_volume", + total = %vol.total, + markets = vol.markets.len() + ); + } + } + Err(e) => error!(endpoint = "live_volume", event_id = 1, error = %e), + } + + // Builder leaderboard + match client + .builder_leaderboard( + &BuilderLeaderboardRequest::builder() + .time_period(TimePeriod::Week) + .limit(5)? + .build(), + ) + .await + { + Ok(builders) => { + info!(endpoint = "builder_leaderboard", count = builders.len()); + if let Some(builder) = builders.first() { + info!( + endpoint = "builder_leaderboard", + name = %builder.builder, + volume = %builder.volume, + rank = %builder.rank + ); + } + } + Err(e) => error!(endpoint = "builder_leaderboard", error = %e), + } + + // Builder volume time series + match client + .builder_volume( + &BuilderVolumeRequest::builder() + .time_period(TimePeriod::Week) + .build(), + ) + .await + { + Ok(volumes) => { + info!(endpoint = "builder_volume", count = volumes.len()); + if let Some(vol) = volumes.first() { + info!( + endpoint = "builder_volume", + builder = %vol.builder, + date = %vol.dt, + volume = %vol.volume + ); + } + } + Err(e) => error!(endpoint = "builder_volume", error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/gamma/client.rs b/polymarket-client-sdk/examples/gamma/client.rs new file mode 100644 index 0000000..f2de57d --- /dev/null +++ b/polymarket-client-sdk/examples/gamma/client.rs @@ -0,0 +1,403 @@ +//! Comprehensive Gamma API endpoint explorer. +//! +//! This example dynamically tests all Gamma API endpoints by: +//! 1. Fetching lists first (events, markets, tags, etc.) +//! 2. Extracting real IDs/slugs from responses +//! 3. Using those IDs for subsequent lookups +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example gamma --features gamma,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=gamma.log RUST_LOG=info,hyper_util=off,hyper=off,reqwest=off,h2=off,rustls=off cargo run --example gamma --features gamma,tracing +//! ``` + +use std::fs::File; + +use polymarket_client_sdk::gamma::Client; +use polymarket_client_sdk::gamma::types::ParentEntityType; +use polymarket_client_sdk::gamma::types::request::{ + CommentsByIdRequest, CommentsByUserAddressRequest, CommentsRequest, EventByIdRequest, + EventBySlugRequest, EventTagsRequest, EventsRequest, MarketByIdRequest, MarketBySlugRequest, + MarketTagsRequest, MarketsRequest, PublicProfileRequest, RelatedTagsByIdRequest, + RelatedTagsBySlugRequest, SearchRequest, SeriesByIdRequest, SeriesListRequest, TagByIdRequest, + TagBySlugRequest, TagsRequest, TeamsRequest, +}; +use tracing::{debug, info}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + + match client.status().await { + Ok(s) => info!(endpoint = "status", result = %s), + Err(e) => debug!(endpoint = "status", error = %e), + } + + match client.sports().await { + Ok(v) => info!(endpoint = "sports", count = v.len()), + Err(e) => debug!(endpoint = "sports", error = %e), + } + + match client.sports_market_types().await { + Ok(v) => info!( + endpoint = "sports_market_types", + count = v.market_types.len() + ), + Err(e) => debug!(endpoint = "sports_market_types", error = %e), + } + + match client + .teams(&TeamsRequest::builder().limit(5).build()) + .await + { + Ok(v) => info!(endpoint = "teams", count = v.len()), + Err(e) => debug!(endpoint = "teams", error = %e), + } + + let tags_result = client.tags(&TagsRequest::builder().limit(10).build()).await; + match &tags_result { + Ok(v) => info!(endpoint = "tags", count = v.len()), + Err(e) => debug!(endpoint = "tags", error = %e), + } + + // Use "politics" tag - known to have related tags + let tag_slug = "politics"; + let tag_result = client + .tag_by_slug(&TagBySlugRequest::builder().slug(tag_slug).build()) + .await; + let tag_id = match &tag_result { + Ok(tag) => { + info!(endpoint = "tag_by_slug", slug = tag_slug, id = %tag.id); + Some(tag.id.clone()) + } + Err(e) => { + debug!(endpoint = "tag_by_slug", slug = tag_slug, error = %e); + None + } + }; + + if let Some(id) = &tag_id { + match client + .tag_by_id(&TagByIdRequest::builder().id(id).build()) + .await + { + Ok(_) => info!(endpoint = "tag_by_id", id = %id), + Err(e) => debug!(endpoint = "tag_by_id", id = %id, error = %e), + } + + match client + .related_tags_by_id(&RelatedTagsByIdRequest::builder().id(id).build()) + .await + { + Ok(v) => info!(endpoint = "related_tags_by_id", id = %id, count = v.len()), + Err(e) => debug!(endpoint = "related_tags_by_id", id = %id, error = %e), + } + + match client + .tags_related_to_tag_by_id(&RelatedTagsByIdRequest::builder().id(id).build()) + .await + { + Ok(v) => info!(endpoint = "tags_related_to_tag_by_id", id = %id, count = v.len()), + Err(e) => debug!(endpoint = "tags_related_to_tag_by_id", id = %id, error = %e), + } + } + + match client + .related_tags_by_slug(&RelatedTagsBySlugRequest::builder().slug(tag_slug).build()) + .await + { + Ok(v) => info!( + endpoint = "related_tags_by_slug", + slug = tag_slug, + count = v.len() + ), + Err(e) => debug!(endpoint = "related_tags_by_slug", slug = tag_slug, error = %e), + } + + match client + .tags_related_to_tag_by_slug(&RelatedTagsBySlugRequest::builder().slug(tag_slug).build()) + .await + { + Ok(v) => info!( + endpoint = "tags_related_to_tag_by_slug", + slug = tag_slug, + count = v.len() + ), + Err(e) => debug!(endpoint = "tags_related_to_tag_by_slug", slug = tag_slug, error = %e), + } + + let events_result = client + .events( + &EventsRequest::builder() + .active(true) + .limit(20) + .order(vec!["volume".to_owned()]) + .ascending(false) + .build(), + ) + .await; + + // Find an event with comments + let (event_with_comments, any_event) = match &events_result { + Ok(events) => { + info!(endpoint = "events", count = events.len()); + let with_comments = events + .iter() + .find(|e| e.comment_count.unwrap_or(0) > 0) + .map(|e| (e.id.clone(), e.slug.clone(), e.comment_count.unwrap_or(0))); + let any = events.first().map(|e| (e.id.clone(), e.slug.clone())); + (with_comments, any) + } + Err(e) => { + debug!(endpoint = "events", error = %e); + (None, None) + } + }; + + if let Some((event_id, event_slug)) = &any_event { + match client + .event_by_id(&EventByIdRequest::builder().id(event_id).build()) + .await + { + Ok(_) => info!(endpoint = "event_by_id", id = %event_id), + Err(e) => debug!(endpoint = "event_by_id", id = %event_id, error = %e), + } + + match client + .event_tags(&EventTagsRequest::builder().id(event_id).build()) + .await + { + Ok(v) => info!(endpoint = "event_tags", id = %event_id, count = v.len()), + Err(e) => debug!(endpoint = "event_tags", id = %event_id, error = %e), + } + + if let Some(slug) = event_slug { + match client + .event_by_slug(&EventBySlugRequest::builder().slug(slug).build()) + .await + { + Ok(_) => info!(endpoint = "event_by_slug", slug = %slug), + Err(e) => debug!(endpoint = "event_by_slug", slug = %slug, error = %e), + } + } + } + + let markets_result = client + .markets(&MarketsRequest::builder().closed(false).limit(10).build()) + .await; + + let (market_id, market_slug) = match &markets_result { + Ok(markets) => { + info!(endpoint = "markets", count = markets.len()); + markets + .first() + .map_or((None, None), |m| (Some(m.id.clone()), m.slug.clone())) + } + Err(e) => { + debug!(endpoint = "markets", error = %e); + (None, None) + } + }; + + // Test multiple slugs - verifies repeated query params work (issue #147) + if let Ok(markets) = &markets_result { + let slugs: Vec = markets + .iter() + .filter_map(|m| m.slug.clone()) + .take(3) + .collect(); + + if slugs.len() >= 2 { + match client + .markets(&MarketsRequest::builder().slug(slugs.clone()).build()) + .await + { + Ok(v) => info!( + endpoint = "markets_multiple_slugs", + slugs = ?slugs, + count = v.len(), + "verified repeated query params work" + ), + Err(e) => debug!(endpoint = "markets_multiple_slugs", slugs = ?slugs, error = %e), + } + } + } + + if let Some(id) = &market_id { + match client + .market_by_id(&MarketByIdRequest::builder().id(id).build()) + .await + { + Ok(_) => info!(endpoint = "market_by_id", id = %id), + Err(e) => debug!(endpoint = "market_by_id", id = %id, error = %e), + } + + match client + .market_tags(&MarketTagsRequest::builder().id(id).build()) + .await + { + Ok(v) => info!(endpoint = "market_tags", id = %id, count = v.len()), + Err(e) => debug!(endpoint = "market_tags", id = %id, error = %e), + } + } + + if let Some(slug) = &market_slug { + match client + .market_by_slug(&MarketBySlugRequest::builder().slug(slug).build()) + .await + { + Ok(_) => info!(endpoint = "market_by_slug", slug = %slug), + Err(e) => debug!(endpoint = "market_by_slug", slug = %slug, error = %e), + } + } + + let series_result = client + .series( + &SeriesListRequest::builder() + .limit(10) + .order("volume".to_owned()) + .ascending(false) + .build(), + ) + .await; + + let series_id = match &series_result { + Ok(series) => { + info!(endpoint = "series", count = series.len()); + series.first().map(|s| s.id.clone()) + } + Err(e) => { + debug!(endpoint = "series", error = %e); + None + } + }; + + if let Some(id) = &series_id { + match client + .series_by_id(&SeriesByIdRequest::builder().id(id).build()) + .await + { + Ok(_) => info!(endpoint = "series_by_id", id = %id), + Err(e) => debug!(endpoint = "series_by_id", id = %id, error = %e), + } + } + + let (comment_id, user_address) = if let Some((event_id, _, comment_count)) = + &event_with_comments + { + let comments_result = client + .comments( + &CommentsRequest::builder() + .parent_entity_type(ParentEntityType::Event) + .parent_entity_id(event_id) + .limit(10) + .build(), + ) + .await; + + match &comments_result { + Ok(comments) => { + info!(endpoint = "comments", event_id = %event_id, expected = comment_count, count = comments.len()); + comments + .first() + .map_or((None, None), |c| (Some(c.id.clone()), c.user_address)) + } + Err(e) => { + debug!(endpoint = "comments", event_id = %event_id, error = %e); + (None, None) + } + } + } else { + debug!( + endpoint = "comments", + "skipped - no event with comments found" + ); + (None, None) + }; + + if let Some(id) = &comment_id { + match client + .comments_by_id(&CommentsByIdRequest::builder().id(id).build()) + .await + { + Ok(v) => info!(endpoint = "comments_by_id", id = %id, count = v.len()), + Err(e) => debug!(endpoint = "comments_by_id", id = %id, error = %e), + } + } + + if let Some(addr) = user_address { + match client + .comments_by_user_address( + &CommentsByUserAddressRequest::builder() + .user_address(addr) + .limit(5) + .build(), + ) + .await + { + Ok(v) => info!(endpoint = "comments_by_user_address", address = %addr, count = v.len()), + Err(e) => debug!(endpoint = "comments_by_user_address", address = %addr, error = %e), + } + } + + // Use the user_address from comments if available + if let Some(profile_address) = user_address { + match client + .public_profile( + &PublicProfileRequest::builder() + .address(profile_address) + .build(), + ) + .await + { + Ok(p) => { + let name = p.pseudonym.as_deref().unwrap_or("anonymous"); + info!(endpoint = "public_profile", address = %profile_address, name = %name); + } + Err(e) => debug!(endpoint = "public_profile", address = %profile_address, error = %e), + } + } + + let query = "trump"; + match client + .search(&SearchRequest::builder().q(query).build()) + .await + { + Ok(r) => { + let events = r.events.map_or(0, |e| e.len()); + let tags = r.tags.map_or(0, |t| t.len()); + let profiles = r.profiles.map_or(0, |p| p.len()); + info!( + endpoint = "search", + query = query, + events = events, + tags = tags, + profiles = profiles + ); + } + Err(e) => debug!(endpoint = "search", query = query, error = %e), + } + + Ok(()) +} diff --git a/polymarket-client-sdk/examples/gamma/streaming.rs b/polymarket-client-sdk/examples/gamma/streaming.rs new file mode 100644 index 0000000..641ea64 --- /dev/null +++ b/polymarket-client-sdk/examples/gamma/streaming.rs @@ -0,0 +1,126 @@ +//! Gamma API streaming endpoint explorer. +//! +//! This example demonstrates streaming data from Gamma API endpoints using offset-based +//! pagination and single-call endpoints. It covers all response types: +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info cargo run --example gamma_streaming --features gamma,tracing +//! ``` +//! +//! Optionally log to a file: +//! ```sh +//! LOG_FILE=gamma_streaming.log RUST_LOG=info cargo run --example gamma_streaming --features gamma,tracing +//! ``` + +use std::fs::File; + +use futures::StreamExt as _; +use polymarket_client_sdk::gamma::{ + Client, + types::request::{EventsRequest, MarketsRequest}, +}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt::init(); + } + + let client = Client::default(); + + stream_events(&client).await?; + stream_markets(&client).await?; + + Ok(()) +} + +/// Streams events from the Gamma API. +async fn stream_events(client: &Client) -> anyhow::Result<()> { + info!(stream = "events", "starting stream"); + + let mut stream = client + .stream_data( + |c, limit, offset| { + let request = EventsRequest::builder() + .active(true) + .limit(limit) + .offset(offset) + .build(); + async move { c.events(&request).await } + }, + 100, + ) + .take(100) + .boxed(); + + let mut count = 0_u32; + + while let Some(result) = stream.next().await { + match result { + Ok(event) => { + count += 1; + info!(stream = "events", count, "{event:?}"); + } + Err(e) => { + warn!(stream = "events", error = %e, "stream error"); + break; + } + } + } + + info!(stream = "events", total = count, "stream completed"); + Ok(()) +} + +/// Streams markets from the Gamma API. +async fn stream_markets(client: &Client) -> anyhow::Result<()> { + info!(stream = "markets", "starting stream"); + + let mut stream = client + .stream_data( + |c, limit, offset| { + let request = MarketsRequest::builder() + .closed(false) + .limit(limit) + .offset(offset) + .build(); + async move { c.markets(&request).await } + }, + 100, + ) + .take(100) + .boxed(); + + let mut count = 0_u32; + + while let Some(result) = stream.next().await { + match result { + Ok(market) => { + count += 1; + info!(stream = "markets", count, "{market:?}"); + } + Err(e) => { + warn!(stream = "markets", error = %e, "stream error"); + break; + } + } + } + + info!(stream = "markets", total = count, "stream completed"); + Ok(()) +} diff --git a/polymarket-client-sdk/examples/rtds_crypto_prices.rs b/polymarket-client-sdk/examples/rtds_crypto_prices.rs new file mode 100644 index 0000000..7975399 --- /dev/null +++ b/polymarket-client-sdk/examples/rtds_crypto_prices.rs @@ -0,0 +1,246 @@ +//! Comprehensive RTDS (Real-Time Data Socket) endpoint explorer. +//! +//! This example dynamically tests all RTDS streaming endpoints by: +//! 1. Subscribing to Binance crypto prices (all symbols and filtered) +//! 2. Subscribing to Chainlink price feeds +//! 3. Subscribing to comment events +//! 4. Demonstrating unsubscribe functionality +//! 5. Showing connection state and subscription count +//! +//! Run with tracing enabled: +//! ```sh +//! RUST_LOG=info cargo run --example rtds_crypto_prices --features rtds,tracing +//! ``` + +use std::time::Duration; + +use futures::StreamExt as _; +use polymarket_client_sdk::rtds::Client; +use polymarket_client_sdk::rtds::types::response::CommentType; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let client = Client::default(); + + // Show connection state + let state = client.connection_state(); + info!(endpoint = "connection_state", state = ?state); + + // Subscribe to all crypto prices from Binance + info!( + stream = "crypto_prices", + "Subscribing to Binance prices (all symbols)" + ); + match client.subscribe_crypto_prices(None) { + Ok(stream) => { + let mut stream = Box::pin(stream); + let mut count = 0; + + while let Ok(Some(result)) = timeout(Duration::from_secs(5), stream.next()).await { + match result { + Ok(price) => { + info!( + stream = "crypto_prices", + symbol = %price.symbol.to_uppercase(), + value = %price.value, + timestamp = %price.timestamp + ); + count += 1; + if count >= 5 { + break; + } + } + Err(e) => debug!(stream = "crypto_prices", error = %e), + } + } + info!(stream = "crypto_prices", received = count); + } + Err(e) => debug!(stream = "crypto_prices", error = %e), + } + + // Subscribe to specific crypto symbols + let symbols = vec!["btcusdt".to_owned(), "ethusdt".to_owned()]; + info!( + stream = "crypto_prices_filtered", + symbols = ?symbols, + "Subscribing to specific symbols" + ); + match client.subscribe_crypto_prices(Some(symbols.clone())) { + Ok(stream) => { + let mut stream = Box::pin(stream); + let mut count = 0; + + while let Ok(Some(result)) = timeout(Duration::from_secs(5), stream.next()).await { + match result { + Ok(price) => { + info!( + stream = "crypto_prices_filtered", + symbol = %price.symbol.to_uppercase(), + value = %price.value + ); + count += 1; + if count >= 3 { + break; + } + } + Err(e) => debug!(stream = "crypto_prices_filtered", error = %e), + } + } + info!(stream = "crypto_prices_filtered", received = count); + } + Err(e) => debug!(stream = "crypto_prices_filtered", error = %e), + } + + // Subscribe to specific Chainlink symbol + let chainlink_symbol = "btc/usd".to_owned(); + info!( + stream = "chainlink_prices", + symbol = %chainlink_symbol, + "Subscribing to Chainlink price feed" + ); + match client.subscribe_chainlink_prices(Some(chainlink_symbol)) { + Ok(stream) => { + let mut stream = Box::pin(stream); + let mut count = 0; + + while let Ok(Some(result)) = timeout(Duration::from_secs(5), stream.next()).await { + match result { + Ok(price) => { + info!( + stream = "chainlink_prices", + symbol = %price.symbol, + value = %price.value, + timestamp = %price.timestamp + ); + count += 1; + if count >= 3 { + break; + } + } + Err(e) => debug!(stream = "chainlink_prices", error = %e), + } + } + info!(stream = "chainlink_prices", received = count); + } + Err(e) => debug!(stream = "chainlink_prices", error = %e), + } + + // Subscribe to comments (unauthenticated) + info!(stream = "comments", "Subscribing to comment events"); + match client.subscribe_comments(None) { + Ok(stream) => { + let mut stream = Box::pin(stream); + let mut count = 0; + + // Comments may be infrequent, use shorter timeout + while let Ok(Some(result)) = timeout(Duration::from_secs(3), stream.next()).await { + match result { + Ok(comment) => { + info!( + stream = "comments", + id = %comment.id, + parent_type = ?comment.parent_entity_type, + parent_id = %comment.parent_entity_id + ); + count += 1; + if count >= 3 { + break; + } + } + Err(e) => debug!(stream = "comments", error = %e), + } + } + if count > 0 { + info!(stream = "comments", received = count); + } else { + debug!(stream = "comments", "no comments received within timeout"); + } + } + Err(e) => debug!(stream = "comments", error = %e), + } + + // Subscribe to specific comment type + info!( + stream = "comments_created", + comment_type = ?CommentType::CommentCreated, + "Subscribing to created comments only" + ); + match client.subscribe_comments(Some(CommentType::CommentCreated)) { + Ok(stream) => { + let mut stream = Box::pin(stream); + let mut count = 0; + + while let Ok(Some(result)) = timeout(Duration::from_secs(3), stream.next()).await { + match result { + Ok(comment) => { + info!( + stream = "comments_created", + id = %comment.id, + parent_id = %comment.parent_entity_id + ); + count += 1; + if count >= 2 { + break; + } + } + Err(e) => debug!(stream = "comments_created", error = %e), + } + } + if count > 0 { + info!(stream = "comments_created", received = count); + } else { + debug!( + stream = "comments_created", + "no created comments received within timeout" + ); + } + } + Err(e) => debug!(stream = "comments_created", error = %e), + } + + // Show subscription count before unsubscribe + let sub_count = client.subscription_count(); + info!( + endpoint = "subscription_count", + count = sub_count, + "Before unsubscribe" + ); + + // Demonstrate unsubscribe functionality + info!("=== Demonstrating unsubscribe ==="); + + // Unsubscribe from crypto_prices (Binance) + info!("Unsubscribing from Binance crypto prices"); + match client.unsubscribe_crypto_prices() { + Ok(()) => info!("Successfully unsubscribed from crypto_prices"), + Err(e) => warn!(error = %e, "Failed to unsubscribe from crypto_prices"), + } + + // Unsubscribe from chainlink prices + info!("Unsubscribing from Chainlink prices"); + match client.unsubscribe_chainlink_prices() { + Ok(()) => info!("Successfully unsubscribed from chainlink_prices"), + Err(e) => warn!(error = %e, "Failed to unsubscribe from chainlink_prices"), + } + + // Unsubscribe from comments (wildcard) + info!("Unsubscribing from comments"); + match client.unsubscribe_comments(None) { + Ok(()) => info!("Successfully unsubscribed from comments"), + Err(e) => warn!(error = %e, "Failed to unsubscribe from comments"), + } + + // Show final subscription count after unsubscribe + let sub_count = client.subscription_count(); + info!( + endpoint = "subscription_count", + count = sub_count, + "After unsubscribe" + ); + + Ok(()) +} diff --git a/polymarket-client-sdk/rustfmt.toml b/polymarket-client-sdk/rustfmt.toml new file mode 100644 index 0000000..9475118 --- /dev/null +++ b/polymarket-client-sdk/rustfmt.toml @@ -0,0 +1,4 @@ +reorder_imports = true +reorder_modules = true + +group_imports = "StdExternalCrate" diff --git a/polymarket-client-sdk/src/auth.rs b/polymarket-client-sdk/src/auth.rs new file mode 100644 index 0000000..fd5e0b5 --- /dev/null +++ b/polymarket-client-sdk/src/auth.rs @@ -0,0 +1,616 @@ +// Re-exported types for public API convenience +/// The [`Signer`] trait from alloy for signing operations. +/// Implement this trait or use provided signers like [`LocalSigner`] or AWS KMS signers. +pub use alloy::signers::Signer; +/// Local wallet signer for signing with a private key. +/// This is the most common signer implementation. +pub use alloy::signers::local::LocalSigner; +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE; +use hmac::{Hmac, Mac as _}; +use reqwest::header::HeaderMap; +use reqwest::{Body, Request}; +/// Secret string types that redact values in debug output for security. +pub use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use sha2::Sha256; +/// UUID type used for API keys and identifiers. +pub use uuid::Uuid; + +use crate::{Result, Timestamp}; + +/// Type alias for API keys, which are UUIDs. +pub type ApiKey = Uuid; + +/// Generic set of credentials used to authenticate to the Polymarket API. These credentials are +/// returned when calling [`crate::clob::Client::create_or_derive_api_key`], [`crate::clob::Client::derive_api_key`], or +/// [`crate::clob::Client::create_api_key`]. They are used by the [`state::Authenticated`] client to +/// sign the [`Request`] when making calls to the API. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Credentials { + #[serde(alias = "apiKey")] + pub(crate) key: ApiKey, + pub(crate) secret: SecretString, + pub(crate) passphrase: SecretString, +} + +impl Credentials { + #[must_use] + pub fn new(key: Uuid, secret: String, passphrase: String) -> Self { + Self { + key, + secret: SecretString::from(secret), + passphrase: SecretString::from(passphrase), + } + } + + /// Returns the API key. + #[must_use] + pub fn key(&self) -> ApiKey { + self.key + } + + /// Returns the secret. + #[must_use] + pub fn secret(&self) -> &SecretString { + &self.secret + } + + /// Returns the passphrase. + #[must_use] + pub fn passphrase(&self) -> &SecretString { + &self.passphrase + } +} + +/// Each client can exist in one state at a time, i.e. [`state::Unauthenticated`] or +/// [`state::Authenticated`]. +pub mod state { + use crate::auth::{Credentials, Kind}; + use crate::types::Address; + + /// The initial state of the client + #[non_exhaustive] + #[derive(Clone, Debug)] + pub struct Unauthenticated; + + /// The elevated state of the client. For example, calling [`crate::clob::Client::authentication_builder`] + /// will return an [`crate::clob::client::AuthenticationBuilder`], which can be turned into + /// an authenticated clob via [`crate::clob::client::AuthenticationBuilder::authenticate`]. + /// + /// See `examples/authenticated.rs` for more context. + #[non_exhaustive] + #[derive(Clone, Debug)] + #[cfg_attr( + not(feature = "clob"), + expect(dead_code, reason = "Fields used by clob module when feature enabled") + )] + pub struct Authenticated { + /// The signer's address that created the credentials + pub(crate) address: Address, + /// The [`Credentials`]'s `secret` is used to generate an [`crate::signer::hmac`] which is + /// passed in the L2 headers ([`super::HeaderMap`]) `POLY_SIGNATURE` field. + pub(crate) credentials: Credentials, + /// The [`Kind`] that this [`Authenticated`] exhibits. Used to generate additional headers + /// for different types of authentication, e.g. Builder. + pub(crate) kind: K, + } + + /// The clob state can only be [`Unauthenticated`] or [`Authenticated`]. + pub trait State: sealed::Sealed {} + + impl State for Unauthenticated {} + impl sealed::Sealed for Unauthenticated {} + + impl State for Authenticated {} + impl sealed::Sealed for Authenticated {} + + mod sealed { + pub trait Sealed {} + } +} + +/// Asynchronous authentication enricher +/// +/// This trait is used to apply extra headers to authenticated requests. For example, in the case +/// of [`builder::Builder`] authentication, Builder headers are added in addition to the [`Normal`] +/// L2 headers. +#[async_trait] +pub trait Kind: sealed::Sealed + Clone + Send + Sync + 'static { + async fn extra_headers(&self, request: &Request, timestamp: Timestamp) -> Result; +} + +/// Non-special, generic authentication. Sometimes referred to as L2 authentication. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub struct Normal; + +#[async_trait] +impl Kind for Normal { + async fn extra_headers(&self, _request: &Request, _timestamp: Timestamp) -> Result { + Ok(HeaderMap::new()) + } +} + +impl sealed::Sealed for Normal {} + +#[async_trait] +impl Kind for builder::Builder { + async fn extra_headers(&self, request: &Request, timestamp: Timestamp) -> Result { + self.create_headers(request, timestamp).await + } +} + +impl sealed::Sealed for builder::Builder {} + +mod sealed { + pub trait Sealed {} +} + +#[cfg(feature = "clob")] +pub(crate) mod l1 { + use std::borrow::Cow; + + use alloy::core::sol; + use alloy::dyn_abi::Eip712Domain; + use alloy::hex::ToHexExt as _; + use alloy::primitives::{ChainId, U256}; + use alloy::signers::Signer; + use alloy::sol_types::SolStruct as _; + use reqwest::header::HeaderMap; + + use crate::{Result, Timestamp}; + + pub(crate) const POLY_ADDRESS: &str = "POLY_ADDRESS"; + pub(crate) const POLY_NONCE: &str = "POLY_NONCE"; + pub(crate) const POLY_SIGNATURE: &str = "POLY_SIGNATURE"; + pub(crate) const POLY_TIMESTAMP: &str = "POLY_TIMESTAMP"; + + sol! { + #[non_exhaustive] + struct ClobAuth { + address address; + string timestamp; + uint256 nonce; + string message; + } + } + + /// Returns the [`HeaderMap`] needed to obtain [`Credentials`] . + pub(crate) async fn create_headers( + signer: &S, + chain_id: ChainId, + timestamp: Timestamp, + nonce: Option, + ) -> Result { + let naive_nonce = nonce.unwrap_or(0); + + let auth = ClobAuth { + address: signer.address(), + timestamp: timestamp.to_string(), + nonce: U256::from(naive_nonce), + message: "This message attests that I control the given wallet".to_owned(), + }; + + let domain = Eip712Domain { + name: Some(Cow::Borrowed("ClobAuthDomain")), + version: Some(Cow::Borrowed("1")), + chain_id: Some(U256::from(chain_id)), + ..Eip712Domain::default() + }; + + let hash = auth.eip712_signing_hash(&domain); + let signature = signer.sign_hash(&hash).await?; + + let mut map = HeaderMap::new(); + map.insert( + POLY_ADDRESS, + signer.address().encode_hex_with_prefix().parse()?, + ); + map.insert(POLY_NONCE, naive_nonce.to_string().parse()?); + map.insert(POLY_SIGNATURE, signature.to_string().parse()?); + map.insert(POLY_TIMESTAMP, timestamp.to_string().parse()?); + + Ok(map) + } +} + +#[cfg(feature = "clob")] +pub(crate) mod l2 { + use alloy::hex::ToHexExt as _; + use reqwest::Request; + use reqwest::header::HeaderMap; + use secrecy::ExposeSecret as _; + + use crate::auth::state::Authenticated; + use crate::auth::{Kind, hmac, to_message}; + use crate::{Result, Timestamp}; + + pub(crate) const POLY_ADDRESS: &str = "POLY_ADDRESS"; + pub(crate) const POLY_API_KEY: &str = "POLY_API_KEY"; + pub(crate) const POLY_PASSPHRASE: &str = "POLY_PASSPHRASE"; + pub(crate) const POLY_SIGNATURE: &str = "POLY_SIGNATURE"; + pub(crate) const POLY_TIMESTAMP: &str = "POLY_TIMESTAMP"; + + /// Returns the [`Headers`] needed to interact with any authenticated endpoints. + pub(crate) async fn create_headers( + state: &Authenticated, + request: &Request, + timestamp: Timestamp, + ) -> Result { + let credentials = &state.credentials; + let signature = hmac(&credentials.secret, &to_message(request, timestamp))?; + + let mut map = HeaderMap::new(); + + map.insert( + POLY_ADDRESS, + state.address.encode_hex_with_prefix().parse()?, + ); + map.insert(POLY_API_KEY, state.credentials.key.to_string().parse()?); + map.insert( + POLY_PASSPHRASE, + state.credentials.passphrase.expose_secret().parse()?, + ); + map.insert(POLY_SIGNATURE, signature.parse()?); + map.insert(POLY_TIMESTAMP, timestamp.to_string().parse()?); + + let extra_headers = state.kind.extra_headers(request, timestamp).await?; + + map.extend(extra_headers); + + Ok(map) + } +} + +/// Specific structs and methods used in configuring and authenticating the Builder flow +pub mod builder { + use reqwest::header::HeaderMap; + use reqwest::{Client, Request}; + use secrecy::ExposeSecret as _; + use serde::{Deserialize, Serialize}; + use serde_json::json; + /// URL type for remote builder host configuration. + pub use url::Url; + + use crate::auth::{Credentials, body_to_string, hmac, to_message}; + use crate::{Result, Timestamp}; + + pub(crate) const POLY_BUILDER_API_KEY: &str = "POLY_BUILDER_API_KEY"; + pub(crate) const POLY_BUILDER_PASSPHRASE: &str = "POLY_BUILDER_PASSPHRASE"; + pub(crate) const POLY_BUILDER_SIGNATURE: &str = "POLY_BUILDER_SIGNATURE"; + pub(crate) const POLY_BUILDER_TIMESTAMP: &str = "POLY_BUILDER_TIMESTAMP"; + + #[derive(Clone, Debug, Deserialize, Serialize)] + #[serde(rename_all = "UPPERCASE")] + #[expect( + clippy::struct_field_names, + reason = "Have to prefix `poly_builder` for serde" + )] + struct HeaderPayload { + poly_builder_api_key: String, + poly_builder_timestamp: String, + poly_builder_passphrase: String, + poly_builder_signature: String, + } + + /// Configuration used to authenticate as a [Builder](https://docs.polymarket.com/developers/builders/builder-intro). Can either be [`Config::local`] + /// or [`Config::remote`]. Local uses locally accessible Builder credentials to generate builder headers. Remote obtains them from a signing server + #[non_exhaustive] + #[derive(Clone, Debug)] + pub enum Config { + Local(Credentials), + Remote { host: Url, token: Option }, + } + + impl Config { + #[must_use] + pub fn local(credentials: Credentials) -> Self { + Config::Local(credentials) + } + + pub fn remote(host: &str, token: Option) -> Result { + let host = Url::parse(host)?; + Ok(Config::Remote { host, token }) + } + } + + /// Used to generate the Builder headers + #[non_exhaustive] + #[derive(Clone, Debug)] + pub struct Builder { + pub(crate) config: Config, + pub(crate) client: Client, + } + + impl Builder { + pub(crate) async fn create_headers( + &self, + request: &Request, + timestamp: Timestamp, + ) -> Result { + match &self.config { + Config::Local(credentials) => { + let signature = hmac(&credentials.secret, &to_message(request, timestamp))?; + + let mut map = HeaderMap::new(); + + map.insert(POLY_BUILDER_API_KEY, credentials.key.to_string().parse()?); + map.insert( + POLY_BUILDER_PASSPHRASE, + credentials.passphrase.expose_secret().parse()?, + ); + map.insert(POLY_BUILDER_SIGNATURE, signature.parse()?); + map.insert(POLY_BUILDER_TIMESTAMP, timestamp.to_string().parse()?); + + Ok(map) + } + Config::Remote { host, token } => { + let payload = json!({ + "method": request.method().as_str(), + "path": request.url().path(), + "body": &request.body().and_then(body_to_string).unwrap_or_default(), + "timestamp": timestamp, + }); + + let mut headers = HeaderMap::new(); + if let Some(token) = token { + headers.insert("Authorization", format!("Bearer {token}").parse()?); + } + + let response = self + .client + .post(host.to_string()) + .headers(headers) + .json(&payload) + .send() + .await?; + + let remote_headers: HeaderPayload = response.error_for_status()?.json().await?; + + let mut map = HeaderMap::new(); + + map.insert( + POLY_BUILDER_SIGNATURE, + remote_headers.poly_builder_signature.parse()?, + ); + map.insert( + POLY_BUILDER_TIMESTAMP, + remote_headers.poly_builder_timestamp.parse()?, + ); + map.insert( + POLY_BUILDER_API_KEY, + remote_headers.poly_builder_api_key.parse()?, + ); + map.insert( + POLY_BUILDER_PASSPHRASE, + remote_headers.poly_builder_passphrase.parse()?, + ); + + Ok(map) + } + } + } + } +} + +#[must_use] +fn to_message(request: &Request, timestamp: Timestamp) -> String { + let method = request.method(); + let body = request.body().and_then(body_to_string).unwrap_or_default(); + let path = request.url().path(); + + format!("{timestamp}{method}{path}{body}") +} + +#[must_use] +fn body_to_string(body: &Body) -> Option { + body.as_bytes() + .map(String::from_utf8_lossy) + .map(|b| b.replace('\'', "\"")) +} + +fn hmac(secret: &SecretString, message: &str) -> Result { + let decoded_secret = URL_SAFE.decode(secret.expose_secret())?; + let mut mac = Hmac::::new_from_slice(&decoded_secret)?; + mac.update(message.as_bytes()); + + let result = mac.finalize().into_bytes(); + Ok(URL_SAFE.encode(result)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + #[cfg(feature = "clob")] + use alloy::signers::local::LocalSigner; + use reqwest::{Client, Method, RequestBuilder}; + use serde_json::json; + use url::Url; + use uuid::Uuid; + + use super::*; + use crate::auth::builder::Config; + #[cfg(feature = "clob")] + use crate::auth::state::Authenticated; + #[cfg(feature = "clob")] + use crate::types::address; + #[cfg(feature = "clob")] + use crate::{AMOY, Result}; + + // publicly known private key + #[cfg(feature = "clob")] + const PRIVATE_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + #[cfg(feature = "clob")] + #[tokio::test] + async fn l1_headers_should_succeed() -> anyhow::Result<()> { + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(AMOY)); + + let headers = l1::create_headers(&signer, AMOY, 10_000_000, Some(23)).await?; + + assert_eq!( + signer.address(), + address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266") + ); + assert_eq!( + headers[l1::POLY_ADDRESS], + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ); + assert_eq!(headers[l1::POLY_NONCE], "23"); + assert_eq!( + headers[l1::POLY_SIGNATURE], + "0xf62319a987514da40e57e2f4d7529f7bac38f0355bd88bb5adbb3768d80de6c1682518e0af677d5260366425f4361e7b70c25ae232aff0ab2331e2b164a1aedc1b" + ); + assert_eq!(headers[l1::POLY_TIMESTAMP], "10000000"); + + Ok(()) + } + + #[cfg(feature = "clob")] + #[tokio::test] + async fn l2_headers_should_succeed() -> anyhow::Result<()> { + let signer = LocalSigner::from_str(PRIVATE_KEY)?; + + let authenticated = Authenticated { + address: signer.address(), + credentials: Credentials { + key: Uuid::nil(), + passphrase: SecretString::from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), + ), + secret: SecretString::from( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + ), + }, + kind: Normal, + }; + + let request = Request::new(Method::GET, Url::parse("http://localhost/")?); + let headers = l2::create_headers(&authenticated, &request, 1).await?; + + assert_eq!( + headers[l2::POLY_ADDRESS], + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ); + assert_eq!( + headers[l2::POLY_PASSPHRASE], + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(headers[l2::POLY_API_KEY], Uuid::nil().to_string()); + assert_eq!( + headers[l2::POLY_SIGNATURE], + "eHaylCwqRSOa2LFD77Nt_SaTpbsxzN8eTEI3LryhEj4=" + ); + assert_eq!(headers[l2::POLY_TIMESTAMP], "1"); + + Ok(()) + } + + #[tokio::test] + async fn builder_headers_should_succeed() -> Result<()> { + let credentials = Credentials { + key: Uuid::nil(), + passphrase: SecretString::from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), + ), + secret: SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + }; + let config = Config::local(credentials); + let request = Request::new(Method::GET, Url::parse("http://localhost/")?); + let timestamp = 1; + + let builder = builder::Builder { + config, + client: Client::default(), + }; + + let headers = builder.create_headers(&request, timestamp).await?; + + assert_eq!( + headers[builder::POLY_BUILDER_API_KEY], + Uuid::nil().to_string() + ); + assert_eq!( + headers[builder::POLY_BUILDER_PASSPHRASE], + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(headers[builder::POLY_BUILDER_TIMESTAMP], "1"); + + Ok(()) + } + + #[test] + fn request_args_should_succeed() -> Result<()> { + let request = Request::new(Method::POST, Url::parse("http://localhost/path")?); + let request = RequestBuilder::from_parts(Client::new(), request) + .json(&json!({"foo": "bar"})) + .build()?; + + let timestamp = 1; + + assert_eq!( + to_message(&request, timestamp), + r#"1POST/path{"foo":"bar"}"# + ); + + Ok(()) + } + + #[test] + fn hmac_succeeds() -> Result<()> { + let json = json!({ + "hash": "0x123" + }); + + let method = Method::from_str("test-sign") + .expect("To avoid needing an error variant just for one test"); + let request = Request::new(method, Url::parse("http://localhost/orders")?); + let request = RequestBuilder::from_parts(Client::new(), request) + .json(&json) + .build()?; + + let message = to_message(&request, 1_000_000); + let signature = hmac( + &SecretString::from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + &message, + )?; + + assert_eq!(message, r#"1000000test-sign/orders{"hash":"0x123"}"#); + assert_eq!(signature, "4gJVbox-R6XlDK4nlaicig0_ANVL1qdcahiL8CXfXLM="); + + Ok(()) + } + + #[test] + fn credentials_key_returns_api_key() { + let key = Uuid::new_v4(); + let credentials = Credentials::new(key, "secret".to_owned(), "passphrase".to_owned()); + assert_eq!(credentials.key(), key); + } + + #[test] + fn debug_does_not_expose_secrets() { + let secret_value = "my_super_secret_value_12345"; + let passphrase_value = "my_super_secret_passphrase_67890"; + let credentials = Credentials::new( + Uuid::nil(), + secret_value.to_owned(), + passphrase_value.to_owned(), + ); + + let debug_output = format!("{credentials:?}"); + + // Verify that the secret values are NOT present in the debug output + assert!( + !debug_output.contains(secret_value), + "Debug output should NOT contain the secret value. Got: {debug_output}" + ); + assert!( + !debug_output.contains(passphrase_value), + "Debug output should NOT contain the passphrase value. Got: {debug_output}" + ); + } +} diff --git a/polymarket-client-sdk/src/bridge/client.rs b/polymarket-client-sdk/src/bridge/client.rs new file mode 100644 index 0000000..f46de2d --- /dev/null +++ b/polymarket-client-sdk/src/bridge/client.rs @@ -0,0 +1,191 @@ +use reqwest::{ + Client as ReqwestClient, Method, + header::{HeaderMap, HeaderValue}, +}; +use url::Url; + +use super::types::{ + DepositRequest, DepositResponse, StatusRequest, StatusResponse, SupportedAssetsResponse, +}; +use crate::Result; + +/// Client for the Polymarket Bridge API. +/// +/// The Bridge API enables bridging assets from various chains (EVM, Solana, Bitcoin) +/// to USDC.e on Polygon for trading on Polymarket. +/// +/// # Example +/// +/// ```no_run +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::bridge::{Client, types::DepositRequest}; +/// +/// # async fn example() -> Result<(), Box> { +/// let client = Client::default(); +/// +/// // Get deposit addresses +/// let request = DepositRequest::builder() +/// .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .build(); +/// let response = client.deposit(&request).await?; +/// +/// // Get supported assets +/// let assets = client.supported_assets().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Debug)] +pub struct Client { + host: Url, + client: ReqwestClient, +} + +impl Default for Client { + fn default() -> Self { + Client::new("https://bridge.polymarket.com") + .expect("Client with default endpoint should succeed") + } +} + +impl Client { + /// Creates a new Bridge API client with a custom host. + /// + /// # Errors + /// + /// Returns an error if the host URL is invalid or the HTTP client fails to build. + pub fn new(host: &str) -> Result { + let mut headers = HeaderMap::new(); + + headers.insert("User-Agent", HeaderValue::from_static("rs_clob_client")); + headers.insert("Accept", HeaderValue::from_static("*/*")); + headers.insert("Connection", HeaderValue::from_static("keep-alive")); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + let client = ReqwestClient::builder().default_headers(headers).build()?; + + Ok(Self { + host: Url::parse(host)?, + client, + }) + } + + /// Returns the host URL for the client. + #[must_use] + pub fn host(&self) -> &Url { + &self.host + } + + #[must_use] + fn client(&self) -> &ReqwestClient { + &self.client + } + + /// Create deposit addresses for a Polymarket wallet. + /// + /// Generates unique deposit addresses for bridging assets to Polymarket. + /// Returns addresses for EVM-compatible chains, Solana, and Bitcoin. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::types::address; + /// use polymarket_client_sdk::bridge::{Client, types::DepositRequest}; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::default(); + /// let request = DepositRequest::builder() + /// .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + /// .build(); + /// + /// let response = client.deposit(&request).await?; + /// println!("EVM: {}", response.address.evm); + /// println!("SVM: {}", response.address.svm); + /// println!("BTC: {}", response.address.btc); + /// # Ok(()) + /// # } + /// ``` + pub async fn deposit(&self, request: &DepositRequest) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}deposit", self.host())) + .json(request) + .build()?; + + crate::request(&self.client, request, None).await + } + + /// Get all supported chains and tokens for deposits. + /// + /// Returns information about which assets can be deposited and their + /// minimum deposit amounts in USD. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::bridge::Client; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::default(); + /// let response = client.supported_assets().await?; + /// + /// for asset in response.supported_assets { + /// println!( + /// "{} ({}) on {} - min: ${:.2}", + /// asset.token.name, + /// asset.token.symbol, + /// asset.chain_name, + /// asset.min_checkout_usd + /// ); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn supported_assets(&self) -> Result { + let request = self + .client() + .request(Method::GET, format!("{}supported-assets", self.host())) + .build()?; + + crate::request(&self.client, request, None).await + } + + /// Get the transaction status for all deposits associated with a given deposit address. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::bridge::{Client, types::StatusRequest}; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::default(); + /// + /// let request = StatusRequest::builder() + /// .address("56687bf447db6ffa42ffe2204a05edaa20f55839") + /// .build(); + /// let response = client.status(&request).await?; + /// + /// for tx in response.transactions { + /// println!( + /// "Sent {} amount of token {} on chainId {} to destination chainId {} with status {:?}", + /// tx.from_amount_base_unit, + /// tx.from_token_address, + /// tx.from_chain_id, + /// tx.to_chain_id, + /// tx.status + /// ); + /// } + /// # Ok(()) + /// # } + /// + /// ``` + pub async fn status(&self, request: &StatusRequest) -> Result { + let request = self + .client() + .request( + Method::GET, + format!("{}status/{}", self.host(), request.address), + ) + .build()?; + + crate::request(&self.client, request, None).await + } +} diff --git a/polymarket-client-sdk/src/bridge/mod.rs b/polymarket-client-sdk/src/bridge/mod.rs new file mode 100644 index 0000000..423aab2 --- /dev/null +++ b/polymarket-client-sdk/src/bridge/mod.rs @@ -0,0 +1,52 @@ +//! Polymarket Bridge API client and types. +//! +//! **Feature flag:** `bridge` (required to use this module) +//! +//! This module provides a client for interacting with the Polymarket Bridge API, +//! which enables bridging assets from various chains (EVM, Solana, Bitcoin) to +//! USDC.e on Polygon for trading on Polymarket. +//! +//! # Overview +//! +//! The Bridge API is a read/write HTTP API that provides: +//! - Deposit address generation for multi-chain asset bridging +//! - Supported asset and chain information +//! +//! ## Available Endpoints +//! +//! | Endpoint | Method | Description | +//! |----------|--------|-------------| +//! | `/deposit` | POST | Create deposit addresses for a wallet | +//! | `/supported-assets` | GET | Get supported chains and tokens | +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::types::address; +//! use polymarket_client_sdk::bridge::{Client, types::DepositRequest}; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create a client with the default endpoint +//! let client = Client::default(); +//! +//! // Get deposit addresses for a wallet +//! let request = DepositRequest::builder() +//! .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +//! .build(); +//! +//! let response = client.deposit(&request).await?; +//! println!("EVM: {}", response.address.evm); +//! println!("SVM: {}", response.address.svm); +//! println!("BTC: {}", response.address.btc); +//! # Ok(()) +//! # } +//! ``` +//! +//! # API Base URL +//! +//! The default API endpoint is `https://bridge.polymarket.com`. + +pub mod client; +pub mod types; + +pub use client::Client; diff --git a/polymarket-client-sdk/src/bridge/types/mod.rs b/polymarket-client-sdk/src/bridge/types/mod.rs new file mode 100644 index 0000000..b8be632 --- /dev/null +++ b/polymarket-client-sdk/src/bridge/types/mod.rs @@ -0,0 +1,5 @@ +mod request; +mod response; + +pub use request::*; +pub use response::*; diff --git a/polymarket-client-sdk/src/bridge/types/request.rs b/polymarket-client-sdk/src/bridge/types/request.rs new file mode 100644 index 0000000..8dccf98 --- /dev/null +++ b/polymarket-client-sdk/src/bridge/types/request.rs @@ -0,0 +1,41 @@ +use bon::Builder; +use serde::Serialize; + +use crate::types::Address; + +/// Request to create deposit addresses for a Polymarket wallet. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::bridge::types::DepositRequest; +/// +/// let request = DepositRequest::builder() +/// .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .build(); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +pub struct DepositRequest { + /// The Polymarket wallet address to generate deposit addresses for. + pub address: Address, +} + +/// Request to get deposit statuses for a given deposit address. +/// +/// ### Note: This doesn't use the alloy Address type, since it supports Solana and Bitcoin addresses. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::bridge::types::StatusRequest; +/// +/// let request = StatusRequest::builder().address("0x9cb12Ec30568ab763ae5891ce4b8c5C96CeD72C9").build(); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +#[builder(on(String, into))] +pub struct StatusRequest { + pub address: String, +} diff --git a/polymarket-client-sdk/src/bridge/types/response.rs b/polymarket-client-sdk/src/bridge/types/response.rs new file mode 100644 index 0000000..d2fa981 --- /dev/null +++ b/polymarket-client-sdk/src/bridge/types/response.rs @@ -0,0 +1,124 @@ +use alloy::primitives::U256; +use bon::Builder; +use serde::Deserialize; +use serde_with::{DisplayFromStr, serde_as}; + +use crate::types::{Address, ChainId, Decimal}; + +/// Response containing deposit addresses for different blockchain networks. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +pub struct DepositResponse { + /// Deposit addresses for different blockchain networks. + pub address: DepositAddresses, + /// Additional information about supported chains. + pub note: Option, +} + +/// Deposit addresses for different blockchain networks. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[builder(on(String, into))] +pub struct DepositAddresses { + /// EVM-compatible deposit address (Ethereum, Polygon, Arbitrum, Base, etc.). + pub evm: Address, + /// Solana Virtual Machine deposit address. + pub svm: String, + /// Bitcoin deposit address. + pub btc: String, +} + +/// Response containing all supported assets for deposits. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SupportedAssetsResponse { + /// List of supported assets with minimum deposit amounts. + pub supported_assets: Vec, + /// Additional information about supported chains and assets. + pub note: Option, +} + +/// A supported asset with chain and token information. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[builder(on(String, into))] +#[serde(rename_all = "camelCase")] +pub struct SupportedAsset { + /// Blockchain chain ID (e.g., 1 for Ethereum mainnet, 137 for Polygon). + /// Deserialized from JSON string representation (e.g., `"137"`). + #[serde_as(as = "DisplayFromStr")] + pub chain_id: ChainId, + /// Human-readable chain name. + pub chain_name: String, + /// Token information. + pub token: Token, + /// Minimum deposit amount in USD. + pub min_checkout_usd: Decimal, +} + +/// Token information for a supported asset. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[builder(on(String, into))] +pub struct Token { + /// Full token name. + pub name: String, + /// Token symbol. + pub symbol: String, + /// Token contract address. + pub address: String, + /// Token decimals. + pub decimals: u8, +} + +/// Transaction status for all deposits associated with a given deposit address. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[builder(on(String, into))] +#[serde(rename_all = "camelCase")] +pub struct StatusResponse { + /// List of transactions for the given address + pub transactions: Vec, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, PartialEq, Builder)] +#[builder(on(String, into))] +#[serde(rename_all = "camelCase")] +pub struct DepositTransaction { + /// Source chain ID + #[serde_as(as = "DisplayFromStr")] + pub from_chain_id: ChainId, + /// Source token contract address + pub from_token_address: String, + /// Amount in base units (without decimals) + #[serde_as(as = "DisplayFromStr")] + pub from_amount_base_unit: U256, + /// Destination chain ID + #[serde_as(as = "DisplayFromStr")] + pub to_chain_id: ChainId, + /// Destination chain ID + pub to_token_address: Address, + /// Current status of the transaction + pub status: DepositTransactionStatus, + /// Transaction hash (only available when status is Completed) + pub tx_hash: Option, + /// Unix timestamp in milliseconds when transaction was created (missing when status is `DepositDetected`) + pub created_time_ms: Option, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DepositTransactionStatus { + DepositDetected, + Processing, + OriginTxConfirmed, + Submitted, + Completed, + Failed, +} diff --git a/polymarket-client-sdk/src/clob/client.rs b/polymarket-client-sdk/src/clob/client.rs new file mode 100644 index 0000000..e9d4042 --- /dev/null +++ b/polymarket-client-sdk/src/clob/client.rs @@ -0,0 +1,2455 @@ +use std::borrow::Cow; +use std::marker::PhantomData; +use std::mem; +use std::sync::Arc; +#[cfg(feature = "heartbeats")] +use std::time::Duration; + +use alloy::dyn_abi::Eip712Domain; +use alloy::primitives::U256; +use alloy::signers::Signer; +use alloy::sol_types::SolStruct as _; +use async_stream::try_stream; +use bon::Builder; +use chrono::{NaiveDate, Utc}; +use dashmap::DashMap; +use futures::Stream; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::{Client as ReqwestClient, Method, Request}; +use serde_json::json; +#[cfg(all(feature = "tracing", feature = "heartbeats"))] +use tracing::{debug, error}; +use url::Url; +use uuid::Uuid; +#[cfg(feature = "heartbeats")] +use {tokio::sync::oneshot::Receiver, tokio::time, tokio_util::sync::CancellationToken}; + +use crate::auth::builder::{Builder, Config as BuilderConfig}; +use crate::auth::state::{Authenticated, State, Unauthenticated}; +use crate::auth::{Credentials, Kind, Normal}; +use crate::clob::order_builder::{Limit, Market, OrderBuilder, generate_seed}; +use crate::clob::types::request::{ + BalanceAllowanceRequest, CancelMarketOrderRequest, DeleteNotificationsRequest, + LastTradePriceRequest, MidpointRequest, OrderBookSummaryRequest, OrdersRequest, + PriceHistoryRequest, PriceRequest, SpreadRequest, TradesRequest, UpdateBalanceAllowanceRequest, + UserRewardsEarningRequest, +}; +use crate::clob::types::response::{ + ApiKeysResponse, BalanceAllowanceResponse, BanStatusResponse, BuilderApiKeyResponse, + BuilderTradeResponse, CancelOrdersResponse, CurrentRewardResponse, FeeRateResponse, + GeoblockResponse, HeartbeatResponse, LastTradePriceResponse, LastTradesPricesResponse, + MarketResponse, MarketRewardResponse, MidpointResponse, MidpointsResponse, NegRiskResponse, + NotificationResponse, OpenOrderResponse, OrderBookSummaryResponse, OrderScoringResponse, + OrdersScoringResponse, Page, PostOrderResponse, PriceHistoryResponse, PriceResponse, + PricesResponse, RewardsPercentagesResponse, SimplifiedMarketResponse, SpreadResponse, + SpreadsResponse, TickSizeResponse, TotalUserEarningResponse, TradeResponse, + UserEarningResponse, UserRewardsEarningResponse, +}; +#[cfg(feature = "rfq")] +use crate::clob::types::{ + AcceptRfqQuoteRequest, AcceptRfqQuoteResponse, ApproveRfqOrderRequest, ApproveRfqOrderResponse, + CancelRfqQuoteRequest, CancelRfqRequestRequest, CreateRfqQuoteRequest, CreateRfqQuoteResponse, + CreateRfqRequestRequest, CreateRfqRequestResponse, RfqQuote, RfqQuotesRequest, RfqRequest, + RfqRequestsRequest, +}; +use crate::clob::types::{SignableOrder, SignatureType, SignedOrder, TickSize}; +use crate::error::{Error, Kind as ErrorKind, Synchronization}; +use crate::types::Address; +use crate::{ + AMOY, POLYGON, Result, Timestamp, ToQueryParams as _, auth, contract_config, + derive_proxy_wallet, derive_safe_wallet, +}; + +const ORDER_NAME: Option> = Some(Cow::Borrowed("Polymarket CTF Exchange")); +const VERSION: Option> = Some(Cow::Borrowed("1")); + +const TERMINAL_CURSOR: &str = "LTE="; // base64("-1") + +/// The type used to build a request to authenticate the inner [`Client`]. Calling +/// `authenticate` on this will elevate that inner `client` into an [`Client>`]. +pub struct AuthenticationBuilder<'signer, S: Signer, K: Kind = Normal> { + /// The initially unauthenticated client that is "carried forward" into the authenticated client. + client: Client, + /// The signer used to generate the L1 headers that will return a set of [`Credentials`]. + signer: &'signer S, + /// If [`Credentials`] are supplied, then those are used instead of making new calls to obtain one. + credentials: Option, + /// An optional `nonce` value, when `credentials` are not present, to pass along to the call to + /// create or derive [`Credentials`]. + nonce: Option, + /// The [`Kind`] that this [`AuthenticationBuilder`] exhibits. Used to generate additional + /// headers for different types of authentication, e.g. Builder. + kind: K, + /// The optional [`Address`] used to represent the funder for this `client`. If a funder is set + /// then `signature_type` must match `Some(SignatureType::Proxy | Signature::GnosisSafe)`. Conversely, + /// if funder is not set, then `signature_type` must be `Some(SignatureType::Eoa)`. + funder: Option
, + /// The optional [`SignatureType`], see `funder` for more information. + signature_type: Option, + /// The optional salt/seed generator for use in creating [`SignableOrder`]s + salt_generator: Option u64>, +} + +impl AuthenticationBuilder<'_, S, K> { + #[must_use] + pub fn nonce(mut self, nonce: u32) -> Self { + self.nonce = Some(nonce); + self + } + + #[must_use] + pub fn credentials(mut self, credentials: Credentials) -> Self { + self.credentials = Some(credentials); + self + } + + #[must_use] + pub fn funder(mut self, funder: Address) -> Self { + self.funder = Some(funder); + self + } + + #[must_use] + pub fn signature_type(mut self, signature_type: SignatureType) -> Self { + self.signature_type = Some(signature_type); + self + } + + #[must_use] + pub fn salt_generator(mut self, salt_generator: fn() -> u64) -> Self { + self.salt_generator = Some(salt_generator); + self + } + + /// Attempt to elevate the inner `client` to [`Client>`] using the optional + /// fields supplied in the builder. + #[expect( + clippy::missing_panics_doc, + reason = "chain_id panic is guarded by prior validation" + )] + pub async fn authenticate(self) -> Result>> { + let inner = Arc::into_inner(self.client.inner).ok_or(Synchronization)?; + + match self.signer.chain_id() { + Some(chain) if chain == POLYGON || chain == AMOY => {} + Some(chain) => { + return Err(Error::validation(format!( + "Only Polygon and AMOY are supported, got {chain}" + ))); + } + None => { + return Err(Error::validation( + "Chain id not set, be sure to provide one on the signer", + )); + } + } + + // SAFETY: chain_id is validated above to be either POLYGON or AMOY + let chain_id = self.signer.chain_id().expect("validated above"); + + // Auto-derive funder from signer using CREATE2 when using proxy signature types + // without explicit funder. This computes the deterministic wallet address that + // Polymarket deploys for the user. + let funder = match (self.funder, self.signature_type) { + (None, Some(SignatureType::Proxy)) => { + let derived = + derive_proxy_wallet(self.signer.address(), chain_id).ok_or_else(|| { + Error::validation( + "Proxy wallet derivation not supported on this chain. \ + Please provide an explicit funder address.", + ) + })?; + Some(derived) + } + (None, Some(SignatureType::GnosisSafe)) => { + let derived = + derive_safe_wallet(self.signer.address(), chain_id).ok_or_else(|| { + Error::validation( + "Safe wallet derivation not supported on this chain. \ + Please provide an explicit funder address.", + ) + })?; + Some(derived) + } + (funder, _) => funder, + }; + + match (funder, self.signature_type) { + (Some(_), Some(sig @ SignatureType::Eoa)) => { + return Err(Error::validation(format!( + "Cannot have a funder address with a {sig} signature type" + ))); + } + ( + Some(Address::ZERO), + Some(sig @ (SignatureType::Proxy | SignatureType::GnosisSafe)), + ) => { + return Err(Error::validation(format!( + "Cannot have a zero funder address with a {sig} signature type" + ))); + } + // Note: (None, Some(Proxy/GnosisSafe)) is unreachable due to auto-derivation above + _ => {} + } + + let credentials = match self.credentials { + Some(_) if self.nonce.is_some() => { + return Err(Error::validation( + "Credentials and nonce are both set. If nonce is set, then you must not supply credentials", + )); + } + Some(credentials) => credentials, + None => { + inner + .create_or_derive_api_key(self.signer, self.nonce) + .await? + } + }; + + let state = Authenticated { + address: self.signer.address(), + credentials, + kind: self.kind, + }; + + #[cfg_attr( + not(feature = "heartbeats"), + expect( + unused_mut, + reason = "Modifier only needed when heartbeats feature is enabled" + ) + )] + let mut client = Client { + inner: Arc::new(ClientInner { + state, + config: inner.config, + host: inner.host, + geoblock_host: inner.geoblock_host, + client: inner.client, + tick_sizes: inner.tick_sizes, + neg_risk: inner.neg_risk, + fee_rate_bps: inner.fee_rate_bps, + funder, + signature_type: self.signature_type.unwrap_or(SignatureType::Eoa), + salt_generator: self.salt_generator.unwrap_or(generate_seed), + }), + #[cfg(feature = "heartbeats")] + heartbeat_token: DroppingCancellationToken(None), + }; + + #[cfg(feature = "heartbeats")] + Client::>::start_heartbeats(&mut client)?; + + Ok(client) + } +} + +/// The main way for API users to interact with the Polymarket CLOB. +/// +/// A [`Client`] can either be [`Unauthenticated`] or [`Authenticated`], that is, authenticated +/// with a particular [`Signer`], `S`, and a particular [`Kind`], `K`. That [`Kind`] lets +/// the client know if it's authenticating [`Normal`]ly or as a [`auth::builder::Builder`]. +/// +/// Only the allowed methods will be available for use when in a particular state, i.e. only +/// unauthenticated methods will be visible when unauthenticated, same for authenticated/builder +/// authenticated methods. +/// +/// [`Client`] is thread-safe +/// +/// Create an unauthenticated client: +/// ```rust,no_run +/// use polymarket_client_sdk::Result; +/// use polymarket_client_sdk::clob::{Client, Config}; +/// +/// #[tokio::main] +/// async fn main() -> Result<()> { +/// let client = Client::new("https://clob.polymarket.com", Config::default())?; +/// +/// let ok = client.ok().await?; +/// println!("Ok: {ok}"); +/// +/// Ok(()) +/// } +/// ``` +/// +/// Elevate into an authenticated client: +/// ```rust,no_run +/// use std::str::FromStr as _; +/// +/// use alloy::signers::Signer as _; +/// use alloy::signers::local::LocalSigner; +/// use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +/// use polymarket_client_sdk::clob::{Client, Config}; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let private_key = std::env::var(PRIVATE_KEY_VAR).expect("Need a private key"); +/// let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); +/// let client = Client::new("https://clob.polymarket.com", Config::default())? +/// .authentication_builder(&signer) +/// .authenticate() +/// .await?; +/// +/// let ok = client.ok().await?; +/// println!("Ok: {ok}"); +/// +/// let api_keys = client.api_keys().await?; +/// println!("API keys: {api_keys:?}"); +/// +/// Ok(()) +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct Client { + inner: Arc>, + #[cfg(feature = "heartbeats")] + /// When the `heartbeats` feature is enabled, the authenticated [`Client`] will automatically + /// send heartbeats at the default cadence. See [`Config`] for more details. + heartbeat_token: DroppingCancellationToken, +} + +#[cfg(feature = "heartbeats")] +/// A specific wrapper type to invoke the inner [`CancellationToken`] (if it's present) to: +/// 1. Avoid manually implementing [`Drop`] for [`Client`] which causes issues with moving values +/// out of such a type +/// 2. Replace the (currently non-existent) ability of specialized implementations of [`Drop`] +/// +/// +/// This way, the inner token is expressly cancelled when [`DroppingCancellationToken`] is dropped. +/// We also have a [`Receiver<()>`] to notify when the inner [`Client`] has been dropped so that +/// we can avoid a race condition when calling [`Arc::into_inner`] on promotion and demotion methods. +#[derive(Clone, Debug, Default)] +struct DroppingCancellationToken(Option<(CancellationToken, Arc>)>); + +#[cfg(feature = "heartbeats")] +impl DroppingCancellationToken { + /// Cancel the inner [`CancellationToken`] and wait to be notified of the relevant cleanup via + /// [`Receiver`]. This is primarily used by the authentication methods when promoting [`Client`]s + /// to ensure that we do not error when transferring ownership of [`ClientInner`]. + pub(crate) async fn cancel_and_wait(&mut self) -> Result<()> { + if let Some((token, rx)) = self.0.take() { + return match Arc::try_unwrap(rx) { + // If this is the only reference, cancel the token and wait for the resources to be + // cleaned up. + Ok(inner) => { + token.cancel(); + _ = inner.await; + Ok(()) + } + // If not, _save_ the original token and receiver to re-use later if desired + Err(original) => { + *self = DroppingCancellationToken(Some((token, original))); + Err(Synchronization.into()) + } + }; + } + + Ok(()) + } +} + +#[cfg(feature = "heartbeats")] +impl Drop for DroppingCancellationToken { + fn drop(&mut self) { + if let Some((token, _)) = self.0.take() { + token.cancel(); + } + } +} + +impl Default for Client { + fn default() -> Self { + Client::new("https://clob.polymarket.com", Config::default()) + .expect("Client with default endpoint should succeed") + } +} + +/// Configuration for [`Client`] +#[derive(Clone, Debug, Default, Builder)] +pub struct Config { + /// Whether the [`Client`] will use the server time provided by Polymarket when creating auth + /// headers. This adds another round trip to the requests. + #[builder(default)] + use_server_time: bool, + /// Override for the geoblock API host. Defaults to `https://polymarket.com`. + /// This is primarily useful for testing. + #[builder(into)] + geoblock_host: Option, + #[cfg(feature = "heartbeats")] + #[builder(default = Duration::from_secs(5))] + /// How often the [`Client`] will automatically submit heartbeats. The default is five (5) seconds. + heartbeat_interval: Duration, +} + +/// The default geoblock API host (separate from CLOB host) +const DEFAULT_GEOBLOCK_HOST: &str = "https://polymarket.com"; + +#[derive(Debug)] +struct ClientInner { + config: Config, + /// The current [`State`] of this client + state: S, + /// The [`Url`] against which `client` is making requests. + host: Url, + /// The [`Url`] for the geoblock API endpoint. + geoblock_host: Url, + /// The inner [`ReqwestClient`] used to make requests to `host`. + client: ReqwestClient, + /// Local cache of [`TickSize`] per token ID + tick_sizes: DashMap, + /// Local cache representing whether this token is part of a `neg_risk` market + neg_risk: DashMap, + /// Local cache representing the fee rate in basis points per token ID + fee_rate_bps: DashMap, + /// The funder for this [`ClientInner`]. If funder is present, then `signature_type` cannot + /// be [`SignatureType::Eoa`]. Conversely, if funder is absent, then `signature_type` cannot be + /// [`SignatureType::Proxy`] or [`SignatureType::GnosisSafe`]. + funder: Option
, + /// The signature type for this [`ClientInner`]. Defaults to [`SignatureType::Eoa`] + signature_type: SignatureType, + /// The salt/seed generator for use in creating [`SignableOrder`]s + salt_generator: fn() -> u64, +} + +impl ClientInner { + pub async fn server_time(&self) -> Result { + let request = self + .client + .request(Method::GET, format!("{}time", self.host)) + .build()?; + + crate::request(&self.client, request, None).await + } +} + +impl ClientInner { + pub async fn create_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + let request = self + .client + .request(Method::POST, format!("{}auth/api-key", self.host)) + .build()?; + let headers = self.create_headers(signer, nonce).await?; + + crate::request(&self.client, request, Some(headers)).await + } + + pub async fn derive_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + let request = self + .client + .request(Method::GET, format!("{}auth/derive-api-key", self.host)) + .build()?; + let headers = self.create_headers(signer, nonce).await?; + + crate::request(&self.client, request, Some(headers)).await + } + + async fn create_or_derive_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + match self.create_api_key(signer, nonce).await { + Ok(creds) => Ok(creds), + Err(err) if err.kind() == ErrorKind::Status => { + // Only fall back to derive_api_key for HTTP status errors (server responded + // with an error, e.g., key already exists). Propagate network/internal errors. + self.derive_api_key(signer, nonce).await + } + Err(err) => Err(err), + } + } + + async fn create_headers(&self, signer: &S, nonce: Option) -> Result { + let chain_id = signer.chain_id().ok_or(Error::validation( + "Chain id not set, be sure to provide one on the signer", + ))?; + + let timestamp = if self.config.use_server_time { + self.server_time().await? + } else { + Utc::now().timestamp() + }; + + auth::l1::create_headers(signer, chain_id, timestamp, nonce).await + } +} + +impl Client { + /// Returns the CLOB API host URL. + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::clob::{Client, Config}; + /// # fn main() -> Result<(), Box> { + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// println!("Host: {}", client.host()); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn host(&self) -> &Url { + &self.inner.host + } + + /// Invalidates all internal caches (tick sizes, neg risk flags, and fee rates). + /// + /// This method clears the cached market configuration data, forcing subsequent + /// requests to fetch fresh data from the API. Use this when you suspect + /// cached data may be stale. + pub fn invalidate_internal_caches(&self) { + self.inner.tick_sizes.clear(); + self.inner.fee_rate_bps.clear(); + self.inner.neg_risk.clear(); + } + + /// Pre-populates the tick size cache for a token, avoiding the HTTP call. + /// + /// Use this when you already have the tick size data from another source + /// (e.g., cached locally or retrieved from a different API). + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::clob::{Client, Config, types::TickSize}; + /// # fn main() -> Result<(), Box> { + /// use polymarket_client_sdk::types::U256; + /// + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// client.set_tick_size(U256::ZERO, TickSize::Hundredth); + /// # Ok(()) + /// # } + /// ``` + pub fn set_tick_size(&self, token_id: U256, tick_size: TickSize) { + self.inner.tick_sizes.insert(token_id, tick_size); + } + + /// Pre-populates the neg risk cache for a token, avoiding the HTTP call. + /// + /// Use this when you already have the neg risk data from another source + /// (e.g., cached locally or retrieved from a different API). + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::clob::{Client, Config}; + /// # fn main() -> Result<(), Box> { + /// use polymarket_client_sdk::types::U256; + /// + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// client.set_neg_risk(U256::ZERO, true); + /// # Ok(()) + /// # } + /// ``` + pub fn set_neg_risk(&self, token_id: U256, neg_risk: bool) { + self.inner.neg_risk.insert(token_id, neg_risk); + } + + /// Pre-populates the fee rate cache for a token, avoiding the HTTP call. + /// + /// Use this when you already have the fee rate data from another source + /// (e.g., cached locally or retrieved from a different API). The fee rate + /// is specified in basis points (bps), where 100 bps = 1%. + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::clob::{Client, Config}; + /// # fn main() -> Result<(), Box> { + /// use polymarket_client_sdk::types::U256; + /// + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// client.set_fee_rate_bps(U256::ZERO, 10); // 0.10% fee + /// # Ok(()) + /// # } + /// ``` + pub fn set_fee_rate_bps(&self, token_id: U256, fee_rate_bps: u32) { + self.inner.fee_rate_bps.insert(token_id, fee_rate_bps); + } + + /// Checks if the CLOB API is healthy and operational. + /// + /// Returns "OK" if the API is functioning properly. This method is useful + /// for health checks and monitoring the API status. + /// + /// # Errors + /// + /// Returns an error if the network request fails or the API is unreachable. + pub async fn ok(&self) -> Result { + let request = self + .client() + .request(Method::GET, self.host().to_owned()) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Returns the current server timestamp in milliseconds since Unix epoch. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn server_time(&self) -> Result { + self.inner.server_time().await + } + + /// Retrieves the midpoint price for a single market outcome token. + /// + /// The midpoint is the average of the best bid and best ask prices, + /// calculated as `(best_bid + best_ask) / 2`. This represents a fair + /// market price estimate for the token. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn midpoint(&self, request: &MidpointRequest) -> Result { + let params = request.query_params(None); + let request = self + .client() + .request(Method::GET, format!("{}midpoint{params}", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves midpoint prices for multiple market outcome tokens in a single request. + /// + /// This is the batch version of [`Self::midpoint`]. Returns midpoint prices + /// for all requested tokens, allowing efficient bulk price queries. + /// + /// # Errors + /// + /// Returns an error if the request fails or any token ID is invalid. + pub async fn midpoints(&self, requests: &[MidpointRequest]) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}midpoints", self.host())) + .json(requests) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves the current price for a market outcome token on a specific side. + /// + /// Returns the best available price for buying (BUY side) or selling (SELL side) + /// the specified token. This reflects the actual executable price on the orderbook. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn price(&self, request: &PriceRequest) -> Result { + let params = request.query_params(None); + let request = self + .client() + .request(Method::GET, format!("{}price{params}", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves prices for multiple market outcome tokens on their specific sides. + /// + /// This is the batch version of [`Self::price`]. Allows querying prices + /// for many tokens at once, with each request specifying its own side (BUY or SELL). + /// + /// # Errors + /// + /// Returns an error if the request fails or any token ID is invalid. + pub async fn prices(&self, requests: &[PriceRequest]) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}prices", self.host())) + .json(requests) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves prices for all available market outcome tokens. + /// + /// Returns the current best bid and ask prices for every active token + /// in the system. This is useful for getting a complete market overview. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn all_prices(&self) -> Result { + let request = self + .client() + .request(Method::GET, format!("{}prices", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves historical price data for a market. + /// + /// Returns time-series price data over a specified time range or interval. + /// The `fidelity` parameter controls the granularity of data points returned. + /// + /// # Errors + /// + /// Returns an error if the request fails or the market ID is invalid. + pub async fn price_history( + &self, + request: &PriceHistoryRequest, + ) -> Result { + let params = request.query_params(None); + let req = self.client().request( + Method::GET, + format!("{}prices-history{params}", self.host()), + ); + + crate::request(&self.inner.client, req.build()?, None).await + } + + /// Retrieves the bid-ask spread for a single market outcome token. + /// + /// The spread is the difference between the best ask price and the best bid price, + /// representing the cost of immediate execution. A smaller spread indicates higher + /// liquidity and more efficient markets. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn spread(&self, request: &SpreadRequest) -> Result { + let params = request.query_params(None); + let request = self + .client() + .request(Method::GET, format!("{}spread{params}", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves bid-ask spreads for multiple market outcome tokens. + /// + /// This is the batch version of [`Self::spread`], allowing efficient + /// retrieval of spread data for many tokens simultaneously. + /// + /// # Errors + /// + /// Returns an error if the request fails or any token ID is invalid. + pub async fn spreads(&self, requests: &[SpreadRequest]) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}spreads", self.host())) + .json(requests) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves the minimum tick size for a market outcome token. + /// + /// The tick size defines the minimum price increment for orders on this token. + /// Results are cached internally to reduce API calls. For example, a tick size + /// of 0.01 means prices must be in increments of $0.01. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn tick_size(&self, token_id: U256) -> Result { + if let Some(tick_size) = self.inner.tick_sizes.get(&token_id) { + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, tick_size = ?tick_size.value(), "cache hit: tick_size"); + return Ok(TickSizeResponse { + minimum_tick_size: *tick_size, + }); + } + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cache miss: tick_size"); + + let request = self + .client() + .request(Method::GET, format!("{}tick-size", self.host())) + .query(&[("token_id", token_id.to_string())]) + .build()?; + + let response = + crate::request::(&self.inner.client, request, None).await?; + + self.inner + .tick_sizes + .insert(token_id, response.minimum_tick_size); + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cached tick_size"); + + Ok(response) + } + + /// Checks if a market outcome token uses the negative risk (`NegRisk`) adapter. + /// + /// `NegRisk` markets have special settlement logic where one outcome is + /// "negative" (representing an event not happening). Returns `true` if the + /// token requires the `NegRisk` adapter contract. Results are cached internally. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn neg_risk(&self, token_id: U256) -> Result { + if let Some(neg_risk) = self.inner.neg_risk.get(&token_id) { + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, neg_risk = *neg_risk, "cache hit: neg_risk"); + return Ok(NegRiskResponse { + neg_risk: *neg_risk, + }); + } + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cache miss: neg_risk"); + + let request = self + .client() + .request(Method::GET, format!("{}neg-risk", self.host())) + .query(&[("token_id", token_id.to_string())]) + .build()?; + + let response = crate::request::(&self.inner.client, request, None).await?; + + self.inner.neg_risk.insert(token_id, response.neg_risk); + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cached neg_risk"); + + Ok(response) + } + + /// Retrieves the trading fee rate for a market outcome token. + /// + /// Returns the fee rate in basis points (bps) charged on trades for this token. + /// For example, 10 bps = 0.10% fee. Results are cached internally to reduce API calls. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn fee_rate_bps(&self, token_id: U256) -> Result { + if let Some(base_fee) = self.inner.fee_rate_bps.get(&token_id) { + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, base_fee = *base_fee, "cache hit: fee_rate_bps"); + return Ok(FeeRateResponse { + base_fee: *base_fee, + }); + } + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cache miss: fee_rate_bps"); + + let request = self + .client() + .request(Method::GET, format!("{}fee-rate", self.host())) + .query(&[("token_id", token_id.to_string())]) + .build()?; + + let response = crate::request::(&self.inner.client, request, None).await?; + + self.inner.fee_rate_bps.insert(token_id, response.base_fee); + + #[cfg(feature = "tracing")] + tracing::trace!(token_id = %token_id, "cached fee_rate_bps"); + + Ok(response) + } + + /// Checks if the current IP address is geoblocked from accessing Polymarket. + /// + /// This method queries the Polymarket geoblock endpoint to determine if access + /// is restricted based on the caller's IP address and geographic location. + /// + /// # Returns + /// + /// Returns `Ok(GeoblockResponse)` containing the geoblock status and location info. + /// Check the `blocked` field to determine if access is restricted. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be parsed. + /// + /// # Example + /// + /// ```rust,no_run + /// use polymarket_client_sdk::clob::{Client, Config}; + /// use polymarket_client_sdk::error::{Kind, Geoblock}; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// + /// let geoblock = client.check_geoblock().await?; + /// + /// if geoblock.blocked { + /// eprintln!( + /// "Trading not available in {}, {}", + /// geoblock.country, geoblock.region + /// ); + /// // Optionally convert to an error: + /// // return Err(Geoblock { + /// // ip: geoblock.ip, + /// // country: geoblock.country, + /// // region: geoblock.region, + /// // }.into()); + /// } else { + /// println!("Trading available from IP: {}", geoblock.ip); + /// } + /// + /// Ok(()) + /// } + /// ``` + pub async fn check_geoblock(&self) -> Result { + let request = self + .client() + .request( + Method::GET, + format!("{}api/geoblock", self.inner.geoblock_host), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves the full orderbook for a market outcome token. + /// + /// Returns all active bids and asks at various price levels, showing + /// the depth of liquidity available in the market. This includes the + /// best bid, best ask, and the full order depth on both sides. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn order_book( + &self, + request: &OrderBookSummaryRequest, + ) -> Result { + let params = request.query_params(None); + let request = self + .client() + .request(Method::GET, format!("{}book{params}", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves orderbooks for multiple market outcome tokens. + /// + /// This is the batch version of [`Self::order_book`], allowing efficient + /// retrieval of orderbook data for many tokens in a single request. + /// + /// # Errors + /// + /// Returns an error if the request fails or any token ID is invalid. + pub async fn order_books( + &self, + requests: &[OrderBookSummaryRequest], + ) -> Result> { + let request = self + .client() + .request(Method::POST, format!("{}books", self.host())) + .json(requests) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves the price of the most recent trade for a market outcome token. + /// + /// Returns the last executed trade price, which represents the most recent + /// market consensus price. This is useful for tracking real-time price movements. + /// + /// # Errors + /// + /// Returns an error if the request fails or the token ID is invalid. + pub async fn last_trade_price( + &self, + request: &LastTradePriceRequest, + ) -> Result { + let params = request.query_params(None); + let request = self + .client() + .request( + Method::GET, + format!("{}last-trade-price{params}", self.host()), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves the last trade prices for multiple market outcome tokens. + /// + /// This is the batch version of [`Self::last_trade_price`], returning + /// the most recent executed trade price for each requested token. + /// + /// # Errors + /// + /// Returns an error if the request fails or any token ID is invalid. + pub async fn last_trades_prices( + &self, + token_ids: &[LastTradePriceRequest], + ) -> Result> { + let request = self + .client() + .request(Method::GET, format!("{}last-trades-prices", self.host())) + .json(token_ids) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves detailed information for a single market by condition ID. + /// + /// Returns comprehensive market data including all outcome tokens, current prices, + /// volume, and market metadata. The condition ID uniquely identifies the market. + /// + /// # Errors + /// + /// Returns an error if the request fails or the condition ID is invalid. + pub async fn market(&self, condition_id: &str) -> Result { + let request = self + .client() + .request( + Method::GET, + format!("{}markets/{condition_id}", self.host()), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves a page of all active markets. + /// + /// Returns a paginated list of all markets with their full details. + /// Use the `next_cursor` from the response to fetch subsequent pages. + /// Useful for iterating through all available markets. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn markets(&self, next_cursor: Option) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request(Method::GET, format!("{}markets{cursor}", self.host())) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves a page of sampling markets. + /// + /// Returns a paginated list of markets designated for the sampling program, + /// where market makers can earn rewards. Use the `next_cursor` from the + /// response to fetch subsequent pages. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn sampling_markets( + &self, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request( + Method::GET, + format!("{}sampling-markets{cursor}", self.host()), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves a page of simplified market data. + /// + /// Returns a paginated list of markets with reduced detail, providing only + /// essential information for faster queries and lower bandwidth usage. + /// Use the `next_cursor` from the response to fetch subsequent pages. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn simplified_markets( + &self, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request( + Method::GET, + format!("{}simplified-markets{cursor}", self.host()), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Retrieves a page of simplified sampling market data. + /// + /// Returns a paginated list of sampling program markets with reduced detail. + /// Combines the efficiency of simplified queries with the filtering of + /// sampling markets. Use the `next_cursor` from the response to fetch subsequent pages. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn sampling_simplified_markets( + &self, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request( + Method::GET, + format!("{}sampling-simplified-markets{cursor}", self.host()), + ) + .build()?; + + crate::request(&self.inner.client, request, None).await + } + + /// Returns a stream of results, using `self` to repeatedly invoke the provided closure, + /// `call`, which takes the next cursor to query against. Each `call` returns a future + /// that returns a [`Page`]. Each page is flattened into the underlying data in the stream. + pub fn stream_data<'client, Call, Fut, Data>( + &'client self, + call: Call, + ) -> impl Stream> + 'client + where + Call: Fn(&'client Client, Option) -> Fut + 'client, + Fut: Future>> + 'client, + Data: 'client, + { + try_stream! { + let mut cursor: Option = None; + + loop { + let page = call(self, mem::take(&mut cursor)).await?; + + for item in page.data { + yield item + } + + if page.next_cursor == TERMINAL_CURSOR { + break; + } + + cursor = Some(page.next_cursor); + } + } + } + + fn client(&self) -> &ReqwestClient { + &self.inner.client + } +} + +impl Client { + /// Creates a new unauthenticated CLOB client. + /// + /// This client can access public API endpoints like market data, prices, + /// and orderbooks. To place orders or access user-specific endpoints, + /// use [`Self::authentication_builder`] to upgrade to an authenticated client. + /// + /// # Arguments + /// + /// * `host` - The CLOB API URL (e.g., ) + /// * `config` - Client configuration options + /// + /// # Errors + /// + /// Returns an error if the host URL is invalid or the HTTP client cannot be initialized. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::clob::{Client, Config}; + /// + /// # fn main() -> Result<(), Box> { + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// # Ok(()) + /// # } + /// ``` + pub fn new(host: &str, config: Config) -> Result> { + let mut headers = HeaderMap::new(); + + headers.insert("User-Agent", HeaderValue::from_static("rs_clob_client")); + headers.insert("Accept", HeaderValue::from_static("*/*")); + headers.insert("Connection", HeaderValue::from_static("keep-alive")); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + let client = ReqwestClient::builder().default_headers(headers).build()?; + + let geoblock_host = Url::parse( + config + .geoblock_host + .as_deref() + .unwrap_or(DEFAULT_GEOBLOCK_HOST), + )?; + + Ok(Self { + inner: Arc::new(ClientInner { + config, + host: Url::parse(host)?, + geoblock_host, + client, + tick_sizes: DashMap::new(), + neg_risk: DashMap::new(), + fee_rate_bps: DashMap::new(), + state: Unauthenticated, + funder: None, + signature_type: SignatureType::Eoa, + salt_generator: generate_seed, + }), + #[cfg(feature = "heartbeats")] + heartbeat_token: DroppingCancellationToken(None), + }) + } + + /// Creates an authentication builder to upgrade this client to authenticated mode. + /// + /// Returns an [`AuthenticationBuilder`] that can be configured with credentials + /// or used to create/derive API keys. Call [`AuthenticationBuilder::authenticate`] + /// to complete the upgrade to an authenticated client. + /// + /// # Arguments + /// + /// * `signer` - A wallet signer used to generate authentication signatures + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::clob::{Client, Config}; + /// use alloy::signers::local::LocalSigner; + /// use std::str::FromStr; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::new("https://clob.polymarket.com", Config::default())?; + /// let signer = LocalSigner::from_str("0x...")?; + /// + /// let authenticated_client = client + /// .authentication_builder(&signer) + /// .authenticate() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn authentication_builder( + self, + signer: &S, + ) -> AuthenticationBuilder<'_, S, Normal> { + AuthenticationBuilder { + signer, + credentials: None, + nonce: None, + kind: Normal, + funder: self.inner.funder, + signature_type: Some(self.inner.signature_type), + client: self, + salt_generator: None, + } + } + + /// Attempts to create a new set of [`Credentials`] and returns an error if there already is one + /// for the particular L2 header's (signer) `address` and `nonce`. + pub async fn create_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + self.inner.create_api_key(signer, nonce).await + } + + /// Attempts to derive an existing set of [`Credentials`] and returns an error if there + /// are none for the particular L2 header's (signer) `address` and `nonce`. + pub async fn derive_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + self.inner.derive_api_key(signer, nonce).await + } + + /// Idempotent alternative to [`Self::create_api_key`] and [`Self::derive_api_key`], which will + /// either create a new set of [`Credentials`] if they do not exist already, or return them if + /// they do. + pub async fn create_or_derive_api_key( + &self, + signer: &S, + nonce: Option, + ) -> Result { + self.inner.create_or_derive_api_key(signer, nonce).await + } +} + +impl Client> { + /// Demotes this authenticated [`Client>`] to an unauthenticated one + #[cfg_attr( + not(feature = "heartbeats"), + expect( + clippy::unused_async, + unused_mut, + reason = "Nothing to await or modify when heartbeats are disabled" + ) + )] + pub async fn deauthenticate(mut self) -> Result> { + #[cfg(feature = "heartbeats")] + self.heartbeat_token.cancel_and_wait().await?; + + let inner = Arc::into_inner(self.inner).ok_or(Synchronization)?; + + Ok(Client:: { + inner: Arc::new(ClientInner { + state: Unauthenticated, + host: inner.host, + geoblock_host: inner.geoblock_host, + config: inner.config, + client: inner.client, + tick_sizes: inner.tick_sizes, + neg_risk: inner.neg_risk, + fee_rate_bps: inner.fee_rate_bps, + // Reset the order parameters that were previously stored on the client + funder: None, + signature_type: SignatureType::Eoa, + salt_generator: generate_seed, + }), + #[cfg(feature = "heartbeats")] + heartbeat_token: DroppingCancellationToken(None), + }) + } + + /// Returns a reference to the authenticated state. + /// + /// Provides access to authentication details including the wallet address + /// and credentials used by this client. + #[must_use] + pub fn state(&self) -> &Authenticated { + &self.inner.state + } + + /// Returns the wallet address associated with this authenticated client. + /// + /// This is the address that was used to authenticate and will be used + /// for signing orders and other authenticated operations. + #[must_use] + pub fn address(&self) -> Address { + self.state().address + } + + /// Returns the credentials associated with this authenticated client. + /// + /// These credentials are required to authorize interactions with the CLOB + /// and authenticate the WebSocket user channel connection. + #[must_use] + pub fn credentials(&self) -> &Credentials { + &self.state().credentials + } + + /// Return all API keys associated with the address corresponding to the inner signer in + /// [`Authenticated`]. + pub async fn api_keys(&self) -> Result { + let request = self + .client() + .request(Method::GET, format!("{}auth/api-keys", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Deletes the current API key used by this authenticated client. + /// + /// After deletion, this client will no longer be able to access authenticated + /// endpoints. You will need to create or derive a new API key to continue. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API key cannot be deleted. + pub async fn delete_api_key(&self) -> Result { + let request = self + .client() + .request(Method::DELETE, format!("{}auth/api-key", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Checks if the account is in closed-only mode (banned from opening new positions). + /// + /// Returns the ban status indicating whether the user can only close existing + /// positions or is allowed to open new positions. Users in closed-only mode + /// can cancel orders and close positions but cannot create new positions. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn closed_only_mode(&self) -> Result { + let request = self + .client() + .request( + Method::GET, + format!("{}auth/ban-status/closed-only", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Creates an [`OrderBuilder`] used to construct a limit order. + #[must_use] + pub fn limit_order(&self) -> OrderBuilder { + self.order_builder() + } + + /// Creates an [`OrderBuilder`] used to construct a market order. + #[must_use] + pub fn market_order(&self) -> OrderBuilder { + self.order_builder() + } + + /// Attempts to sign the provided [`SignableOrder`] using the inner signer of [`Authenticated`] + #[expect( + clippy::missing_panics_doc, + reason = "No need to publicly document as we are guarded by the typestate pattern. \ + We cannot call `sign` without first calling `authenticate`" + )] + pub async fn sign( + &self, + signer: &S, + SignableOrder { + order, + order_type, + post_only, + }: SignableOrder, + ) -> Result { + let token_id = order.tokenId; + let neg_risk = self.neg_risk(token_id).await?.neg_risk; + let chain_id = signer + .chain_id() + .expect("Validated not none in `authenticate`"); + + let exchange_contract = contract_config(chain_id, neg_risk) + .ok_or(Error::missing_contract_config(chain_id, neg_risk))? + .exchange; + + let domain = Eip712Domain { + name: ORDER_NAME, + version: VERSION, + chain_id: Some(U256::from(chain_id)), + verifying_contract: Some(exchange_contract), + ..Eip712Domain::default() + }; + + let signature = signer + .sign_hash(&order.eip712_signing_hash(&domain)) + .await?; + + Ok(SignedOrder { + order, + signature, + order_type, + owner: self.state().credentials.key, + post_only, + }) + } + + /// Posts a signed order to the orderbook. + /// + /// Submits a single limit or market order that has been signed with the + /// user's wallet. The order will be validated and added to the orderbook + /// if it meets all requirements (sufficient balance, valid price, etc.). + /// + /// # Errors + /// + /// Returns an error if: + /// - The order signature is invalid + /// - The user has insufficient balance or allowance + /// - The order price/size violates market rules + /// - The request fails + pub async fn post_order(&self, order: SignedOrder) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}order", self.host())) + .json(&order) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Posts multiple signed orders to the orderbook in a single request. + /// + /// This is the batch version of [`Self::post_order`], allowing efficient + /// submission of multiple orders at once. All orders are validated and + /// processed atomically. + /// + /// # Errors + /// + /// Returns an error if any order fails validation or the request fails. + pub async fn post_orders(&self, orders: Vec) -> Result> { + let request = self + .client() + .request(Method::POST, format!("{}orders", self.host())) + .json(&orders) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Attempts to return the corresponding order at the provided `order_id` + pub async fn order(&self, order_id: &str) -> Result { + let request = self + .client() + .request(Method::GET, format!("{}data/order/{order_id}", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves a paginated list of orders matching the specified criteria. + /// + /// Returns orders filtered by token ID, market condition, or other parameters + /// specified in the request. Use the `next_cursor` from the response to fetch + /// subsequent pages. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn orders( + &self, + request: &OrdersRequest, + next_cursor: Option, + ) -> Result> { + let params = request.query_params(next_cursor.as_deref()); + let request = self + .client() + .request(Method::GET, format!("{}data/orders{params}", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Cancels a single order by its order ID. + /// + /// Removes an open order from the orderbook. The order must belong to + /// the authenticated user and must still be active (not filled or expired). + /// + /// # Errors + /// + /// Returns an error if the order ID is invalid, the order doesn't exist, + /// or the request fails. + pub async fn cancel_order(&self, order_id: &str) -> Result { + let request = self + .client() + .request(Method::DELETE, format!("{}order", self.host())) + .json(&json!({ "orderId": order_id })) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Cancels multiple orders by their order IDs in a single request. + /// + /// This is the batch version of [`Self::cancel_order`], allowing efficient + /// cancellation of many orders at once. All specified orders must belong + /// to the authenticated user. + /// + /// # Errors + /// + /// Returns an error if any order ID is invalid or the request fails. + pub async fn cancel_orders(&self, order_ids: &[&str]) -> Result { + let request = self + .client() + .request(Method::DELETE, format!("{}orders", self.host())) + .json(&json!(order_ids)) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Cancels all open orders for the authenticated user. + /// + /// Removes every active order from the orderbook for this account. + /// Use with caution as this operation cannot be undone. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn cancel_all_orders(&self) -> Result { + let request = self + .client() + .request(Method::DELETE, format!("{}cancel-all", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Attempts to cancel all open orders for a particular [`CancelMarketOrderRequest::market`] + /// and/or [`CancelMarketOrderRequest::asset_id`] + pub async fn cancel_market_orders( + &self, + request: &CancelMarketOrderRequest, + ) -> Result { + let request = self + .client() + .request( + Method::DELETE, + format!("{}cancel-market-orders", self.host()), + ) + .json(&request) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves a paginated list of trades for the authenticated user. + /// + /// Returns executed trades filtered by the criteria in the request (token ID, + /// market, maker/taker side, etc.). Use the `next_cursor` from the response + /// to fetch subsequent pages. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn trades( + &self, + request: &TradesRequest, + next_cursor: Option, + ) -> Result> { + let params = request.query_params(next_cursor.as_deref()); + let request = self + .client() + .request(Method::GET, format!("{}data/trades{params}", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves all notifications for the authenticated user. + /// + /// Returns order fill notifications, cancellations, and other trading events. + /// Notifications help track order status changes asynchronously. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn notifications(&self) -> Result> { + let request = self + .client() + .request(Method::GET, format!("{}notifications", self.host())) + .query(&[("signature_type", self.inner.signature_type as u8)]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Deletes notifications matching the specified IDs. + /// + /// Removes notifications from the user's notification list. This is useful + /// for cleaning up old notifications after they've been processed. + /// + /// # Errors + /// + /// Returns an error if the request fails or the notification IDs are invalid. + pub async fn delete_notifications(&self, request: &DeleteNotificationsRequest) -> Result<()> { + let params = request.query_params(None); + let mut request = self + .client() + .request( + Method::DELETE, + format!("{}notifications{params}", self.host()), + ) + .json(&request) + .build()?; + let headers = self.create_headers(&request).await?; + *request.headers_mut() = headers; + + // We have to send the request separately from `self.request` because this endpoint does + // not return anything in the response body. Otherwise, we would get an EOF error from reqwest + self.client().execute(request).await?; + + Ok(()) + } + + /// Retrieves the user's USDC balance and token allowances. + /// + /// Returns the current USDC balance in the user's wallet and the allowance + /// granted to the CLOB exchange contract. The allowance must be sufficient + /// to place orders. This query updates internal cached balance state. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn balance_allowance( + &self, + mut request: BalanceAllowanceRequest, + ) -> Result { + if request.signature_type.is_none() { + request.signature_type = Some(self.inner.signature_type); + } + + let params = request.query_params(None); + let request = self + .client() + .request( + Method::GET, + format!("{}balance-allowance{params}", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Forces an update of the cached balance and allowance data. + /// + /// Triggers the CLOB backend to refresh its cached view of the user's + /// on-chain balance and allowances. Use this after approving tokens or + /// depositing USDC to ensure the exchange recognizes the updated state. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn update_balance_allowance( + &self, + mut request: UpdateBalanceAllowanceRequest, + ) -> Result<()> { + if request.signature_type.is_none() { + request.signature_type = Some(self.inner.signature_type); + } + + let params = request.query_params(None); + let mut request = self + .client() + .request( + Method::GET, + format!("{}balance-allowance/update{params}", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + *request.headers_mut() = headers; + + // We have to send the request separately from `self.request` because this endpoint does + // not return anything in the response body. Otherwise, we would get an EOF error from reqwest + self.client().execute(request).await?; + + Ok(()) + } + + /// Checks if an order is eligible for market maker rewards. + /// + /// Returns whether the specified order qualifies for the sampling program + /// rewards based on its market, size, and other criteria. Only certain markets + /// and order sizes are eligible for rewards. + /// + /// # Errors + /// + /// Returns an error if the order ID is invalid or the request fails. + pub async fn is_order_scoring(&self, order_id: &str) -> Result { + let request = self + .client() + .request(Method::GET, format!("{}order-scoring", self.host())) + .query(&[("order_id", order_id)]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Checks if multiple orders are eligible for market maker rewards. + /// + /// This is the batch version of [`Self::is_order_scoring`], allowing efficient + /// checking of reward eligibility for many orders at once. + /// + /// # Errors + /// + /// Returns an error if any order ID is invalid or the request fails. + pub async fn are_orders_scoring(&self, order_ids: &[&str]) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}orders-scoring", self.host())) + .json(&order_ids) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves detailed market maker earnings for a specific day. + /// + /// Returns a paginated list of reward earnings broken down by market and order + /// for the specified date. Use this to track individual reward-earning orders. + /// + /// # Errors + /// + /// Returns an error if the request fails or the date format is invalid. + pub async fn earnings_for_user_for_day( + &self, + date: NaiveDate, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request(Method::GET, format!("{}rewards/user{cursor}", self.host())) + .query(&[ + ("date", date.to_string()), + ( + "signature_type", + (self.inner.signature_type as u8).to_string(), + ), + ]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves total market maker earnings summary for a specific day. + /// + /// Returns aggregated reward totals for the specified date, providing a + /// high-level view of earnings without per-order details. + /// + /// # Errors + /// + /// Returns an error if the request fails or the date format is invalid. + pub async fn total_earnings_for_user_for_day( + &self, + date: NaiveDate, + ) -> Result> { + let request = self + .client() + .request(Method::GET, format!("{}rewards/user/total", self.host())) + .query(&[ + ("date", date.to_string()), + ( + "signature_type", + (self.inner.signature_type as u8).to_string(), + ), + ]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves user earnings along with market reward configurations. + /// + /// Returns earnings data combined with the reward configuration for each market, + /// helping understand which markets offer rewards and their earning potential. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn user_earnings_and_markets_config( + &self, + request: &UserRewardsEarningRequest, + next_cursor: Option, + ) -> Result> { + let params = request.query_params(next_cursor.as_deref()); + let request = self + .client() + .request( + Method::GET, + format!("{}rewards/user/total{params}", self.host()), + ) + .query(&[( + "signature_type", + (self.inner.signature_type as u8).to_string(), + )]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves the user's current reward earning percentages. + /// + /// Returns the percentage of total rewards the user is earning across + /// different markets, indicating market making performance relative to others. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn reward_percentages(&self) -> Result { + let request = self + .client() + .request( + Method::GET, + format!("{}rewards/user/percentages", self.host()), + ) + .query(&[( + "signature_type", + (self.inner.signature_type as u8).to_string(), + )]) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves current active reward programs and their configurations. + /// + /// Returns information about ongoing reward programs, including eligible markets, + /// reward amounts, and program parameters. Use this to discover opportunities + /// for earning market maker rewards. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn current_rewards( + &self, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request( + Method::GET, + format!("{}rewards/markets/current{cursor}", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Retrieves detailed reward data for a specific market. + /// + /// Returns the reward configuration and earning details for orders in the + /// specified market condition. Useful for tracking rewards on a per-market basis. + /// + /// # Errors + /// + /// Returns an error if the condition ID is invalid or the request fails. + pub async fn raw_rewards_for_market( + &self, + condition_id: &str, + next_cursor: Option, + ) -> Result> { + let cursor = next_cursor.map_or(String::new(), |c| format!("?next_cursor={c}")); + let request = self + .client() + .request( + Method::GET, + format!("{}rewards/markets/{condition_id}{cursor}", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Creates a new Builder API key for order attribution. + /// + /// Builder API keys allow you to attribute orders to your builder account, + /// enabling tracking of order flow and potential builder rewards. This is + /// separate from regular API keys used for trading. + /// + /// # Errors + /// + /// Returns an error if the request fails or the account is not eligible for builder keys. + pub async fn create_builder_api_key(&self) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}auth/builder-api-key", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + /// Posts a heartbeat to maintain order liveness. + /// + /// Heartbeats signal that your trading application is actively monitoring markets. + /// Regular heartbeats (every 5-10 seconds) ensure your orders maintain priority + /// in the orderbook. If heartbeats stop, orders may lose priority or be cancelled. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn post_heartbeat(&self, heartbeat_id: Option) -> Result { + let request = self + .client() + .request(Method::POST, format!("{}v1/heartbeats", self.host())) + .json(&json!({ "heartbeat_id": heartbeat_id })) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + #[cfg(feature = "heartbeats")] + /// Checks if automatic heartbeats are currently active. + /// + /// Returns `true` if the heartbeat background task is running, `false` otherwise. + /// Requires the `heartbeats` feature to be enabled. + #[must_use] + pub fn heartbeats_active(&self) -> bool { + self.heartbeat_token.0.is_some() + } + + #[cfg(feature = "heartbeats")] + /// Starts automatic heartbeat posting in the background. + /// + /// Spawns a background task that automatically posts heartbeats at the configured + /// interval. This maintains order priority without manual intervention. The heartbeat + /// interval is configured in [`Config`]'s `heartbeat_interval`. + /// + /// # Errors + /// + /// Returns an error if heartbeats are already active. + /// + /// # Note + /// + /// Requires the `heartbeats` feature to be enabled. + pub fn start_heartbeats(client: &mut Client>) -> Result<()> { + if client.heartbeats_active() { + return Err(Error::validation("Unable to create another heartbeat task")); + } + + let token = CancellationToken::new(); + let duration = client.inner.config.heartbeat_interval; + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + + let token_clone = token.clone(); + let client_clone = client.clone(); + + tokio::task::spawn(async move { + let mut heartbeat_id: Option = None; + + let mut ticker = time::interval(duration); + ticker.tick().await; + + loop { + tokio::select! { + () = token_clone.cancelled() => { + #[cfg(feature = "tracing")] + debug!("Heartbeat cancellation requested, terminating..."); + break + }, + _ = ticker.tick() => { + match client_clone.post_heartbeat(heartbeat_id).await { + Ok(response) => { + #[cfg(feature = "tracing")] + debug!("Heartbeat successfully sent: {response:?}"); + heartbeat_id = Some(response.heartbeat_id); + }, + Err(e) => { + #[cfg(feature = "tracing")] + error!("Unable to post heartbeat: {e:?}"); + #[cfg(not(feature = "tracing"))] + let _ = &e; + } + } + } + } + } + + tx.send(()) + }); + + client.heartbeat_token = DroppingCancellationToken(Some((token, Arc::new(rx)))); + + Ok(()) + } + + #[cfg(feature = "heartbeats")] + /// Stops automatic heartbeat posting. + /// + /// Cancels the background heartbeat task and waits for it to terminate cleanly. + /// After stopping, you can restart heartbeats by calling [`Self::start_heartbeats`] again. + /// + /// # Errors + /// + /// Returns an error if the heartbeat task cannot be stopped cleanly. + /// + /// # Note + /// + /// Requires the `heartbeats` feature to be enabled. + pub async fn stop_heartbeats(&mut self) -> Result<()> { + self.heartbeat_token.cancel_and_wait().await + } + + async fn create_headers(&self, request: &Request) -> Result { + let timestamp = if self.inner.config.use_server_time { + self.server_time().await? + } else { + Utc::now().timestamp() + }; + + auth::l2::create_headers(self.state(), request, timestamp).await + } + + fn order_builder(&self) -> OrderBuilder { + OrderBuilder { + signer: self.address(), + signature_type: self.inner.signature_type, + funder: self.inner.funder, + salt_generator: self.inner.salt_generator, + token_id: None, + price: None, + size: None, + amount: None, + side: None, + nonce: None, + expiration: None, + taker: None, + order_type: None, + post_only: Some(false), + client: Client { + inner: Arc::clone(&self.inner), + #[cfg(feature = "heartbeats")] + heartbeat_token: self.heartbeat_token.clone(), + }, + _kind: PhantomData, + } + } +} + +impl Client> { + /// Convert this [`Client>`] to [`Client>`] using + /// the provided `config`. + /// + /// Note: If `heartbeats` feature flag is enabled, then this method _will_ cancel all + /// outstanding orders since it will disable the background heartbeats task and then + /// re-enable it. + #[cfg_attr( + not(feature = "heartbeats"), + expect( + clippy::unused_async, + unused_mut, + reason = "Nothing to await or modify when heartbeats are disabled" + ) + )] + pub async fn promote_to_builder( + mut self, + config: BuilderConfig, + ) -> Result>> { + #[cfg(feature = "heartbeats")] + self.heartbeat_token.cancel_and_wait().await?; + + let inner = Arc::into_inner(self.inner).ok_or(Synchronization)?; + + let state = Authenticated { + address: inner.state.address, + credentials: inner.state.credentials, + kind: Builder { + config, + client: inner.client.clone(), + }, + }; + + let new_inner = ClientInner { + config: inner.config, + state, + host: inner.host, + geoblock_host: inner.geoblock_host, + client: inner.client, + tick_sizes: inner.tick_sizes, + neg_risk: inner.neg_risk, + fee_rate_bps: inner.fee_rate_bps, + funder: inner.funder, + signature_type: inner.signature_type, + salt_generator: inner.salt_generator, + }; + + #[cfg_attr( + not(feature = "heartbeats"), + expect( + unused_mut, + reason = "Modifier only needed when heartbeats feature is enabled" + ) + )] + let mut client = Client { + inner: Arc::new(new_inner), + #[cfg(feature = "heartbeats")] + heartbeat_token: DroppingCancellationToken(None), + }; + + #[cfg(feature = "heartbeats")] + Client::>::start_heartbeats(&mut client)?; + + Ok(client) + } +} + +impl Client> { + pub async fn builder_api_keys(&self) -> Result> { + let request = self + .client() + .request(Method::GET, format!("{}auth/builder-api-key", self.host())) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } + + pub async fn revoke_builder_api_key(&self) -> Result<()> { + let mut request = self + .client() + .request( + Method::DELETE, + format!("{}auth/builder-api-key", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + *request.headers_mut() = headers; + + // We have to send the request separately from `self.request` because this endpoint does + // not return anything in the response body. Otherwise, we would get an EOF error from reqwest + self.client().execute(request).await?; + + Ok(()) + } + + pub async fn builder_trades( + &self, + request: &TradesRequest, + next_cursor: Option, + ) -> Result> { + let params = request.query_params(next_cursor.as_deref()); + + let request = self + .client() + .request( + Method::GET, + format!("{}builder/trades{params}", self.host()), + ) + .build()?; + let headers = self.create_headers(&request).await?; + + crate::request(&self.inner.client, request, Some(headers)).await + } +} + +#[cfg(feature = "rfq")] +impl Client> { + /// Creates an RFQ Request to buy or sell outcome tokens. + /// + /// This initiates the RFQ flow where market makers can provide quotes. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be parsed. + pub async fn create_request( + &self, + request: &CreateRfqRequestRequest, + ) -> Result { + let http_request = self + .client() + .request(Method::POST, format!("{}rfq/request", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + crate::request(&self.inner.client, http_request, Some(headers)).await + } + + /// Cancels an RFQ request. + /// + /// The request must be in the `STATE_ACCEPTING_QUOTES` state. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the request cannot be canceled. + pub async fn cancel_request(&self, request: &CancelRfqRequestRequest) -> Result<()> { + let http_request = self + .client() + .request(Method::DELETE, format!("{}rfq/request", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + self.rfq_request_text(http_request, headers).await + } + + /// Gets RFQ requests. + /// + /// Requesters can only view their own requests. + /// Quoters can only see their own quotes and requests that they quoted. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be parsed. + pub async fn requests( + &self, + request: &RfqRequestsRequest, + next_cursor: Option<&str>, + ) -> Result> { + let params = request.query_params(next_cursor); + let http_request = self + .client() + .request( + Method::GET, + format!("{}rfq/data/requests{params}", self.host()), + ) + .build()?; + let headers = self.create_headers(&http_request).await?; + + crate::request(&self.inner.client, http_request, Some(headers)).await + } + + /// Creates an RFQ Quote in response to a Request. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be parsed. + pub async fn create_quote( + &self, + request: &CreateRfqQuoteRequest, + ) -> Result { + let http_request = self + .client() + .request(Method::POST, format!("{}rfq/quote", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + crate::request(&self.inner.client, http_request, Some(headers)).await + } + + /// Cancels an RFQ quote. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the quote cannot be canceled. + pub async fn cancel_quote(&self, request: &CancelRfqQuoteRequest) -> Result<()> { + let http_request = self + .client() + .request(Method::DELETE, format!("{}rfq/quote", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + self.rfq_request_text(http_request, headers).await + } + + /// Gets RFQ quotes. + /// + /// Requesters can view quotes for their requests. + /// Quoters can view all quotes. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be parsed. + pub async fn quotes( + &self, + request: &RfqQuotesRequest, + next_cursor: Option<&str>, + ) -> Result> { + let params = request.query_params(next_cursor); + let http_request = self + .client() + .request( + Method::GET, + format!("{}rfq/data/quotes{params}", self.host()), + ) + .build()?; + let headers = self.create_headers(&http_request).await?; + + crate::request(&self.inner.client, http_request, Some(headers)).await + } + + /// Requester accepts an RFQ Quote. + /// + /// This creates an Order that the Requester must sign. The signed order + /// is submitted to the API to initiate the trade. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the quote cannot be accepted. + pub async fn accept_quote( + &self, + request: &AcceptRfqQuoteRequest, + ) -> Result { + let http_request = self + .client() + .request(Method::POST, format!("{}rfq/request/accept", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + self.rfq_request_text(http_request, headers).await?; + Ok(AcceptRfqQuoteResponse) + } + + /// Quoter approves an RFQ order during the last look window. + /// + /// This queues the order for onchain execution. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the order cannot be approved. + pub async fn approve_order( + &self, + request: &ApproveRfqOrderRequest, + ) -> Result { + let http_request = self + .client() + .request(Method::POST, format!("{}rfq/quote/approve", self.host())) + .json(request) + .build()?; + let headers = self.create_headers(&http_request).await?; + + crate::request(&self.inner.client, http_request, Some(headers)).await + } + + /// Helper method for RFQ endpoints that return plain text instead of JSON. + /// + /// This is used for cancel operations (`cancel_request`, `cancel_quote`) + /// and accept quote which return "OK" as plain text rather than a JSON response. + /// The standard `crate::request` helper expects JSON responses and would fail + /// to deserialize plain text. + async fn rfq_request_text(&self, mut request: Request, headers: HeaderMap) -> Result<()> { + let method = request.method().clone(); + let path = request.url().path().to_owned(); + + *request.headers_mut() = headers; + + let response = self.inner.client.execute(request).await?; + let status = response.status(); + + if !status.is_success() { + let message = response.text().await.unwrap_or_default(); + return Err(crate::error::Error::status(status, method, path, message)); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_default_should_succeed() { + _ = Client::default(); + } +} diff --git a/polymarket-client-sdk/src/clob/mod.rs b/polymarket-client-sdk/src/clob/mod.rs new file mode 100644 index 0000000..8cfe6f8 --- /dev/null +++ b/polymarket-client-sdk/src/clob/mod.rs @@ -0,0 +1,152 @@ +//! Polymarket CLOB (Central Limit Order Book) API client and types. +//! +//! **Feature flag:** None (this is the core module, always available) +//! +//! This module provides the primary client for interacting with the Polymarket CLOB API, +//! which handles all trading operations including order placement, cancellation, market +//! data queries, and account management. +//! +//! # Overview +//! +//! The CLOB API is the main trading interface for Polymarket. It supports both +//! authenticated and unauthenticated operations: +//! +//! - **Unauthenticated**: Market data, pricing, orderbooks, health checks +//! - **Authenticated**: Order placement/cancellation, balances, API keys, rewards +//! - **Builder Authentication**: Special endpoints for market makers and builders +//! +//! ## Public Endpoints (No Authentication Required) +//! +//! | Endpoint | Description | +//! |----------|-------------| +//! | `/` | Health check - returns "OK" | +//! | `/time` | Current server timestamp | +//! | `/midpoint` | Mid-market price for a token | +//! | `/midpoints` | Batch midpoint prices | +//! | `/price` | Best bid or ask price | +//! | `/prices` | Batch best prices | +//! | `/spread` | Bid-ask spread | +//! | `/spreads` | Batch spreads | +//! | `/last-trade-price` | Most recent trade price | +//! | `/last-trades-prices` | Batch last trade prices | +//! | `/prices-all` | All token prices | +//! | `/tick-size` | Minimum price increment (cached) | +//! | `/neg-risk` | `NegRisk` adapter flag (cached) | +//! | `/fee-rate-bps` | Trading fee in basis points (cached) | +//! | `/book` | Full orderbook depth | +//! | `/books` | Batch orderbooks | +//! | `/market` | Single market details | +//! | `/markets` | All markets (paginated) | +//! | `/sampling-markets` | Sampling program markets | +//! | `/simplified-markets` | Markets with reduced detail | +//! | `/sampling-simplified-markets` | Simplified sampling markets | +//! | `/data/price-history` | Historical price data | +//! | `/geoblock` | Geographic restriction check | +//! +//! ## Authenticated Endpoints +//! +//! | Endpoint | Description | +//! |----------|-------------| +//! | `/order` | Place a new order | +//! | `/cancel` | Cancel an order | +//! | `/cancel-market-orders` | Cancel all orders in a market | +//! | `/cancel-all` | Cancel all orders | +//! | `/orders` | Get user's orders | +//! | `/trades` | Get user's trade history | +//! | `/balances` | Get USDC balances and allowances | +//! | `/api-keys` | List API keys | +//! | `/create-api-key` | Create new API key | +//! | `/delete-api-key` | Delete an API key | +//! | `/notifications` | Get notifications | +//! | `/mark-notifications-as-read` | Mark notifications read | +//! | `/drop-notifications` | Delete notifications | +//! | `/rewards/current` | Current rewards info | +//! | `/rewards/percentages` | Rewards percentages | +//! | `/order-scoring` | Order score for rewards | +//! | `/ban` | Check ban status | +//! +//! # Examples +//! +//! ## Unauthenticated Client +//! +//! ```rust,no_run +//! use std::str::FromStr as _; +//! +//! use polymarket_client_sdk::clob::{Client, Config}; +//! use polymarket_client_sdk::clob::types::request::MidpointRequest; +//! use polymarket_client_sdk::types::U256; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create an unauthenticated client +//! let client = Client::new("https://clob.polymarket.com", Config::default())?; +//! +//! // Check API health +//! let status = client.ok().await?; +//! println!("Status: {status}"); +//! +//! // Get midpoint price for a token +//! let request = MidpointRequest::builder() +//! .token_id(U256::from_str("15871154585880608648532107628464183779895785213830018178010423617714102767076")?) +//! .build(); +//! let midpoint = client.midpoint(&request).await?; +//! println!("Midpoint: {}", midpoint.mid); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Authenticated Client +//! +//! ```rust,no_run +//! use std::str::FromStr as _; +//! +//! use alloy::signers::Signer; +//! use alloy::signers::local::LocalSigner; +//! use polymarket_client_sdk::{POLYGON, PRIVATE_KEY_VAR}; +//! use polymarket_client_sdk::clob::{Client, Config}; +//! use polymarket_client_sdk::clob::types::{Side, SignedOrder}; +//! use polymarket_client_sdk::types::{dec, Decimal, U256}; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create signer from private key +//! let private_key = std::env::var(PRIVATE_KEY_VAR)?; +//! let signer = LocalSigner::from_str(&private_key)?.with_chain_id(Some(POLYGON)); +//! +//! let client = Client::new("https://clob.polymarket.com", Config::default())? +//! .authentication_builder(&signer) +//! .authenticate() +//! .await?; +//! +//! let order = client +//! .limit_order() +//! .token_id(U256::from_str("15871154585880608648532107628464183779895785213830018178010423617714102767076")?) +//! .side(Side::Buy) +//! .price(dec!(0.5)) +//! .size(Decimal::TEN) +//! .build() +//! .await?; +//! +//! let signed_order = client.sign(&signer, order).await?; +//! let response = client.post_order(signed_order).await?; +//! println!("Order ID: {}", response.order_id); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Optional Features +//! +//! - **`ws`**: Enables WebSocket support for real-time orderbook and trade streams +//! - **`heartbeats`**: Enables automatic heartbeat mechanism for authenticated sessions +//! - **`tracing`**: Enables detailed request/response tracing +//! - **`rfq`**: Enables RFQ (Request for Quote) endpoints for institutional trading +//! +//! # API Base URL +//! +//! The default API endpoint is `https://clob.polymarket.com`. + +pub mod client; +pub mod order_builder; +pub mod types; +#[cfg(feature = "ws")] +pub mod ws; + +pub use client::{Client, Config}; diff --git a/polymarket-client-sdk/src/clob/order_builder.rs b/polymarket-client-sdk/src/clob/order_builder.rs new file mode 100644 index 0000000..93023cc --- /dev/null +++ b/polymarket-client-sdk/src/clob/order_builder.rs @@ -0,0 +1,538 @@ +use std::marker::PhantomData; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy::primitives::U256; +use chrono::{DateTime, Utc}; +use rand::Rng as _; +use rust_decimal::prelude::ToPrimitive as _; + +use crate::Result; +use crate::auth::Kind as AuthKind; +use crate::auth::state::Authenticated; +use crate::clob::Client; +use crate::clob::types::request::OrderBookSummaryRequest; +use crate::clob::types::{ + Amount, AmountInner, Order, OrderType, Side, SignableOrder, SignatureType, +}; +use crate::error::Error; +use crate::types::{Address, Decimal}; + +pub(crate) const USDC_DECIMALS: u32 = 6; + +/// Maximum number of decimal places for `size` +pub(crate) const LOT_SIZE_SCALE: u32 = 2; + +/// Placeholder type for compile-time checks on limit order builders +#[non_exhaustive] +#[derive(Debug)] +pub struct Limit; + +/// Placeholder type for compile-time checks on market order builders +#[non_exhaustive] +#[derive(Debug)] +pub struct Market; + +/// Used to create an order iteratively and ensure validity with respect to its order kind. +#[derive(Debug)] +pub struct OrderBuilder { + pub(crate) client: Client>, + pub(crate) signer: Address, + pub(crate) signature_type: SignatureType, + pub(crate) salt_generator: fn() -> u64, + pub(crate) token_id: Option, + pub(crate) price: Option, + pub(crate) size: Option, + pub(crate) amount: Option, + pub(crate) side: Option, + pub(crate) nonce: Option, + pub(crate) expiration: Option>, + pub(crate) taker: Option
, + pub(crate) order_type: Option, + pub(crate) post_only: Option, + pub(crate) funder: Option
, + pub(crate) _kind: PhantomData, +} + +impl OrderBuilder { + /// Sets the `token_id` for this builder. This is a required field. + #[must_use] + pub fn token_id(mut self, token_id: U256) -> Self { + self.token_id = Some(token_id); + self + } + + /// Sets the [`Side`] for this builder. This is a required field. + #[must_use] + pub fn side(mut self, side: Side) -> Self { + self.side = Some(side); + self + } + + /// Sets the nonce for this builder. + #[must_use] + pub fn nonce(mut self, nonce: u64) -> Self { + self.nonce = Some(nonce); + self + } + + #[must_use] + pub fn expiration(mut self, expiration: DateTime) -> Self { + self.expiration = Some(expiration); + self + } + + #[must_use] + pub fn taker(mut self, taker: Address) -> Self { + self.taker = Some(taker); + self + } + + #[must_use] + pub fn order_type(mut self, order_type: OrderType) -> Self { + self.order_type = Some(order_type); + self + } + + /// Sets the `postOnly` flag for this builder. + #[must_use] + pub fn post_only(mut self, post_only: bool) -> Self { + self.post_only = Some(post_only); + self + } +} + +impl OrderBuilder { + /// Sets the price for this limit builder. This is a required field. + #[must_use] + pub fn price(mut self, price: Decimal) -> Self { + self.price = Some(price); + self + } + + /// Sets the size for this limit builder. This is a required field. + #[must_use] + pub fn size(mut self, size: Decimal) -> Self { + self.size = Some(size); + self + } + + /// Validates and transforms this limit builder into a [`SignableOrder`] + #[cfg_attr( + feature = "tracing", + tracing::instrument(skip(self), err(level = "warn")) + )] + pub async fn build(self) -> Result { + let Some(token_id) = self.token_id else { + return Err(Error::validation( + "Unable to build Order due to missing token ID", + )); + }; + + let Some(side) = self.side else { + return Err(Error::validation( + "Unable to build Order due to missing token side", + )); + }; + + let Some(price) = self.price else { + return Err(Error::validation( + "Unable to build Order due to missing price", + )); + }; + + if price.is_sign_negative() { + return Err(Error::validation(format!( + "Unable to build Order due to negative price {price}" + ))); + } + + let fee_rate = self.client.fee_rate_bps(token_id).await?; + let minimum_tick_size = self + .client + .tick_size(token_id) + .await? + .minimum_tick_size + .as_decimal(); + + let decimals = minimum_tick_size.scale(); + + if price.scale() > minimum_tick_size.scale() { + return Err(Error::validation(format!( + "Unable to build Order: Price {price} has {} decimal places. Minimum tick size \ + {minimum_tick_size} has {} decimal places. Price decimal places <= minimum tick size decimal places", + price.scale(), + minimum_tick_size.scale() + ))); + } + + if price < minimum_tick_size || price > Decimal::ONE - minimum_tick_size { + return Err(Error::validation(format!( + "Price {price} is too small or too large for the minimum tick size {minimum_tick_size}" + ))); + } + + let Some(size) = self.size else { + return Err(Error::validation( + "Unable to build Order due to missing size", + )); + }; + + if size.scale() > LOT_SIZE_SCALE { + return Err(Error::validation(format!( + "Unable to build Order: Size {size} has {} decimal places. Maximum lot size is {LOT_SIZE_SCALE}", + size.scale() + ))); + } + + if size.is_zero() || size.is_sign_negative() { + return Err(Error::validation(format!( + "Unable to build Order due to negative size {size}" + ))); + } + + let nonce = self.nonce.unwrap_or(0); + let expiration = self.expiration.unwrap_or(DateTime::::UNIX_EPOCH); + let taker = self.taker.unwrap_or(Address::ZERO); + let order_type = self.order_type.unwrap_or(OrderType::GTC); + let post_only = Some(self.post_only.unwrap_or(false)); + + if !matches!(order_type, OrderType::GTD) && expiration > DateTime::::UNIX_EPOCH { + return Err(Error::validation( + "Only GTD orders may have a non-zero expiration", + )); + } + + if post_only == Some(true) && !matches!(order_type, OrderType::GTC | OrderType::GTD) { + return Err(Error::validation( + "postOnly is only supported for GTC and GTD orders", + )); + } + + // When buying `YES` tokens, the user will "make" `size` * `price` USDC and "take" + // `size` `YES` tokens, and vice versa for sells. We have to truncate the notional values + // to the combined precision of the tick size _and_ the lot size. This is to ensure that + // this order will "snap" to the precision of resting orders on the book. The returned + // values are quantized to `USDC_DECIMALS`. + // + // e.g. User submits a limit order to buy 100 `YES` tokens at $0.34. + // This means they will take/receive 100 `YES` tokens, make/give up 34 USDC. This means that + // the `taker_amount` is `100000000` and the `maker_amount` of `34000000`. + let (taker_amount, maker_amount) = match side { + Side::Buy => ( + size, + (size * price).trunc_with_scale(decimals + LOT_SIZE_SCALE), + ), + Side::Sell => ( + (size * price).trunc_with_scale(decimals + LOT_SIZE_SCALE), + size, + ), + side => return Err(Error::validation(format!("Invalid side: {side}"))), + }; + + let salt = to_ieee_754_int((self.salt_generator)()); + + let order = Order { + salt: U256::from(salt), + maker: self.funder.unwrap_or(self.signer), + taker, + tokenId: token_id, + makerAmount: U256::from(to_fixed_u128(maker_amount)), + takerAmount: U256::from(to_fixed_u128(taker_amount)), + side: side as u8, + feeRateBps: U256::from(fee_rate.base_fee), + nonce: U256::from(nonce), + signer: self.signer, + expiration: U256::from(expiration.timestamp().to_u64().ok_or(Error::validation( + format!("Unable to represent expiration {expiration} as a u64"), + ))?), + signatureType: self.signature_type as u8, + }; + + #[cfg(feature = "tracing")] + tracing::debug!(token_id = %token_id, side = ?side, price = %price, size = %size, "limit order built"); + + Ok(SignableOrder { + order, + order_type, + post_only, + }) + } +} + +impl OrderBuilder { + /// Sets the price for this market builder. This is an optional field. + #[must_use] + pub fn price(mut self, price: Decimal) -> Self { + self.price = Some(price); + self + } + + /// Sets the [`Amount`] for this market order. This is a required field. + #[must_use] + pub fn amount(mut self, amount: Amount) -> Self { + self.amount = Some(amount); + self + } + + // Attempts to calculate the market price from the top of the book for the particular token. + // - Uses an orderbook depth search to find the cutoff price: + // - BUY + USDC: walk asks until notional >= USDC + // - BUY + Shares: walk asks until shares >= N + // - SELL + Shares: walk bids until shares >= N + async fn calculate_price(&self, order_type: OrderType) -> Result { + let token_id = self + .token_id + .expect("Token ID was already validated in `build`"); + let side = self.side.expect("Side was already validated in `build`"); + let amount = self + .amount + .as_ref() + .expect("Amount was already validated in `build`"); + + let book = self + .client + .order_book(&OrderBookSummaryRequest { + token_id, + side: None, + }) + .await?; + + if !matches!(order_type, OrderType::FAK | OrderType::FOK) { + return Err(Error::validation( + "Cannot set an order type other than FAK/FOK for a market order", + )); + } + + let (levels, amount) = match side { + Side::Buy => (book.asks, amount.0), + Side::Sell => match amount.0 { + a @ AmountInner::Shares(_) => (book.bids, a), + AmountInner::Usdc(_) => { + return Err(Error::validation( + "Sell Orders must specify their `amount`s in shares", + )); + } + }, + + side => return Err(Error::validation(format!("Invalid side: {side}"))), + }; + + let first = levels.first().ok_or(Error::validation(format!( + "No opposing orders for {token_id} which means there is no market price" + )))?; + + let mut sum = Decimal::ZERO; + let cutoff_price = levels.iter().rev().find_map(|level| { + match amount { + AmountInner::Usdc(_) => sum += level.size * level.price, + AmountInner::Shares(_) => sum += level.size, + } + (sum >= amount.as_inner()).then_some(level.price) + }); + + match cutoff_price { + Some(price) => Ok(price), + None if matches!(order_type, OrderType::FOK) => Err(Error::validation(format!( + "Insufficient liquidity to fill order for {token_id} at {}", + amount.as_inner() + ))), + None => Ok(first.price), + } + } + + /// Validates and transforms this market builder into a [`SignableOrder`] + #[cfg_attr( + feature = "tracing", + tracing::instrument(skip(self), err(level = "warn")) + )] + pub async fn build(self) -> Result { + let Some(token_id) = self.token_id else { + return Err(Error::validation( + "Unable to build Order due to missing token ID", + )); + }; + + let Some(side) = self.side else { + return Err(Error::validation( + "Unable to build Order due to missing token side", + )); + }; + + let amount = self + .amount + .ok_or_else(|| Error::validation("Unable to build Order due to missing amount"))?; + + let nonce = self.nonce.unwrap_or(0); + let taker = self.taker.unwrap_or(Address::ZERO); + + let order_type = self.order_type.clone().unwrap_or(OrderType::FAK); + let post_only = self.post_only; + if post_only == Some(true) { + return Err(Error::validation( + "postOnly is only supported for limit orders", + )); + } + let price = match self.price { + Some(price) => price, + None => self.calculate_price(order_type.clone()).await?, + }; + + let minimum_tick_size = self + .client + .tick_size(token_id) + .await? + .minimum_tick_size + .as_decimal(); + let fee_rate = self.client.fee_rate_bps(token_id).await?; + + let decimals = minimum_tick_size.scale(); + + // Ensure that the market price returned internally is truncated to our tick size + let price = price.trunc_with_scale(decimals); + if price < minimum_tick_size || price > Decimal::ONE - minimum_tick_size { + return Err(Error::validation(format!( + "Price {price} is too small or too large for the minimum tick size {minimum_tick_size}" + ))); + } + + // When buying `YES` tokens, the user will "make" `USDC` dollars and "take" + // `USDC` / `price` `YES` tokens. When selling `YES` tokens, the user will "make" `YES` + // token shares, and "take" `YES` shares * `price`. We have to truncate the notional values + // to the combined precision of the tick size _and_ the lot size. This is to ensure that + // this order will "snap" to the precision of resting orders on the book. The returned + // values are quantized to `USDC_DECIMALS`. + // + // e.g. User submits a market order to buy $100 worth of `YES` tokens at + // the current `market_price` of $0.34. This means they will take/receive (100/0.34) + // 294.1176(47) `YES` tokens, make/give up $100. This means that the `taker_amount` is + // `294117600` and the `maker_amount` of `100000000`. + // + // e.g. User submits a market order to sell 100 `YES` tokens at the current + // `market_price` of $0.34. This means that they will take/receive $34, make/give up 100 + // `YES` tokens. This means that the `taker_amount` is `34000000` and the `maker_amount` is + // `100000000`. + let raw_amount = amount.as_inner(); + + let (taker_amount, maker_amount) = match (side, amount.0) { + // Spend USDC to buy shares + (Side::Buy, AmountInner::Usdc(_)) => { + let shares = (raw_amount / price).trunc_with_scale(decimals + LOT_SIZE_SCALE); + (shares, raw_amount) + } + + // Buy N shares: use cutoff `price` derived from ask depth + (Side::Buy, AmountInner::Shares(_)) => { + let usdc = (raw_amount * price).trunc_with_scale(decimals + LOT_SIZE_SCALE); + (raw_amount, usdc) + } + + // Sell N shares for USDC + (Side::Sell, AmountInner::Shares(_)) => { + let usdc = (raw_amount * price).trunc_with_scale(decimals + LOT_SIZE_SCALE); + (usdc, raw_amount) + } + + (Side::Sell, AmountInner::Usdc(_)) => { + return Err(Error::validation( + "Sell Orders must specify their `amount`s in shares", + )); + } + + (side, _) => return Err(Error::validation(format!("Invalid side: {side}"))), + }; + + let salt = to_ieee_754_int((self.salt_generator)()); + + let order = Order { + salt: U256::from(salt), + maker: self.funder.unwrap_or(self.signer), + taker, + tokenId: token_id, + makerAmount: U256::from(to_fixed_u128(maker_amount)), + takerAmount: U256::from(to_fixed_u128(taker_amount)), + side: side as u8, + feeRateBps: U256::from(fee_rate.base_fee), + nonce: U256::from(nonce), + signer: self.signer, + expiration: U256::ZERO, + signatureType: self.signature_type as u8, + }; + + #[cfg(feature = "tracing")] + tracing::debug!(token_id = %token_id, side = ?side, price = %price, amount = %amount.as_inner(), "market order built"); + + Ok(SignableOrder { + order, + order_type, + post_only: None, + }) + } +} + +/// Removes trailing zeros, truncates to [`USDC_DECIMALS`] decimal places, and quanitizes as an +/// integer. +fn to_fixed_u128(d: Decimal) -> u128 { + d.normalize() + .trunc_with_scale(USDC_DECIMALS) + .mantissa() + .to_u128() + .expect("The `build` call in `OrderBuilder` ensures that only positive values are being multiplied/divided") +} + +/// Mask the salt to be <= 2^53 - 1, as the backend parses as an IEEE 754. +fn to_ieee_754_int(salt: u64) -> u64 { + salt & ((1 << 53) - 1) +} + +#[must_use] +#[expect( + clippy::float_arithmetic, + reason = "We are not concerned with precision for the seed" +)] +#[expect( + clippy::cast_possible_truncation, + reason = "We are not concerned with truncation for a seed" +)] +#[expect(clippy::cast_sign_loss, reason = "We only need positive integers")] +pub(crate) fn generate_seed() -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards"); + + let seconds = now.as_secs_f64(); + let rand = rand::rng().random::(); + + (seconds * rand).round() as u64 +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn to_fixed_u128_should_succeed() { + assert_eq!(to_fixed_u128(dec!(123.456)), 123_456_000); + assert_eq!(to_fixed_u128(dec!(123.456789)), 123_456_789); + assert_eq!(to_fixed_u128(dec!(123.456789111111111)), 123_456_789); + assert_eq!(to_fixed_u128(dec!(3.456789111111111)), 3_456_789); + assert_eq!(to_fixed_u128(Decimal::ZERO), 0); + } + + #[test] + #[should_panic( + expected = "The `build` call in `OrderBuilder` ensures that only positive values are being multiplied/divided" + )] + fn to_fixed_u128_panics() { + to_fixed_u128(dec!(-123.456)); + } + + #[test] + fn order_salt_should_be_less_than_or_equal_to_2_to_the_53_minus_1() { + let raw_salt = u64::MAX; + let masked_salt = to_ieee_754_int(raw_salt); + + assert!(masked_salt < (1 << 53)); + } +} diff --git a/polymarket-client-sdk/src/clob/types/mod.rs b/polymarket-client-sdk/src/clob/types/mod.rs new file mode 100644 index 0000000..52f6a71 --- /dev/null +++ b/polymarket-client-sdk/src/clob/types/mod.rs @@ -0,0 +1,728 @@ +use std::fmt; + +use alloy::core::sol; +use alloy::primitives::{Signature, U256}; +use bon::Builder; +use rust_decimal_macros::dec; +use serde::ser::{Error as _, SerializeStruct as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde_repr::Serialize_repr; +use serde_with::{DisplayFromStr, serde_as}; +use strum_macros::Display; + +use crate::Result; +use crate::auth::ApiKey; +use crate::clob::order_builder::{LOT_SIZE_SCALE, USDC_DECIMALS}; +use crate::error::Error; +use crate::types::Decimal; + +pub mod request; +pub mod response; + +// Re-export RFQ types for convenient access +#[cfg(feature = "rfq")] +pub use request::{ + AcceptRfqQuoteRequest, ApproveRfqOrderRequest, CancelRfqQuoteRequest, CancelRfqRequestRequest, + CreateRfqQuoteRequest, CreateRfqRequestRequest, RfqQuotesRequest, RfqRequestsRequest, +}; +#[cfg(feature = "rfq")] +pub use response::{ + AcceptRfqQuoteResponse, ApproveRfqOrderResponse, CreateRfqQuoteResponse, + CreateRfqRequestResponse, RfqQuote, RfqRequest, +}; + +#[non_exhaustive] +#[derive( + Clone, Debug, Display, Default, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +pub enum OrderType { + /// Good 'til Cancelled; If not fully filled, the order rests on the book until it is explicitly + /// cancelled. + #[serde(alias = "gtc")] + GTC, + /// Fill or Kill; Order is attempted to be filled, in full, immediately. If it cannot be fully + /// filled, the entire order is cancelled. + #[default] + #[serde(alias = "fok")] + FOK, + /// Good 'til Date; If not fully filled, the order rests on the book until the specified date. + #[serde(alias = "gtd")] + GTD, + /// Fill and Kill; Order is attempted to be filled, however much is possible, immediately. If + /// the order cannot be fully filled, the remaining quantity is cancelled. + #[serde(alias = "fak")] + FAK, + /// Unknown order type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +#[non_exhaustive] +#[derive( + Clone, Copy, Debug, Display, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[repr(u8)] +pub enum Side { + #[serde(alias = "buy")] + Buy = 0, + #[serde(alias = "sell")] + Sell = 1, + #[serde(other)] + Unknown = 255, +} + +impl TryFrom for Side { + type Error = Error; + + fn try_from(value: u8) -> std::result::Result { + match value { + 0 => Ok(Side::Buy), + 1 => Ok(Side::Sell), + other => Err(Error::validation(format!( + "Unable to create Side from {other}" + ))), + } + } +} + +/// Time interval for price history queries. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Display, Eq, PartialEq, Serialize, Deserialize)] +pub enum Interval { + /// 1 minute + #[serde(rename = "1m")] + #[strum(serialize = "1m")] + OneMinute, + /// 1 hour + #[serde(rename = "1h")] + #[strum(serialize = "1h")] + OneHour, + /// 6 hours + #[serde(rename = "6h")] + #[strum(serialize = "6h")] + SixHours, + /// 1 day + #[serde(rename = "1d")] + #[strum(serialize = "1d")] + OneDay, + /// 1 week + #[serde(rename = "1w")] + #[strum(serialize = "1w")] + OneWeek, + /// Maximum available history + #[serde(rename = "max")] + #[strum(serialize = "max")] + Max, +} + +/// Time range specification for price history queries. +/// +/// The CLOB API requires either an interval or explicit start/end timestamps. +/// This enum enforces that requirement at compile time. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(untagged)] +pub enum TimeRange { + /// Use a predefined interval (e.g., last day, last week). + Interval { + /// The time interval. + interval: Interval, + }, + /// Use explicit start and end timestamps. + #[serde(rename_all = "camelCase")] + Range { + /// Start timestamp (Unix seconds). + start_ts: i64, + /// End timestamp (Unix seconds). + end_ts: i64, + }, +} + +impl TimeRange { + /// Create a time range from a predefined interval. + #[must_use] + pub const fn from_interval(interval: Interval) -> Self { + Self::Interval { interval } + } + + /// Create a time range from explicit timestamps. + #[must_use] + pub const fn from_range(start_ts: i64, end_ts: i64) -> Self { + Self::Range { start_ts, end_ts } + } +} + +impl From for TimeRange { + fn from(interval: Interval) -> Self { + Self::from_interval(interval) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum AmountInner { + Usdc(Decimal), + Shares(Decimal), +} + +impl AmountInner { + pub fn as_inner(&self) -> Decimal { + match self { + AmountInner::Usdc(d) | AmountInner::Shares(d) => *d, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Amount(pub(crate) AmountInner); + +impl Amount { + pub fn usdc(value: Decimal) -> Result { + let normalized = value.normalize(); + if normalized.scale() > USDC_DECIMALS { + return Err(Error::validation(format!( + "Unable to build Amount with {} decimal points, must be <= {USDC_DECIMALS}", + normalized.scale() + ))); + } + + Ok(Amount(AmountInner::Usdc(normalized))) + } + + pub fn shares(value: Decimal) -> Result { + let normalized = value.normalize(); + if normalized.scale() > LOT_SIZE_SCALE { + return Err(Error::validation(format!( + "Unable to build Amount with {} decimal points, must be <= {LOT_SIZE_SCALE}", + normalized.scale() + ))); + } + + Ok(Amount(AmountInner::Shares(normalized))) + } + + #[must_use] + pub fn as_inner(&self) -> Decimal { + self.0.as_inner() + } + + #[must_use] + pub fn is_usdc(&self) -> bool { + matches!(self.0, AmountInner::Usdc(_)) + } + + #[must_use] + pub fn is_shares(&self) -> bool { + matches!(self.0, AmountInner::Shares(_)) + } +} + +#[non_exhaustive] +#[derive( + Clone, + Copy, + Display, + Debug, + Default, + Eq, + Ord, + PartialEq, + PartialOrd, + Serialize_repr, + Deserialize, +)] +#[repr(u8)] +pub enum SignatureType { + #[default] + Eoa = 0, + Proxy = 1, + GnosisSafe = 2, +} + +/// RFQ state filter for queries. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RfqState { + /// Active requests/quotes + #[default] + Active, + /// Inactive requests/quotes + Inactive, +} + +/// Sort field for RFQ queries. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RfqSortBy { + /// Sort by price + Price, + /// Sort by expiry + Expiry, + /// Sort by size + Size, + /// Sort by creation time (default) + #[default] + Created, +} + +/// Sort direction for RFQ queries. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RfqSortDir { + /// Ascending order (default) + #[default] + Asc, + /// Descending order + Desc, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Display, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum OrderStatusType { + #[serde(alias = "live")] + Live, + #[serde(alias = "matched")] + Matched, + #[serde(alias = "canceled")] + Canceled, + #[serde(alias = "delayed")] + Delayed, + #[serde(alias = "unmatched")] + Unmatched, + /// Unknown order status type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +#[non_exhaustive] +#[derive(Clone, Debug, Display, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum TradeStatusType { + #[serde(alias = "matched")] + Matched, + #[serde(alias = "mined")] + Mined, + #[serde(alias = "confirmed")] + Confirmed, + #[serde(alias = "retrying")] + Retrying, + #[serde(alias = "failed")] + Failed, + /// Unknown trade status type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +#[non_exhaustive] +#[derive( + Clone, Debug, Default, Display, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +pub enum AssetType { + #[default] + Collateral, + Conditional, + /// Unknown asset type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum TraderSide { + Taker, + Maker, + /// Unknown trader side from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +/// Represents the maximum number of decimal places for an order's price field +#[non_exhaustive] +#[derive(Debug, Clone, Copy)] +pub enum TickSize { + Tenth, + Hundredth, + Thousandth, + TenThousandth, +} + +impl fmt::Display for TickSize { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + TickSize::Tenth => "Tenth", + TickSize::Hundredth => "Hundredth", + TickSize::Thousandth => "Thousandth", + TickSize::TenThousandth => "TenThousandth", + }; + + write!(f, "{name}({})", self.as_decimal()) + } +} + +impl TickSize { + #[must_use] + pub fn as_decimal(&self) -> Decimal { + match self { + TickSize::Tenth => dec!(0.1), + TickSize::Hundredth => dec!(0.01), + TickSize::Thousandth => dec!(0.001), + TickSize::TenThousandth => dec!(0.0001), + } + } +} + +impl From for Decimal { + fn from(tick_size: TickSize) -> Self { + tick_size.as_decimal() + } +} + +impl TryFrom for TickSize { + type Error = Error; + + fn try_from(value: Decimal) -> std::result::Result { + match value { + v if v == dec!(0.1) => Ok(TickSize::Tenth), + v if v == dec!(0.01) => Ok(TickSize::Hundredth), + v if v == dec!(0.001) => Ok(TickSize::Thousandth), + v if v == dec!(0.0001) => Ok(TickSize::TenThousandth), + other => Err(Error::validation(format!( + "Unknown tick size: {other}. Expected one of: 0.1, 0.01, 0.001, 0.0001" + ))), + } + } +} + +impl PartialEq for TickSize { + fn eq(&self, other: &Self) -> bool { + self.as_decimal() == other.as_decimal() + } +} + +impl<'de> Deserialize<'de> for TickSize { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let dec = ::deserialize(deserializer)?; + TickSize::try_from(dec).map_err(de::Error::custom) + } +} + +sol! { + /// Alloy solidity type representing an order in the context of the Polymarket exchange + /// + /// + #[non_exhaustive] + #[serde_as] + #[derive(Serialize, Debug, Default, PartialEq)] + struct Order { + #[serde(serialize_with = "ser_salt")] + uint256 salt; + address maker; + address signer; + address taker; + #[serde_as(as = "DisplayFromStr")] + uint256 tokenId; + #[serde_as(as = "DisplayFromStr")] + uint256 makerAmount; + #[serde_as(as = "DisplayFromStr")] + uint256 takerAmount; + #[serde_as(as = "DisplayFromStr")] + uint256 expiration; + #[serde_as(as = "DisplayFromStr")] + uint256 nonce; + #[serde_as(as = "DisplayFromStr")] + uint256 feeRateBps; + uint8 side; + uint8 signatureType; + } +} + +// CLOB expects salt as a JSON number. U256 as an integer will not fit as a JSON number. Since +// we generated the salt as a u64 originally (see `salt_generator`), we can be very confident that +// we can invert the conversion to U256 and return a u64 when serializing. +fn ser_salt(value: &U256, serializer: S) -> std::result::Result { + let v: u64 = value + .try_into() + .map_err(|e| S::Error::custom(format!("salt does not fit into u64: {e}")))?; + serializer.serialize_u64(v) +} + +#[non_exhaustive] +#[derive(Clone, Debug, Default, Serialize, Builder, PartialEq)] +pub struct SignableOrder { + pub order: Order, + pub order_type: OrderType, + #[serde(rename = "postOnly", skip_serializing_if = "Option::is_none")] + pub post_only: Option, +} + +#[non_exhaustive] +#[derive(Debug, Builder, PartialEq)] +pub struct SignedOrder { + pub order: Order, + pub signature: Signature, + pub order_type: OrderType, + pub owner: ApiKey, + pub post_only: Option, +} + +/// Helper struct for serializing Order with signature injected. +/// This avoids the overhead of `serde_json::to_value()` followed by mutation. +#[serde_as] +#[derive(Serialize)] +struct OrderWithSignature<'order> { + #[serde(serialize_with = "ser_salt")] + salt: &'order U256, + maker: &'order alloy::primitives::Address, + signer: &'order alloy::primitives::Address, + taker: &'order alloy::primitives::Address, + #[serde_as(as = "DisplayFromStr")] + #[serde(rename = "tokenId")] + token_id: &'order U256, + #[serde_as(as = "DisplayFromStr")] + #[serde(rename = "makerAmount")] + maker_amount: &'order U256, + #[serde_as(as = "DisplayFromStr")] + #[serde(rename = "takerAmount")] + taker_amount: &'order U256, + #[serde_as(as = "DisplayFromStr")] + expiration: &'order U256, + #[serde_as(as = "DisplayFromStr")] + nonce: &'order U256, + #[serde_as(as = "DisplayFromStr")] + #[serde(rename = "feeRateBps")] + fee_rate_bps: &'order U256, + /// Side serialized as "BUY"/"SELL" string (CLOB API requirement) + side: Side, + #[serde(rename = "signatureType")] + signature_type: u8, + /// Signature injected into the order object + signature: String, +} + +// CLOB expects a struct that has the `signature` "folded" into the `order` key +impl Serialize for SignedOrder { + fn serialize(&self, serializer: S) -> std::result::Result { + let len = if self.post_only.is_some() { 4 } else { 3 }; + let mut st = serializer.serialize_struct("SignedOrder", len)?; + + // Convert numeric side to Side enum for string serialization + let side = Side::try_from(self.order.side).map_err(S::Error::custom)?; + + // Serialize order directly with signature injected, avoiding intermediate JSON tree + let order_with_sig = OrderWithSignature { + salt: &self.order.salt, + maker: &self.order.maker, + signer: &self.order.signer, + taker: &self.order.taker, + token_id: &self.order.tokenId, + maker_amount: &self.order.makerAmount, + taker_amount: &self.order.takerAmount, + expiration: &self.order.expiration, + nonce: &self.order.nonce, + fee_rate_bps: &self.order.feeRateBps, + side, + signature_type: self.order.signatureType, + signature: self.signature.to_string(), + }; + + st.serialize_field("order", &order_with_sig)?; + st.serialize_field("orderType", &self.order_type)?; + st.serialize_field("owner", &self.owner)?; + if let Some(post_only) = self.post_only { + st.serialize_field("postOnly", &post_only)?; + } + + st.end() + } +} + +#[cfg(test)] +mod tests { + use serde_json::to_value; + + use super::*; + use crate::error::Validation; + + #[test] + fn tick_size_decimals_should_succeed() { + assert_eq!(TickSize::Tenth.as_decimal().scale(), 1); + assert_eq!(TickSize::Hundredth.as_decimal().scale(), 2); + assert_eq!(TickSize::Thousandth.as_decimal().scale(), 3); + assert_eq!(TickSize::TenThousandth.as_decimal().scale(), 4); + } + + #[test] + fn tick_size_should_display() { + assert_eq!(format!("{}", TickSize::Tenth), "Tenth(0.1)"); + assert_eq!(format!("{}", TickSize::Hundredth), "Hundredth(0.01)"); + assert_eq!(format!("{}", TickSize::Thousandth), "Thousandth(0.001)"); + assert_eq!( + format!("{}", TickSize::TenThousandth), + "TenThousandth(0.0001)" + ); + } + + #[test] + fn tick_from_decimal_should_succeed() { + assert_eq!( + TickSize::try_from(dec!(0.0001)).unwrap(), + TickSize::TenThousandth + ); + assert_eq!( + TickSize::try_from(dec!(0.001)).unwrap(), + TickSize::Thousandth + ); + assert_eq!(TickSize::try_from(dec!(0.01)).unwrap(), TickSize::Hundredth); + assert_eq!(TickSize::try_from(dec!(0.1)).unwrap(), TickSize::Tenth); + } + + #[test] + fn non_standard_decimal_to_tick_size_should_fail() { + let result = TickSize::try_from(Decimal::ONE); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown tick size: 1") + ); + } + + #[test] + fn amount_should_succeed() -> Result<()> { + let usdc = Amount::usdc(Decimal::ONE_HUNDRED)?; + assert!(usdc.is_usdc()); + assert_eq!(usdc.as_inner(), Decimal::ONE_HUNDRED); + + let shares = Amount::shares(Decimal::ONE_HUNDRED)?; + assert!(shares.is_shares()); + assert_eq!(shares.as_inner(), Decimal::ONE_HUNDRED); + + Ok(()) + } + + #[test] + fn improper_shares_lot_size_should_fail() { + let Err(err) = Amount::shares(dec!(0.23400)) else { + panic!() + }; + + let message = err.downcast_ref::().unwrap(); + assert_eq!( + message.reason, + format!("Unable to build Amount with 3 decimal points, must be <= {LOT_SIZE_SCALE}") + ); + } + + #[test] + fn improper_usdc_decimal_size_should_fail() { + let Err(err) = Amount::usdc(dec!(0.2340011)) else { + panic!() + }; + + let message = err.downcast_ref::().unwrap(); + assert_eq!( + message.reason, + format!("Unable to build Amount with 7 decimal points, must be <= {USDC_DECIMALS}") + ); + } + + #[test] + fn side_to_string_should_succeed() { + assert_eq!(Side::Buy.to_string(), "BUY"); + assert_eq!(Side::Sell.to_string(), "SELL"); + } + + #[test] + fn order_type_deserialize_known_variants() { + // Test that known variants still deserialize correctly + assert_eq!( + serde_json::from_str::(r#""GTC""#).unwrap(), + OrderType::GTC + ); + assert_eq!( + serde_json::from_str::(r#""gtc""#).unwrap(), + OrderType::GTC + ); + assert_eq!( + serde_json::from_str::(r#""FOK""#).unwrap(), + OrderType::FOK + ); + } + + #[test] + fn order_type_deserialize_unknown_variant() { + // Test that unknown variants are captured + let result = serde_json::from_str::(r#""NEW_ORDER_TYPE""#).unwrap(); + assert_eq!(result, OrderType::Unknown("NEW_ORDER_TYPE".to_owned())); + } + + #[test] + fn order_status_type_deserialize_known_variants() { + assert_eq!( + serde_json::from_str::(r#""LIVE""#).unwrap(), + OrderStatusType::Live + ); + assert_eq!( + serde_json::from_str::(r#""live""#).unwrap(), + OrderStatusType::Live + ); + } + + #[test] + fn order_status_type_deserialize_unknown_variant() { + let result = serde_json::from_str::(r#""NEW_STATUS""#).unwrap(); + assert_eq!(result, OrderStatusType::Unknown("NEW_STATUS".to_owned())); + } + + #[test] + fn order_type_display_known_variants() { + assert_eq!(format!("{}", OrderType::GTC), "GTC"); + assert_eq!(format!("{}", OrderType::FOK), "FOK"); + } + + #[test] + fn order_type_display_unknown_variant() { + // strum Display will show the variant name + contents for tuple variants + let unknown = OrderType::Unknown("NEW_TYPE".to_owned()); + let display = format!("{unknown}"); + // Just verify it displays something reasonable (contains the inner value) + assert!(display.contains("Unknown") || display.contains("NEW_TYPE")); + } + + #[test] + fn signed_order_serialization_omits_post_only_when_none() { + let signed_order = SignedOrder { + order: Order::default(), + signature: Signature::new(U256::ZERO, U256::ZERO, false), + order_type: OrderType::GTC, + owner: ApiKey::nil(), + post_only: None, + }; + + let value = to_value(&signed_order).expect("serialize SignedOrder"); + let object = value + .as_object() + .expect("SignedOrder should serialize to an object"); + + assert!(!object.contains_key("postOnly")); + } +} diff --git a/polymarket-client-sdk/src/clob/types/request.rs b/polymarket-client-sdk/src/clob/types/request.rs new file mode 100644 index 0000000..7fc1a79 --- /dev/null +++ b/polymarket-client-sdk/src/clob/types/request.rs @@ -0,0 +1,506 @@ +#![allow( + clippy::module_name_repetitions, + reason = "Request suffix is intentional for clarity" +)] + +use bon::Builder; +use chrono::NaiveDate; +use serde::{Serialize, Serializer}; +use serde_with::{ + DisplayFromStr, StringWithSeparator, formats::CommaSeparator, serde_as, skip_serializing_none, +}; +#[cfg(feature = "rfq")] +use { + crate::clob::types::{RfqSortBy, RfqSortDir, RfqState}, + crate::{Timestamp, auth::ApiKey, types::Decimal}, +}; + +use crate::clob::types::{AssetType, Side, SignatureType, TimeRange}; +use crate::types::U256; +use crate::types::{Address, B256}; + +#[serde_as] +#[non_exhaustive] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct MidpointRequest { + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, +} + +#[serde_as] +#[non_exhaustive] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct PriceRequest { + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, + pub side: Side, +} + +#[non_exhaustive] +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct SpreadRequest { + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, + pub side: Option, +} + +#[non_exhaustive] +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct OrderBookSummaryRequest { + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, + pub side: Option, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct LastTradePriceRequest { + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, +} + +#[serde_as] +#[non_exhaustive] +#[skip_serializing_none] +#[derive(Debug, Serialize, Builder)] +#[builder(on(String, into))] +pub struct PriceHistoryRequest { + /// The CLOB token ID to fetch price history for. + #[serde_as(as = "DisplayFromStr")] + pub market: U256, + /// The time range for the price history query. + /// Either a predefined interval or explicit start/end timestamps. + #[serde(flatten)] + #[builder(into)] + pub time_range: TimeRange, + /// Optional fidelity (number of data points). + #[serde(skip_serializing_if = "Option::is_none")] + pub fidelity: Option, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Serialize, Builder)] +#[builder(on(String, into))] +pub struct CancelMarketOrderRequest { + /// The market condition ID to cancel orders for. + pub market: Option, + #[serde_as(as = "Option")] + pub asset_id: Option, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Clone, Builder, Serialize)] +#[builder(on(String, into))] +pub struct TradesRequest { + pub id: Option, + #[serde(rename = "taker")] + pub taker_address: Option
, + #[serde(rename = "maker")] + pub maker_address: Option
, + /// The market condition ID to filter trades. + pub market: Option, + #[serde_as(as = "Option")] + pub asset_id: Option, + pub before: Option, + pub after: Option, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Serialize, Builder)] +#[builder(on(String, into))] +pub struct OrdersRequest { + #[serde(rename = "id")] + pub order_id: Option, + /// The market condition ID to filter orders. + pub market: Option, + #[serde_as(as = "Option")] + pub asset_id: Option, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Serialize, Builder)] +pub struct DeleteNotificationsRequest { + #[serde(rename = "ids", skip_serializing_if = "Vec::is_empty")] + #[serde_as(as = "StringWithSeparator::")] + #[builder(default)] + pub notification_ids: Vec, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Clone, Builder, Serialize)] +#[builder(on(String, into))] +pub struct BalanceAllowanceRequest { + pub asset_type: AssetType, + #[serde_as(as = "Option")] + pub token_id: Option, + pub signature_type: Option, +} + +pub type UpdateBalanceAllowanceRequest = BalanceAllowanceRequest; + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +#[builder(on(String, into))] +pub struct UserRewardsEarningRequest { + pub date: NaiveDate, + #[builder(default)] + pub order_by: String, + #[builder(default)] + pub position: String, + #[builder(default)] + pub no_competition: bool, +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub enum Asset { + Usdc, + Asset(U256), +} + +impl Serialize for Asset { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Asset::Usdc => serializer.serialize_str("0"), + Asset::Asset(a) => serializer.collect_str(a), + } + } +} + +/// Request body for creating an RFQ request. +/// +/// Creates an RFQ Request to buy or sell outcome tokens. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +pub struct CreateRfqRequestRequest { + /// Token ID the Requester wants to receive. "0" indicates USDC. + pub asset_in: Asset, + /// Token ID the Requester wants to give. "0" indicates USDC. + pub asset_out: Asset, + /// Amount of asset to receive (in base units). + pub amount_in: Decimal, + /// Amount of asset to give (in base units). + pub amount_out: Decimal, + /// Signature type (`EOA`, `Proxy`, or `GnosisSafe`). + pub user_type: SignatureType, +} + +/// Request body for canceling an RFQ request. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct CancelRfqRequestRequest { + /// ID of the request to cancel. + pub request_id: String, +} + +/// Query parameters for getting RFQ requests. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Default, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct RfqRequestsRequest { + /// Cursor offset for pagination (base64 encoded). + pub offset: Option, + /// Max requests to return. Defaults to 50, max 1000. + pub limit: Option, + /// Filter by state (active or inactive). + pub state: Option, + /// Filter by request IDs. + #[serde(rename = "requestIds", skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub request_ids: Vec, + /// Filter by market condition IDs. + #[serde_as(as = "StringWithSeparator::")] + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub markets: Vec, + /// Minimum size in tokens. + pub size_min: Option, + /// Maximum size in tokens. + pub size_max: Option, + /// Minimum size in USDC. + pub size_usdc_min: Option, + /// Maximum size in USDC. + pub size_usdc_max: Option, + /// Minimum price. + pub price_min: Option, + /// Maximum price. + pub price_max: Option, + /// Sort field. + pub sort_by: Option, + /// Sort direction. + pub sort_dir: Option, +} + +/// Request body for creating an RFQ quote. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct CreateRfqQuoteRequest { + /// ID of the Request to quote. + pub request_id: String, + /// Token ID the Quoter wants to receive. "0" indicates USDC. + pub asset_in: Asset, + /// Token ID the Quoter wants to give. "0" indicates USDC. + pub asset_out: Asset, + /// Amount of asset to receive (in base units). + pub amount_in: Decimal, + /// Amount of asset to give (in base units). + pub amount_out: Decimal, + /// Signature type (`EOA`, `Proxy`, or `GnosisSafe`). + pub user_type: SignatureType, +} + +/// Request body for canceling an RFQ quote. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct CancelRfqQuoteRequest { + /// ID of the quote to cancel. + pub quote_id: String, +} + +/// Query parameters for getting RFQ quotes. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Default, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct RfqQuotesRequest { + /// Cursor offset for pagination (base64 encoded). + pub offset: Option, + /// Max quotes to return. Defaults to 50, max 1000. + pub limit: Option, + /// Filter by state (active or inactive). + pub state: Option, + /// Filter by quote IDs. + #[serde(rename = "quoteIds", skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub quote_ids: Vec, + /// Filter by request IDs. + #[serde(rename = "requestIds", skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub request_ids: Vec, + /// Filter by market condition IDs. + #[serde_as(as = "StringWithSeparator::")] + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub markets: Vec, + /// Minimum size in tokens. + pub size_min: Option, + /// Maximum size in tokens. + pub size_max: Option, + /// Minimum size in USDC. + pub size_usdc_min: Option, + /// Maximum size in USDC. + pub size_usdc_max: Option, + /// Minimum price. + pub price_min: Option, + /// Maximum price. + pub price_max: Option, + /// Sort field. + pub sort_by: Option, + /// Sort direction. + pub sort_dir: Option, +} + +/// Request body for accepting an RFQ quote. +/// +/// This creates an Order that the Requester must sign. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct AcceptRfqQuoteRequest { + /// ID of the Request. + pub request_id: String, + /// ID of the Quote being accepted. + pub quote_id: String, + /// Maker's amount in base units. + pub maker_amount: Decimal, + /// Taker's amount in base units. + pub taker_amount: Decimal, + /// Outcome token ID. + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, + /// Maker's address. + pub maker: Address, + /// Signer's address. + pub signer: Address, + /// Taker's address. + pub taker: Address, + /// Order nonce. + pub nonce: u64, + /// Unix timestamp for order expiration. + pub expiration: i64, + /// Order side (BUY or SELL). + pub side: Side, + /// Fee rate in basis points. + pub fee_rate_bps: u64, + /// EIP-712 signature. + pub signature: String, + /// Random salt for order uniqueness. + pub salt: String, + /// Owner identifier. + pub owner: ApiKey, +} + +/// Request body for approving an RFQ order. +/// +/// Quoter approves an RFQ order during the last look window. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Serialize, Builder)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct ApproveRfqOrderRequest { + /// ID of the Request. + pub request_id: String, + /// ID of the Quote being approved. + pub quote_id: String, + /// Maker's amount in base units. + pub maker_amount: Decimal, + /// Taker's amount in base units. + pub taker_amount: Decimal, + /// Outcome token ID. + #[serde_as(as = "DisplayFromStr")] + pub token_id: U256, + /// Maker's address. + pub maker: Address, + /// Signer's address. + pub signer: Address, + /// Taker's address. + pub taker: Address, + /// Order nonce. + pub nonce: u64, + /// Unix timestamp for order expiration. + pub expiration: Timestamp, + /// Order side (BUY or SELL). + pub side: Side, + /// Fee rate in basis points. + pub fee_rate_bps: u64, + /// EIP-712 signature. + pub signature: String, + /// Random salt for order uniqueness. + pub salt: String, + /// Owner identifier. + pub owner: ApiKey, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ToQueryParams as _; + use crate::types::b256; + + #[test] + fn trades_request_as_params_should_succeed() { + let market = b256!("0000000000000000000000000000000000000000000000000000000000010000"); + let request = TradesRequest::builder() + .market(market) + .asset_id(U256::from(100)) + .id("aa-bb") + .maker_address(Address::ZERO) + .build(); + + assert_eq!( + request.query_params(None), + "?id=aa-bb&maker=0x0000000000000000000000000000000000000000&market=0x0000000000000000000000000000000000000000000000000000000000010000&asset_id=100" + ); + assert_eq!( + request.query_params(Some("1")), + "?id=aa-bb&maker=0x0000000000000000000000000000000000000000&market=0x0000000000000000000000000000000000000000000000000000000000010000&asset_id=100&next_cursor=1" + ); + } + + #[test] + fn orders_request_as_params_should_succeed() { + let market = b256!("0000000000000000000000000000000000000000000000000000000000010000"); + let request = OrdersRequest::builder() + .market(market) + .asset_id(U256::from(100)) + .order_id("aa-bb") + .build(); + + assert_eq!( + request.query_params(None), + "?id=aa-bb&market=0x0000000000000000000000000000000000000000000000000000000000010000&asset_id=100" + ); + assert_eq!( + request.query_params(Some("1")), + "?id=aa-bb&market=0x0000000000000000000000000000000000000000000000000000000000010000&asset_id=100&next_cursor=1" + ); + } + + #[test] + fn delete_notifications_request_as_params_should_succeed() { + let empty_request = DeleteNotificationsRequest::builder().build(); + let request = DeleteNotificationsRequest::builder() + .notification_ids(vec!["1".to_owned(), "2".to_owned()]) + .build(); + + assert_eq!(empty_request.query_params(None), ""); + assert_eq!(request.query_params(None), "?ids=1%2C2"); + } + + #[test] + fn balance_allowance_request_as_params_should_succeed() { + let request = BalanceAllowanceRequest::builder() + .asset_type(AssetType::Collateral) + .token_id(U256::from(1)) + .signature_type(SignatureType::Eoa) + .build(); + + assert_eq!( + request.query_params(None), + "?asset_type=COLLATERAL&token_id=1&signature_type=0" + ); + } + + #[test] + fn user_rewards_earning_request_as_params_should_succeed() { + let request = UserRewardsEarningRequest::builder() + .date(NaiveDate::MIN) + .build(); + + assert_eq!( + request.query_params(Some("1")), + "?date=-262143-01-01&order_by=&position=&no_competition=false&next_cursor=1" + ); + } +} diff --git a/polymarket-client-sdk/src/clob/types/response.rs b/polymarket-client-sdk/src/clob/types/response.rs new file mode 100644 index 0000000..2dbd178 --- /dev/null +++ b/polymarket-client-sdk/src/clob/types/response.rs @@ -0,0 +1,810 @@ +#![allow( + clippy::module_name_repetitions, + reason = "Response suffix is intentional for clarity" +)] + +use std::collections::HashMap; + +use bon::Builder; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{ + DefaultOnError, DefaultOnNull, NoneAsEmptyString, TimestampMilliSeconds, TimestampSeconds, + TryFromInto, serde_as, +}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; + +use crate::Result; +use crate::auth::ApiKey; +use crate::clob::types::{OrderStatusType, OrderType, Side, TickSize, TradeStatusType, TraderSide}; +use crate::serde_helpers::StringFromAny; +use crate::types::{Address, B256, Decimal, U256}; + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct MidpointResponse { + pub mid: Decimal, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Default, Deserialize, Builder, PartialEq)] +#[serde(transparent)] +pub struct MidpointsResponse { + pub midpoints: HashMap, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct PriceResponse { + pub price: Decimal, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Default, Deserialize, Builder, PartialEq)] +#[serde(transparent)] +pub struct PricesResponse { + pub prices: Option>>, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct SpreadResponse { + pub spread: Decimal, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct SpreadsResponse { + pub spreads: Option>, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct PriceHistoryResponse { + pub history: Vec, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct PricePoint { + pub t: i64, + pub p: Decimal, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +#[builder(on(TickSize, into))] +pub struct TickSizeResponse { + pub minimum_tick_size: TickSize, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct NegRiskResponse { + pub neg_risk: bool, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct FeeRateResponse { + pub base_fee: u32, +} + +/// Response from the Polymarket geoblock endpoint. +/// +/// This indicates whether the requesting IP address is blocked from placing orders +/// due to geographic restrictions. +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct GeoblockResponse { + /// Whether the user is blocked from placing orders + pub blocked: bool, + /// The detected IP address + pub ip: String, + /// ISO 3166-1 alpha-2 country code + pub country: String, + /// Region/state code + pub region: String, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Clone, Debug, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct OrderBookSummaryResponse { + /// The market condition ID. + pub market: B256, + pub asset_id: U256, + #[serde_as(as = "TimestampMilliSeconds")] + pub timestamp: DateTime, + #[serde(default)] + pub hash: Option, + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub bids: Vec, + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub asks: Vec, + pub min_order_size: Decimal, + pub neg_risk: bool, + #[serde_as(as = "TryFromInto")] + pub tick_size: TickSize, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnError")] + pub last_trade_price: Option, +} + +impl OrderBookSummaryResponse { + pub fn hash(&self) -> Result { + let json = serde_json::to_string(&self)?; + + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + let result = hasher.finalize(); + + Ok(format!("{result:x}")) + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, Serialize, Deserialize, Hash, Builder, PartialEq)] +pub struct OrderSummary { + pub price: Decimal, + pub size: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Deserialize, Builder, PartialEq)] +pub struct LastTradePriceResponse { + pub price: Decimal, + pub side: Side, +} + +#[non_exhaustive] +#[derive(Debug, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct LastTradesPricesResponse { + pub token_id: U256, + pub price: Decimal, + pub side: Side, +} + +#[expect( + clippy::struct_excessive_bools, + reason = "The current API has these fields, so we have to capture this" +)] +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct MarketResponse { + pub enable_order_book: bool, + pub active: bool, + pub closed: bool, + pub archived: bool, + pub accepting_orders: bool, + pub accepting_order_timestamp: Option>, + pub minimum_order_size: Decimal, + pub minimum_tick_size: Decimal, + /// The market condition ID (unique market identifier). + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub condition_id: Option, + /// The CTF question ID. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub question_id: Option, + pub question: String, + pub description: String, + pub market_slug: String, + pub end_date_iso: Option>, + pub game_start_time: Option>, + pub seconds_delay: u64, + /// The FPMM (Fixed Product Market Maker) contract address. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub fpmm: Option
, + pub maker_base_fee: Decimal, + pub taker_base_fee: Decimal, + pub notifications_enabled: bool, + pub neg_risk: bool, + /// The negative risk market ID (empty string if not a neg risk market). + #[serde_as(as = "DefaultOnError")] + #[serde(default)] + pub neg_risk_market_id: Option, + /// The negative risk request ID (empty string if not a neg risk market). + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub neg_risk_request_id: Option, + pub icon: String, + pub image: String, + pub rewards: Rewards, + pub is_50_50_outcome: bool, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub tokens: Vec, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub tags: Vec, +} + +#[non_exhaustive] +#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct Token { + pub token_id: U256, + pub outcome: String, + pub price: Decimal, + #[serde(default)] + pub winner: bool, +} + +#[expect( + clippy::struct_excessive_bools, + reason = "The current API has these fields" +)] +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Serialize, Deserialize, Clone, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct SimplifiedMarketResponse { + /// The market condition ID (unique market identifier). + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub condition_id: Option, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub tokens: Vec, + pub rewards: Rewards, + pub active: bool, + pub closed: bool, + pub archived: bool, + pub accepting_orders: bool, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Default, Deserialize, Builder, PartialEq)] +pub struct ApiKeysResponse { + #[serde(rename = "apiKeys")] + keys: Option>, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +pub struct BanStatusResponse { + pub closed_only: bool, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct PostOrderResponse { + pub error_msg: Option, + #[serde(deserialize_with = "empty_string_as_zero")] + pub making_amount: Decimal, + #[serde(deserialize_with = "empty_string_as_zero")] + pub taking_amount: Decimal, + #[serde(rename = "orderID")] + pub order_id: String, + pub status: OrderStatusType, + pub success: bool, + /// On-chain transaction hashes for the order execution. + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + #[serde(alias = "transactionsHashes")] + pub transaction_hashes: Vec, + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub trade_ids: Vec, +} + +pub fn empty_string_as_zero<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + + if s.trim().is_empty() { + Ok(Decimal::ZERO) + } else { + s.parse::().map_err(serde::de::Error::custom) + } +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct OpenOrderResponse { + pub id: String, + pub status: OrderStatusType, + pub owner: ApiKey, + pub maker_address: Address, + /// The market condition ID. + pub market: B256, + pub asset_id: U256, + pub side: Side, + pub original_size: Decimal, + pub size_matched: Decimal, + pub price: Decimal, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub associate_trades: Vec, + pub outcome: String, + #[serde(with = "chrono::serde::ts_seconds")] + pub created_at: DateTime, + #[serde_as(as = "TimestampSeconds")] + pub expiration: DateTime, + pub order_type: OrderType, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CancelOrdersResponse { + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub canceled: Vec, + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + #[serde(alias = "not_canceled")] + pub not_canceled: HashMap, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct TradeResponse { + pub id: String, + pub taker_order_id: String, + /// The market condition ID. + pub market: B256, + pub asset_id: U256, + pub side: Side, + pub size: Decimal, + pub fee_rate_bps: Decimal, + pub price: Decimal, + pub status: TradeStatusType, + #[serde_as(as = "TimestampSeconds")] + pub match_time: DateTime, + #[serde_as(as = "TimestampSeconds")] + pub last_update: DateTime, + pub outcome: String, + pub bucket_index: u32, + pub owner: ApiKey, + pub maker_address: Address, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub maker_orders: Vec, + /// On-chain transaction hash. + pub transaction_hash: B256, + pub trader_side: TraderSide, + #[serde(default)] + pub error_msg: Option, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +pub struct NotificationResponse { + pub r#type: u32, + pub owner: ApiKey, + pub payload: NotificationPayload, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct NotificationPayload { + pub asset_id: U256, + /// The market condition ID (unique market identifier). + pub condition_id: B256, + #[serde(rename = "eventSlug")] + pub event_slug: String, + pub icon: String, + pub image: String, + /// The market condition ID (same as `condition_id`). + pub market: B256, + pub market_slug: String, + pub matched_size: Decimal, + pub order_id: String, + pub original_size: Decimal, + pub outcome: String, + pub outcome_index: u64, + pub owner: ApiKey, + pub price: Decimal, + pub question: String, + pub remaining_size: Decimal, + #[serde(rename = "seriesSlug")] + pub series_slug: String, + pub side: Side, + pub trade_id: String, + /// On-chain transaction hash. + pub transaction_hash: B256, + #[serde(alias = "type")] + pub order_type: OrderType, +} + +#[non_exhaustive] +#[allow( + clippy::allow_attributes, + clippy::allow_attributes_without_reason, + reason = "Bon will generate code that has an allow attribute for some reason on the `allowances` field" +)] +#[derive(Debug, Default, Clone, Deserialize, Builder, PartialEq)] +pub struct BalanceAllowanceResponse { + pub balance: Decimal, + #[serde(default)] + #[builder(default)] + pub allowances: HashMap, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +pub struct OrderScoringResponse { + pub scoring: bool, +} + +pub type OrdersScoringResponse = HashMap; + +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize, Builder, PartialEq)] +pub struct PriceSideResponse { + pub side: Side, + pub price: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)] +pub struct RewardRate { + pub asset_address: Address, + pub rewards_daily_rate: Decimal, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Default, Clone, Serialize, Deserialize, Builder, PartialEq)] +pub struct Rewards { + #[builder(default)] + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub rates: Vec, + pub min_size: Decimal, + pub max_spread: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct UserInfo { + pub address: Address, + pub username: String, + pub profile_picture: String, + pub optimized_profile_picture: String, + pub pseudonym: String, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct MakerOrder { + pub order_id: String, + pub owner: ApiKey, + pub maker_address: Address, + pub matched_amount: Decimal, + pub price: Decimal, + pub fee_rate_bps: Decimal, + pub asset_id: U256, + pub outcome: String, + pub side: Side, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct UserEarningResponse { + pub date: NaiveDate, + /// The market condition ID (unique market identifier). + pub condition_id: B256, + pub asset_address: Address, + pub maker_address: Address, + pub earnings: Decimal, + pub asset_rate: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct TotalUserEarningResponse { + pub date: NaiveDate, + pub asset_address: Address, + pub maker_address: Address, + pub earnings: Decimal, + pub asset_rate: Decimal, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct UserRewardsEarningResponse { + /// The market condition ID (unique market identifier). + pub condition_id: B256, + pub question: String, + pub market_slug: String, + pub event_slug: String, + pub image: String, + pub rewards_max_spread: Decimal, + pub rewards_min_size: Decimal, + pub market_competitiveness: Decimal, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub tokens: Vec, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub rewards_config: Vec, + pub maker_address: Address, + pub earning_percentage: Decimal, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub earnings: Vec, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +pub struct RewardsConfig { + pub asset_address: Address, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub rate_per_day: Decimal, + pub total_rewards: Decimal, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct MarketRewardsConfig { + #[serde_as(as = "StringFromAny")] + pub id: String, + pub asset_address: Address, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub rate_per_day: Decimal, + pub total_rewards: Decimal, + pub total_days: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, Builder, PartialEq)] +pub struct Earning { + pub asset_address: Address, + pub earnings: Decimal, + pub asset_rate: Decimal, +} + +pub type RewardsPercentagesResponse = HashMap; + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct CurrentRewardResponse { + /// The market condition ID (unique market identifier). + pub condition_id: B256, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub rewards_config: Vec, + pub rewards_max_spread: Decimal, + pub rewards_min_size: Decimal, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct MarketRewardResponse { + /// The market condition ID (unique market identifier). + pub condition_id: B256, + pub question: String, + pub market_slug: String, + pub event_slug: String, + pub image: String, + pub rewards_max_spread: Decimal, + pub rewards_min_size: Decimal, + pub market_competitiveness: Decimal, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub tokens: Vec, + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub rewards_config: Vec, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BuilderApiKeyResponse { + pub key: ApiKey, + #[serde(default)] + pub created_at: Option>, + #[serde(default)] + pub revoked_at: Option>, +} + +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct BuilderTradeResponse { + pub id: String, + pub trade_type: String, + /// Hash of the taker order. + pub taker_order_hash: B256, + /// Address of the builder. + pub builder: Address, + /// The market condition ID. + pub market: B256, + pub asset_id: U256, + pub side: Side, + pub size: Decimal, + pub size_usdc: Decimal, + pub price: Decimal, + pub status: TradeStatusType, + pub outcome: String, + pub outcome_index: u32, + pub owner: ApiKey, + /// Address of the maker. + pub maker: Address, + /// On-chain transaction hash. + pub transaction_hash: B256, + #[serde_as(as = "TimestampSeconds")] + pub match_time: DateTime, + pub bucket_index: u32, + pub fee: Decimal, + pub fee_usdc: Decimal, + #[serde(alias = "err_msg")] + pub err_msg: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct HeartbeatResponse { + pub heartbeat_id: Uuid, + pub error: Option, +} + +/// Generic wrapper structure that holds inner `data` with metadata designating how to query for the +/// next page. +#[non_exhaustive] +#[derive(Clone, Debug, Serialize, Deserialize, Builder, PartialEq)] +#[builder(on(String, into))] +pub struct Page { + pub data: Vec, + /// The continuation token to supply to the API to trigger for the next [`Page`]. + pub next_cursor: String, + /// The maximum length of `data`. + pub limit: u64, + /// The length of `data` + pub count: u64, +} + +/// Response from creating an RFQ request. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct CreateRfqRequestResponse { + /// Unique identifier for the created request. + pub request_id: String, + /// Unix timestamp when the request expires. + pub expiry: i64, +} + +/// Response from creating an RFQ quote. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct CreateRfqQuoteResponse { + /// Unique identifier for the created quote. + pub quote_id: String, +} + +/// Response from accepting an RFQ quote. +/// +/// Returns "OK" as text, represented as unit type for deserialization. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptRfqQuoteResponse; + +/// Response from approving an RFQ order. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct ApproveRfqOrderResponse { + /// Trade IDs for the executed order. + pub trade_ids: Vec, +} + +/// An RFQ request in the system. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct RfqRequest { + /// Unique request identifier. + pub request_id: String, + /// User's address. + pub user_address: Address, + /// Proxy address (may be same as user). + pub proxy_address: Address, + /// Market condition ID. + pub condition: B256, + /// Token ID for the outcome token. + pub token: U256, + /// Complement token ID. + pub complement: U256, + /// Order side (BUY or SELL). + pub side: Side, + /// Size of tokens to receive. + pub size_in: Decimal, + /// Size of tokens to give. + pub size_out: Decimal, + /// Price for the request. + pub price: Decimal, + /// Unix timestamp when the request expires. + pub expiry: i64, +} + +/// An RFQ quote in the system. +#[cfg(feature = "rfq")] +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder, PartialEq)] +#[serde(rename_all = "camelCase")] +#[builder(on(String, into))] +pub struct RfqQuote { + /// Unique quote identifier. + pub quote_id: String, + /// Request ID this quote is for. + pub request_id: String, + /// Quoter's address. + pub user_address: Address, + /// Proxy address (may be same as user). + pub proxy_address: Address, + /// Market condition ID. + pub condition: B256, + /// Token ID for the outcome token. + pub token: U256, + /// Complement token ID. + pub complement: U256, + /// Order side (BUY or SELL). + pub side: Side, + /// Size of tokens to receive. + pub size_in: Decimal, + /// Size of tokens to give. + pub size_out: Decimal, + /// Quoted price. + pub price: Decimal, +} diff --git a/polymarket-client-sdk/src/clob/ws/client.rs b/polymarket-client-sdk/src/clob/ws/client.rs new file mode 100644 index 0000000..92cb2e4 --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/client.rs @@ -0,0 +1,666 @@ +use std::sync::Arc; + +use async_stream::try_stream; +use dashmap::mapref::one::{Ref, RefMut}; +use dashmap::{DashMap, Entry}; +use futures::Stream; +use futures::StreamExt as _; + +use super::interest::InterestTracker; +use super::subscription::{ChannelType, SubscriptionManager}; +use super::types::response::{ + BestBidAsk, BookUpdate, LastTradePrice, MarketResolved, MidpointUpdate, NewMarket, + OrderMessage, PriceChange, TickSizeChange, TradeMessage, WsMessage, +}; +use crate::Result; +use crate::auth::state::{Authenticated, State, Unauthenticated}; +use crate::auth::{Credentials, Kind as AuthKind, Normal}; +use crate::error::Error; +use crate::types::{Address, B256, Decimal, U256}; +use crate::ws::ConnectionManager; +use crate::ws::config::Config; +use crate::ws::connection::ConnectionState; + +/// WebSocket client for real-time market data and user updates. +/// +/// This client uses a type-state pattern to enforce authentication requirements at compile time: +/// - [`Client`]: Can only access public market data +/// - [`Client>`]: Can access both public and user-specific data +/// +/// # Examples +/// +/// ```rust, no_run +/// use std::str::FromStr as _; +/// +/// use polymarket_client_sdk::clob::ws::Client; +/// use polymarket_client_sdk::types::U256; +/// use futures::StreamExt; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// // Create unauthenticated client +/// let client = Client::default(); +/// +/// let stream = client.subscribe_orderbook(vec![U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548")?])?; +/// let mut stream = Box::pin(stream); +/// +/// while let Some(book) = stream.next().await { +/// println!("Orderbook: {:?}", book?); +/// } +/// +/// Ok(()) +/// } +/// ``` +#[derive(Clone)] +pub struct Client { + inner: Arc>, +} + +impl Default for Client { + fn default() -> Self { + Self::new( + "wss://ws-subscriptions-clob.polymarket.com", + Config::default(), + ) + .expect("WebSocket client with default endpoint should succeed") + } +} + +struct ClientInner { + /// Current state of the client (authenticated or unauthenticated) + state: S, + /// Configuration for the WebSocket connections + config: Config, + /// Base endpoint without channel suffix (e.g. `wss://...`) + base_endpoint: String, + /// Resources for each WebSocket channel (lazily initialized) + channels: DashMap, +} + +impl Client { + /// Create a new unauthenticated WebSocket client. + /// + /// The `endpoint` should be the base WebSocket URL (e.g. `wss://...polymarket.com`); + /// channel paths (`/ws/market` or `/ws/user`) are appended automatically. + /// + /// The WebSocket connection is established lazily upon the first subscription. + pub fn new(endpoint: &str, config: Config) -> Result { + let base_endpoint = normalize_base_endpoint(endpoint); + + Ok(Self { + inner: Arc::new(ClientInner { + state: Unauthenticated, + config, + base_endpoint, + channels: DashMap::new(), + }), + }) + } + + /// Authenticate this client and elevate to authenticated state. + /// + /// Returns an error if there are other references to this client (e.g., from clones). + /// Ensure all clones are dropped before calling this method. + /// + /// The user WebSocket connection is established lazily upon the first subscription. + pub fn authenticate( + self, + credentials: Credentials, + address: Address, + ) -> Result>> { + let inner = Arc::into_inner(self.inner).ok_or(Error::validation( + "Cannot authenticate while other references to this client exist; \ + drop all clones before calling authenticate", + ))?; + let ClientInner { + config, + base_endpoint, + channels, + .. + } = inner; + + Ok(Client { + inner: Arc::new(ClientInner { + state: Authenticated { + address, + credentials, + kind: Normal, + }, + config, + base_endpoint, + channels, + }), + }) + } +} + +// Methods available in any state +impl Client { + /// Subscribes to real-time orderbook updates for specified market assets. + /// + /// Returns a stream of orderbook snapshots showing all bid and ask levels. + /// Each update contains the full orderbook state at that moment, useful for + /// maintaining an accurate local orderbook copy. + /// + /// # Arguments + /// + /// * `asset_ids` - List of asset/token IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection is not established. + pub fn subscribe_orderbook( + &self, + asset_ids: Vec, + ) -> Result>> { + let resources = self.inner.get_or_create_channel(ChannelType::Market)?; + let stream = resources.subscriptions.subscribe_market(asset_ids)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::Book(book)) => Some(Ok(book)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribes to real-time last trade price updates for specified assets. + /// + /// Returns a stream of the most recent executed trade price for each asset. + /// This reflects the latest market consensus price from actual transactions. + /// + /// # Arguments + /// + /// * `asset_ids` - List of asset/token IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection is not established. + pub fn subscribe_last_trade_price( + &self, + asset_ids: Vec, + ) -> Result>> { + let resources = self.inner.get_or_create_channel(ChannelType::Market)?; + let stream = resources.subscriptions.subscribe_market(asset_ids)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::LastTradePrice(last_trade_price)) => Some(Ok(last_trade_price)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribes to real-time price changes for specified assets. + /// + /// Returns a stream of price updates when the best bid or ask changes. + /// More lightweight than full orderbook subscriptions when you only need + /// top-of-book prices. + /// + /// # Arguments + /// + /// * `asset_ids` - List of asset/token IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection is not established. + pub fn subscribe_prices( + &self, + asset_ids: Vec, + ) -> Result>> { + let resources = self.inner.get_or_create_channel(ChannelType::Market)?; + let stream = resources.subscriptions.subscribe_market(asset_ids)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::PriceChange(price)) => Some(Ok(price)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribes to real-time tick size change events for specified assets. + /// + /// Returns a stream of tick size change when the backend adjusts the minimum + /// price increment for an asset. + /// + /// # Arguments + /// + /// * `asset_ids` - List of asset/token IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection is not established. + pub fn subscribe_tick_size_change( + &self, + asset_ids: Vec, + ) -> Result>> { + let resources = self.inner.get_or_create_channel(ChannelType::Market)?; + let stream = resources.subscriptions.subscribe_market(asset_ids)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::TickSizeChange(tsc)) => Some(Ok(tsc)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribes to real-time midpoint price updates for specified assets. + /// + /// Returns a stream of midpoint prices calculated as the average of the best + /// bid and best ask: `(best_bid + best_ask) / 2`. This provides a fair market + /// price estimate that updates with every orderbook change. + /// + /// # Arguments + /// + /// * `asset_ids` - List of asset/token IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection is not established. + pub fn subscribe_midpoints( + &self, + asset_ids: Vec, + ) -> Result>> { + let stream = self.subscribe_orderbook(asset_ids)?; + + Ok(try_stream! { + for await book_result in stream { + let book = book_result?; + + // Calculate midpoint from best bid/ask + if let (Some(bid), Some(ask)) = (book.bids.first(), book.asks.first()) { + let midpoint = (bid.price + ask.price) / Decimal::TWO; + yield MidpointUpdate { + asset_id: book.asset_id, + market: book.market, + midpoint, + timestamp: book.timestamp, + }; + } + } + }) + } + + /// Subscribe to best bid/ask updates with custom features enabled. + /// + /// Requires `custom_feature_enabled` flag on the server side. + pub fn subscribe_best_bid_ask( + &self, + asset_ids: Vec, + ) -> Result>> { + let stream = self + .inner + .get_or_create_channel(ChannelType::Market)? + .subscriptions + .subscribe_market_with_options(asset_ids, true)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::BestBidAsk(bba)) => Some(Ok(bba)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribe to new market events with custom features enabled. + /// + /// Requires `custom_feature_enabled` flag on the server side. + pub fn subscribe_new_markets( + &self, + asset_ids: Vec, + ) -> Result>> { + let stream = self + .inner + .get_or_create_channel(ChannelType::Market)? + .subscriptions + .subscribe_market_with_options(asset_ids, true)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::NewMarket(nm)) => Some(Ok(nm)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribe to market resolved events with custom features enabled. + /// + /// Requires `custom_feature_enabled` flag on the server side. + pub fn subscribe_market_resolutions( + &self, + asset_ids: Vec, + ) -> Result>> { + let stream = self + .inner + .get_or_create_channel(ChannelType::Market)? + .subscriptions + .subscribe_market_with_options(asset_ids, true)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::MarketResolved(mr)) => Some(Ok(mr)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Get the current connection state for a specific channel. + /// + /// Returns [`ConnectionState::Disconnected`] if the channel has not been + /// initialized yet (no subscriptions have been made). + #[must_use] + pub fn connection_state(&self, channel_type: ChannelType) -> ConnectionState { + self.inner.channel(channel_type).as_deref().map_or( + ConnectionState::Disconnected, + ChannelResources::connection_state, + ) + } + + /// Check if the WebSocket connection is established for a specific channel. + /// + /// Returns `false` if no subscriptions have been made yet for this channel. + #[must_use] + pub fn is_connected(&self, channel_type: ChannelType) -> bool { + self.inner.channel(channel_type).is_some() + } + + /// Get the number of active subscriptions. + #[must_use] + pub fn subscription_count(&self) -> usize { + self.inner + .channels + .iter() + .map(|entry| entry.value().subscriptions.subscription_count()) + .sum() + } + + /// Unsubscribe from orderbook updates for specific assets. + /// + /// This decrements the reference count for each asset. The server unsubscribe + /// is only sent when no other subscriptions are using those assets. + pub fn unsubscribe_orderbook(&self, asset_ids: &[U256]) -> Result<()> { + self.inner + .unsubscribe_and_cleanup(ChannelType::Market, |subs| { + subs.unsubscribe_market(asset_ids) + }) + } + + /// Unsubscribe from price changes for specific assets. + /// + /// This decrements the reference count for each asset. The server unsubscribe + /// is only sent when no other subscriptions are using those assets. + pub fn unsubscribe_prices(&self, asset_ids: &[U256]) -> Result<()> { + self.unsubscribe_orderbook(asset_ids) + } + + /// Unsubscribe from tick size change updates for specific assets. + /// + /// This decrements the reference count for each asset. The server unsubscribe + /// is only sent when no other subscriptions are using those assets. + pub fn unsubscribe_tick_size_change(&self, asset_ids: &[U256]) -> Result<()> { + self.unsubscribe_orderbook(asset_ids) + } + + /// Unsubscribe from midpoint updates for specific assets. + /// + /// This decrements the reference count for each asset. The server unsubscribe + /// is only sent when no other subscriptions are using those assets. + pub fn unsubscribe_midpoints(&self, asset_ids: &[U256]) -> Result<()> { + self.unsubscribe_orderbook(asset_ids) + } +} + +// Methods only available for authenticated clients +impl Client> { + /// Subscribes to all user-specific events (orders and trades) for specified markets. + /// + /// Returns a stream of raw WebSocket messages containing both order updates + /// (fills, cancellations, placements) and trade executions. Use this for + /// comprehensive monitoring of all trading activity. + /// + /// # Arguments + /// + /// * `markets` - List of market condition IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created, the WebSocket + /// connection is not established, or authentication fails. + /// + /// # Note + /// + /// This method is only available on authenticated clients. + pub fn subscribe_user_events( + &self, + markets: Vec, + ) -> Result>> { + let resources = self.inner.get_or_create_channel(ChannelType::User)?; + + resources + .subscriptions + .subscribe_user(markets, &self.inner.state.credentials) + } + + /// Subscribes to real-time order status updates for the authenticated user. + /// + /// Returns a stream of order events including order placement, fills, partial fills, + /// and cancellations. Useful for tracking the lifecycle of your orders in real-time. + /// + /// # Arguments + /// + /// * `markets` - List of market condition IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created, the WebSocket + /// connection is not established, or authentication fails. + /// + /// # Note + /// + /// This method is only available on authenticated clients. + pub fn subscribe_orders( + &self, + markets: Vec, + ) -> Result>> { + let stream = self.subscribe_user_events(markets)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::Order(order)) => Some(Ok(order)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Subscribes to real-time trade execution updates for the authenticated user. + /// + /// Returns a stream of trade events when your orders are matched and executed. + /// Each trade event contains details about the execution price, size, maker/taker + /// side, and associated order IDs. + /// + /// # Arguments + /// + /// * `markets` - List of market condition IDs to monitor + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created, the WebSocket + /// connection is not established, or authentication fails. + /// + /// # Note + /// + /// This method is only available on authenticated clients. + pub fn subscribe_trades( + &self, + markets: Vec, + ) -> Result>> { + let stream = self.subscribe_user_events(markets)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(WsMessage::Trade(trade)) => Some(Ok(trade)), + Err(e) => Some(Err(e)), + _ => None, + } + })) + } + + /// Unsubscribe from user channel events for specific markets. + /// + /// This decrements the reference count for each market. The server unsubscribe + /// is only sent when no other subscriptions are using those markets. + pub fn unsubscribe_user_events(&self, markets: &[B256]) -> Result<()> { + self.inner + .unsubscribe_and_cleanup(ChannelType::User, |subs| subs.unsubscribe_user(markets)) + } + + /// Unsubscribe from user's order updates for specific markets. + /// + /// This decrements the reference count for each market. The server unsubscribe + /// is only sent when no other subscriptions are using those markets. + pub fn unsubscribe_orders(&self, markets: &[B256]) -> Result<()> { + self.unsubscribe_user_events(markets) + } + + /// Unsubscribe from user's trade executions for specific markets. + /// + /// This decrements the reference count for each market. The server unsubscribe + /// is only sent when no other subscriptions are using those markets. + pub fn unsubscribe_trades(&self, markets: &[B256]) -> Result<()> { + self.unsubscribe_user_events(markets) + } + + /// Deauthenticate and return to unauthenticated state. + /// + /// Returns an error if there are other references to this client (e.g., from clones). + /// Ensure all clones are dropped before calling this method. + pub fn deauthenticate(self) -> Result> { + let inner = Arc::into_inner(self.inner).ok_or(Error::validation( + "Cannot deauthenticate while other references to this client exist; \ + drop all clones before calling deauthenticate", + ))?; + let ClientInner { + config, + base_endpoint, + channels, + .. + } = inner; + channels.remove(&ChannelType::User); + + Ok(Client { + inner: Arc::new(ClientInner { + state: Unauthenticated, + config, + base_endpoint, + channels, + }), + }) + } +} + +impl ClientInner { + fn get_or_create_channel( + &self, + channel_type: ChannelType, + ) -> Result> { + self.channels + .entry(channel_type) + .or_try_insert_with(|| { + let endpoint = channel_endpoint(&self.base_endpoint, channel_type); + ChannelResources::new(endpoint, self.config.clone()) + }) + .map(RefMut::downgrade) + } + + fn channel(&self, channel_type: ChannelType) -> Option> { + self.channels.get(&channel_type) + } + + /// Helper to unsubscribe and remove connection if there are no more subscriptions on this channel + fn unsubscribe_and_cleanup(&self, channel_type: ChannelType, unsubscribe_fn: F) -> Result<()> + where + F: FnOnce(&SubscriptionManager) -> Result<()>, + { + match self.channels.entry(channel_type) { + Entry::Vacant(_) => Ok(()), + Entry::Occupied(channel_ref) => { + // Clone the Arc to subscriptions while holding the Entry + let subs = Arc::clone(&channel_ref.get().subscriptions); + drop(channel_ref); // Release Entry immediately + + // Do potentially blocking network I/O without holding the Entry lock + unsubscribe_fn(&subs)?; + + // Atomically check and remove channel if empty + if let Entry::Occupied(entry) = self.channels.entry(channel_type) + && !entry.get().subscriptions.has_subscriptions(channel_type) + { + entry.remove(); + } + Ok(()) + } + } + } +} + +/// Resources for a WebSocket channel. +struct ChannelResources { + connection: ConnectionManager>, + subscriptions: Arc, +} + +impl ChannelResources { + fn new(endpoint: String, config: Config) -> Result { + let interest = Arc::new(InterestTracker::new()); + let connection = ConnectionManager::new(endpoint, config, Arc::clone(&interest))?; + let subscriptions = Arc::new(SubscriptionManager::new(connection.clone(), interest)); + + subscriptions.start_reconnection_handler(); + + Ok(Self { + connection, + subscriptions, + }) + } + + fn connection_state(&self) -> ConnectionState { + self.connection.state() + } +} + +fn normalize_base_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim_end_matches('/'); + if let Some(stripped) = trimmed.strip_suffix("/ws/market") { + stripped.to_owned() + } else if let Some(stripped) = trimmed.strip_suffix("/ws/user") { + stripped.to_owned() + } else if let Some(stripped) = trimmed.strip_suffix("/ws") { + stripped.to_owned() + } else { + trimmed.to_owned() + } +} + +fn channel_endpoint(base: &str, channel: ChannelType) -> String { + let trimmed = base.trim_end_matches('/'); + let segment = match channel { + ChannelType::Market => "market", + ChannelType::User => "user", + }; + format!("{trimmed}/ws/{segment}") +} diff --git a/polymarket-client-sdk/src/clob/ws/interest.rs b/polymarket-client-sdk/src/clob/ws/interest.rs new file mode 100644 index 0000000..c5218ae --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/interest.rs @@ -0,0 +1,227 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU16, Ordering}; + +use bitflags::bitflags; + +use crate::clob::ws::types::response::WsMessage; +use crate::clob::ws::types::response::parse_if_interested; + +bitflags! { + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct MessageInterest: u16 { + /// No interest in any message types. + const NONE = 0; + + /// Interest in orderbook updates. + const BOOK = 1; + + /// Interest in price change notifications. + const PRICE_CHANGE = 1 << 1; + + /// Interest in tick size changes. + const TICK_SIZE = 1 << 2; + + /// Interest in last trade price updates. + const LAST_TRADE_PRICE = 1 << 3; + + /// Interest in trade executions. + const TRADE = 1 << 4; + + /// Interest in order updates. + const ORDER = 1 << 5; + + /// Interest in best bid/ask updates (requires `custom_feature_enabled`). + const BEST_BID_ASK = 1 << 6; + + /// Interest in new market events (requires `custom_feature_enabled`). + const NEW_MARKET = 1 << 7; + + /// Interest in market resolved events (requires `custom_feature_enabled`). + const MARKET_RESOLVED = 1 << 8; + + /// Interest in all market data messages (including custom feature messages). + const MARKET = Self::BOOK.bits() + | Self::PRICE_CHANGE.bits() + | Self::TICK_SIZE.bits() + | Self::LAST_TRADE_PRICE.bits() + | Self::BEST_BID_ASK.bits() + | Self::NEW_MARKET.bits() + | Self::MARKET_RESOLVED.bits(); + + /// Interest in all user channel messages. + const USER = Self::TRADE.bits() | Self::ORDER.bits(); + + /// Interest in all message types. + const ALL = Self::MARKET.bits() | Self::USER.bits(); + } +} + +impl MessageInterest { + /// Get the interest flag for a given event type string. + #[must_use] + pub fn from_event_type(event_type: &str) -> Self { + match event_type { + "book" => Self::BOOK, + "price_change" => Self::PRICE_CHANGE, + "tick_size_change" => Self::TICK_SIZE, + "last_trade_price" => Self::LAST_TRADE_PRICE, + "trade" => Self::TRADE, + "order" => Self::ORDER, + "best_bid_ask" => Self::BEST_BID_ASK, + "new_market" => Self::NEW_MARKET, + "market_resolved" => Self::MARKET_RESOLVED, + _ => Self::NONE, + } + } + + #[must_use] + pub fn is_interested_in_event(&self, event_type: &str) -> bool { + let interest = MessageInterest::from_event_type(event_type); + !interest.is_empty() && self.contains(interest) + } +} + +impl Default for MessageInterest { + fn default() -> Self { + Self::ALL + } +} + +/// Thread-safe interest tracker that can be shared between subscription manager and connection. +#[derive(Debug, Default)] +pub struct InterestTracker { + interest: AtomicU16, +} + +impl InterestTracker { + /// Create a new tracker with no interest. + #[must_use] + pub const fn new() -> Self { + Self { + interest: AtomicU16::new(0), + } + } + + /// Add interest in specific message types. + pub fn add(&self, interest: MessageInterest) { + self.interest.fetch_or(interest.bits(), Ordering::Release); + } + + /// Get the current interest set. + #[must_use] + pub fn get(&self) -> MessageInterest { + MessageInterest::from_bits(self.interest.load(Ordering::Acquire)) + .unwrap_or(MessageInterest::NONE) + } + + /// Check if there's interest in a specific message type. + #[must_use] + pub fn is_interested(&self, interest: MessageInterest) -> bool { + self.get().contains(interest) + } + + /// Check if there's interest in a message with the given event type. + #[must_use] + pub fn is_interested_in_event(&self, event_type: &str) -> bool { + let interest = MessageInterest::from_event_type(event_type); + !interest.is_empty() && self.is_interested(interest) + } +} + +impl crate::ws::traits::MessageParser for Arc { + fn parse(&self, bytes: &[u8]) -> crate::Result> { + parse_if_interested(bytes, &self.get()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interest_contains() { + assert!(MessageInterest::MARKET.contains(MessageInterest::BOOK)); + assert!(MessageInterest::MARKET.contains(MessageInterest::PRICE_CHANGE)); + assert!(!MessageInterest::MARKET.contains(MessageInterest::TRADE)); + assert!(MessageInterest::ALL.contains(MessageInterest::TRADE)); + } + + #[test] + fn interest_from_event_type() { + assert_eq!( + MessageInterest::from_event_type("book"), + MessageInterest::BOOK + ); + assert_eq!( + MessageInterest::from_event_type("trade"), + MessageInterest::TRADE + ); + assert_eq!( + MessageInterest::from_event_type("unknown"), + MessageInterest::NONE + ); + } + + #[test] + fn interest_from_event_type_custom_features() { + assert_eq!( + MessageInterest::from_event_type("best_bid_ask"), + MessageInterest::BEST_BID_ASK + ); + assert_eq!( + MessageInterest::from_event_type("new_market"), + MessageInterest::NEW_MARKET + ); + assert_eq!( + MessageInterest::from_event_type("market_resolved"), + MessageInterest::MARKET_RESOLVED + ); + } + + #[test] + fn market_contains_custom_feature_interests() { + assert!(MessageInterest::MARKET.contains(MessageInterest::BEST_BID_ASK)); + assert!(MessageInterest::MARKET.contains(MessageInterest::NEW_MARKET)); + assert!(MessageInterest::MARKET.contains(MessageInterest::MARKET_RESOLVED)); + } + + #[test] + fn tracker_is_interested_in_event() { + let tracker = InterestTracker::new(); + tracker.add(MessageInterest::BEST_BID_ASK); + + assert!(tracker.is_interested_in_event("best_bid_ask")); + assert!(!tracker.is_interested_in_event("book")); + assert!(!tracker.is_interested_in_event("unknown")); + } + + #[test] + fn message_interest_is_interested_in_event() { + let interest = MessageInterest::MARKET; + assert!(interest.is_interested_in_event("book")); + assert!(interest.is_interested_in_event("best_bid_ask")); + assert!(!interest.is_interested_in_event("trade")); + assert!(!interest.is_interested_in_event("unknown")); + } + + #[test] + fn message_interest_default() { + let interest = MessageInterest::default(); + assert_eq!(interest, MessageInterest::ALL); + } + + #[test] + fn tracker_add_and_get() { + let tracker = InterestTracker::new(); + assert!(tracker.get().is_empty()); + + tracker.add(MessageInterest::BOOK); + assert!(tracker.is_interested(MessageInterest::BOOK)); + assert!(!tracker.is_interested(MessageInterest::TRADE)); + + tracker.add(MessageInterest::TRADE); + assert!(tracker.is_interested(MessageInterest::BOOK)); + assert!(tracker.is_interested(MessageInterest::TRADE)); + } +} diff --git a/polymarket-client-sdk/src/clob/ws/mod.rs b/polymarket-client-sdk/src/clob/ws/mod.rs new file mode 100644 index 0000000..44beb96 --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/mod.rs @@ -0,0 +1,21 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Re-exported names intentionally match their modules for API clarity" +)] + +pub mod client; +pub mod interest; +pub mod subscription; +pub mod types; + +// Re-export commonly used types +pub use client::Client; +pub use subscription::{ChannelType, SubscriptionInfo, SubscriptionTarget}; +pub use types::request::SubscriptionRequest; +pub use types::response::{ + BestBidAsk, BookUpdate, EventMessage, LastTradePrice, MakerOrder, MarketResolved, + MidpointUpdate, NewMarket, OrderMessage, OrderStatus, PriceChange, PriceChangeBatchEntry, + TickSizeChange, TradeMessage, WsMessage, +}; + +pub use crate::ws::WsError; diff --git a/polymarket-client-sdk/src/clob/ws/subscription.rs b/polymarket-client-sdk/src/clob/ws/subscription.rs new file mode 100644 index 0000000..0a30488 --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/subscription.rs @@ -0,0 +1,563 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Subscription types deliberately include the module name for clarity" +)] + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, PoisonError, RwLock}; +use std::time::Instant; + +use async_stream::try_stream; +use dashmap::{DashMap, Entry}; +use futures::Stream; +use tokio::sync::broadcast::error::RecvError; + +use super::interest::{InterestTracker, MessageInterest}; +use super::types::request::SubscriptionRequest; +use super::types::response::WsMessage; +use crate::Result; +use crate::auth::Credentials; +use crate::types::{B256, U256}; +use crate::ws::ConnectionManager; +use crate::ws::WsError; +use crate::ws::connection::ConnectionState; + +/// What a subscription is targeting. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum SubscriptionTarget { + /// Subscribed to market data for specific assets. + Assets(Vec), + /// Subscribed to user events for specific markets. + Markets(Vec), +} + +impl SubscriptionTarget { + /// Returns the channel type this target belongs to. + #[must_use] + pub const fn channel(&self) -> ChannelType { + match self { + Self::Assets(_) => ChannelType::Market, + Self::Markets(_) => ChannelType::User, + } + } +} + +/// Information about an active subscription. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SubscriptionInfo { + /// What this subscription is targeting. + pub target: SubscriptionTarget, + /// When the subscription was created. + pub created_at: Instant, +} + +impl SubscriptionInfo { + /// Returns the channel type for this subscription. + #[must_use] + pub const fn channel(&self) -> ChannelType { + self.target.channel() + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChannelType { + /// Public market data channel + Market, + /// Authenticated user data channel + User, +} + +/// Manages active subscriptions and routes messages to subscribers. +pub struct SubscriptionManager { + connection: ConnectionManager>, + active_subs: DashMap, + interest: Arc, + /// Subscribed assets with reference counts (for multiplexing) + subscribed_assets: DashMap, + /// Subscribed markets with reference counts (for multiplexing) + subscribed_markets: DashMap, + last_auth: Arc>>, + /// Track if custom features were enabled for any market subscription + /// (enables `best_bid_ask`, `new_market`, `market_resolved` messages) + custom_features_enabled: AtomicBool, +} + +impl SubscriptionManager { + /// Create a new subscription manager. + #[must_use] + pub fn new( + connection: ConnectionManager>, + interest: Arc, + ) -> Self { + Self { + connection, + active_subs: DashMap::new(), + interest, + subscribed_assets: DashMap::new(), + subscribed_markets: DashMap::new(), + last_auth: Arc::new(RwLock::new(None)), + custom_features_enabled: AtomicBool::new(false), + } + } + + /// Start the reconnection handler that re-subscribes on connection recovery. + pub fn start_reconnection_handler(self: &Arc) { + let this = Arc::clone(self); + + tokio::spawn(async move { + let mut state_rx = this.connection.state_receiver(); + let mut was_connected = state_rx.borrow().is_connected(); + + loop { + // Wait for next state change + if state_rx.changed().await.is_err() { + // Channel closed, connection manager is gone + break; + } + + let state = *state_rx.borrow_and_update(); + + match state { + ConnectionState::Connected { .. } => { + if was_connected { + // Reconnect to subscriptions + #[cfg(feature = "tracing")] + tracing::debug!("WebSocket reconnected, re-establishing subscriptions"); + this.resubscribe_all(); + } + was_connected = true; + } + ConnectionState::Disconnected => { + // Connection permanently closed + break; + } + _ => { + // Other states are no-op + } + } + } + }); + } + + /// Re-send subscription requests for all tracked assets and markets. + fn resubscribe_all(&self) { + // Collect all subscribed assets + let assets: Vec = self.subscribed_assets.iter().map(|r| *r.key()).collect(); + + if !assets.is_empty() { + let custom_features = self.custom_features_enabled.load(Ordering::Relaxed); + #[cfg(feature = "tracing")] + tracing::debug!( + count = assets.len(), + custom_features, + "Re-subscribing to market assets" + ); + let mut request = SubscriptionRequest::market(assets); + if custom_features { + request = request.with_custom_features(true); + } + if let Err(e) = self.connection.send(&request) { + #[cfg(feature = "tracing")] + tracing::warn!(%e, "Failed to re-subscribe to market channel"); + #[cfg(not(feature = "tracing"))] + let _ = &e; + } + } + + // Store auth for re-subscription on reconnect. + // We can recover from poisoned lock because Option has no inconsistent intermediate state. + let auth = self + .last_auth + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(auth) = auth { + let markets: Vec = self.subscribed_markets.iter().map(|r| *r.key()).collect(); + + #[cfg(feature = "tracing")] + tracing::debug!( + markets_count = markets.len(), + "Re-subscribing to user channel" + ); + let request = SubscriptionRequest::user(markets); + if let Err(e) = self.connection.send_authenticated(&request, &auth) { + #[cfg(feature = "tracing")] + tracing::warn!(%e, "Failed to re-subscribe to user channel"); + #[cfg(not(feature = "tracing"))] + let _ = &e; + } + } + } + + /// Subscribe to public market data channel. + /// + /// This will fail if `asset_ids` is empty. + pub fn subscribe_market( + &self, + asset_ids: Vec, + ) -> Result> + use<>> { + self.subscribe_market_with_options(asset_ids, false) + } + + /// Subscribe to public market data channel with options. + /// + /// When `custom_features` is true, enables receiving additional message types: + /// `best_bid_ask`, `new_market`, `market_resolved`. + /// + /// This will fail if `asset_ids` is empty. + pub fn subscribe_market_with_options( + &self, + asset_ids: Vec, + custom_features: bool, + ) -> Result> + use<>> { + if asset_ids.is_empty() { + return Err(WsError::SubscriptionFailed( + "asset_ids cannot be empty: at least one asset ID must be provided for subscription" + .to_owned(), + ) + .into()); + } + + self.interest.add(MessageInterest::MARKET); + + // Track if custom features are enabled (for re-subscription on reconnect) + if custom_features { + self.custom_features_enabled.store(true, Ordering::Relaxed); + } + + // Increment refcounts and determine which assets are truly new + let new_assets: Vec = asset_ids + .iter() + .filter_map(|id| match self.subscribed_assets.entry(*id) { + Entry::Occupied(mut o) => { + *o.get_mut() += 1; + None + } + Entry::Vacant(v) => { + v.insert(1); + Some(id.to_owned()) + } + }) + .collect(); + + // Only send subscription request for new assets + if new_assets.is_empty() { + #[cfg(feature = "tracing")] + tracing::debug!("All requested assets already subscribed, multiplexing"); + } else { + #[cfg(feature = "tracing")] + tracing::debug!( + count = new_assets.len(), + ?new_assets, + custom_features, + "Subscribing to new market assets" + ); + let mut request = SubscriptionRequest::market(new_assets); + if custom_features { + request = request.with_custom_features(true); + } + self.connection.send(&request)?; + } + + // Register subscription + let sub_id = format!( + "market:{}", + asset_ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + self.active_subs.insert( + sub_id, + SubscriptionInfo { + target: SubscriptionTarget::Assets(asset_ids.clone()), + created_at: Instant::now(), + }, + ); + + // Create filtered stream with its own receiver + let mut rx = self.connection.subscribe(); + let asset_ids_set: HashSet = asset_ids.into_iter().collect(); + + Ok(try_stream! { + loop { + match rx.recv().await { + Ok(msg) => { + // Filter messages by asset_id + let should_yield = match &msg { + WsMessage::Book(book) => asset_ids_set.contains(&book.asset_id), + WsMessage::PriceChange(price) => { + price + .price_changes + .iter() + .any(|pc| asset_ids_set.contains(&pc.asset_id)) + }, + WsMessage::LastTradePrice(ltp) => asset_ids_set.contains(<p.asset_id), + WsMessage::TickSizeChange(tsc) => asset_ids_set.contains(&tsc.asset_id), + WsMessage::BestBidAsk(bba) => asset_ids_set.contains(&bba.asset_id), + WsMessage::NewMarket(nm) => { + nm.asset_ids.iter().any(|id| asset_ids_set.contains(id)) + }, + WsMessage::MarketResolved(mr) => { + mr.asset_ids.iter().any(|id| asset_ids_set.contains(id)) + }, + _ => false, + }; + + if should_yield { + yield msg + } + } + Err(RecvError::Lagged(n)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Subscription lagged, missed {n} messages"); + Err(WsError::Lagged { count: n })?; + } + Err(RecvError::Closed) => { + break; + } + } + } + }) + } + + /// Subscribe to authenticated user channel. + pub fn subscribe_user( + &self, + markets: Vec, + auth: &Credentials, + ) -> Result> + use<>> { + self.interest.add(MessageInterest::USER); + + // Store auth for re-subscription on reconnect. + // We can recover from poisoned lock because Option has no inconsistent intermediate state. + *self + .last_auth + .write() + .unwrap_or_else(PoisonError::into_inner) = Some(auth.clone()); + + // Increment refcounts and determine which markets are truly new + let new_markets: Vec = markets + .iter() + .filter_map(|id| match self.subscribed_markets.entry(id.to_owned()) { + Entry::Occupied(mut o) => { + *o.get_mut() += 1; + None + } + Entry::Vacant(v) => { + v.insert(1); + Some(id.to_owned()) + } + }) + .collect(); + + // Only send subscription request for new markets (or if subscribing to all) + if !markets.is_empty() && new_markets.is_empty() { + #[cfg(feature = "tracing")] + tracing::debug!("All requested markets already subscribed, multiplexing"); + } else { + #[cfg(feature = "tracing")] + tracing::debug!( + count = new_markets.len(), + ?new_markets, + "Subscribing to user channel" + ); + let request = SubscriptionRequest::user(new_markets); + self.connection.send_authenticated(&request, auth)?; + } + + // Register subscription + let sub_id = format!( + "user:{}", + markets + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + self.active_subs.insert( + sub_id, + SubscriptionInfo { + target: SubscriptionTarget::Markets(markets), + created_at: Instant::now(), + }, + ); + + // Create stream for user messages + let mut rx = self.connection.subscribe(); + + Ok(try_stream! { + loop { + match rx.recv().await { + Ok(msg) => { + if msg.is_user() { + yield msg; + } + } + Err(RecvError::Lagged(n)) => { + #[cfg(feature = "tracing")] + tracing::warn!("Subscription lagged, missed {n} messages"); + Err(WsError::Lagged { count: n })?; + } + Err(RecvError::Closed) => { + break; + } + } + } + }) + } + + /// Get information about all active subscriptions. + #[must_use] + pub fn active_subscriptions(&self) -> HashMap> { + self.active_subs + .iter() + .fold(HashMap::new(), |mut acc, entry| { + acc.entry(entry.value().channel()) + .or_default() + .push(entry.value().clone()); + acc + }) + } + + /// Get the number of active subscriptions. + #[must_use] + pub fn subscription_count(&self) -> usize { + self.active_subs.len() + } + + /// Check if there are any subscriptions for a specific channel type. + #[must_use] + pub fn has_subscriptions(&self, channel: ChannelType) -> bool { + match channel { + ChannelType::Market => !self.subscribed_assets.is_empty(), + ChannelType::User => !self.subscribed_markets.is_empty(), + } + } + + /// Unsubscribe from market data for specific assets. + /// + /// This decrements the reference count for each asset. Only sends an unsubscribe + /// request to the server when the reference count reaches zero (no other streams + /// are using that asset). + pub fn unsubscribe_market(&self, asset_ids: &[U256]) -> Result<()> { + if asset_ids.is_empty() { + return Err(WsError::SubscriptionFailed( + "asset_ids cannot be empty: at least one asset ID must be provided for unsubscription" + .to_owned(), + ) + .into()); + } + + let mut to_unsubscribe = Vec::new(); + + // Atomically decrement refcounts and remove assets that reach zero + // Using Entry API to prevent TOCTOU race between decrement and removal + for id in asset_ids { + if let Entry::Occupied(mut entry) = self.subscribed_assets.entry(*id) { + let refcount = entry.get_mut(); + *refcount = refcount.saturating_sub(1); + if *refcount == 0 { + entry.remove(); + to_unsubscribe.push(*id); + } + } + } + + // Send unsubscribe only for zero-refcount assets + if !to_unsubscribe.is_empty() { + #[cfg(feature = "tracing")] + tracing::debug!( + count = to_unsubscribe.len(), + ?to_unsubscribe, + "Unsubscribing from market assets" + ); + let request = SubscriptionRequest::market_unsubscribe(to_unsubscribe); + self.connection.send(&request)?; + } + + // Remove active_subs entries where all assets are now unsubscribed + self.active_subs.retain(|_, info| { + if let SubscriptionTarget::Assets(assets) = &info.target { + // Keep entry only if at least one asset is still subscribed + assets + .iter() + .any(|a| self.subscribed_assets.contains_key(a)) + } else { + true // Keep non-market subscriptions + } + }); + + Ok(()) + } + + /// Unsubscribe from user events for specific markets. + /// + /// This decrements the reference count for each market. Only sends an unsubscribe + /// request to the server when the reference count reaches zero (no other streams + /// are using that market). + pub fn unsubscribe_user(&self, markets: &[B256]) -> Result<()> { + if markets.is_empty() { + return Err(WsError::SubscriptionFailed( + "markets cannot be empty: at least one market ID must be provided for unsubscription" + .to_owned(), + ) + .into()); + } + + let mut to_unsubscribe = Vec::new(); + + // Atomically decrement refcounts and remove markets that reach zero + // Using Entry API to prevent TOCTOU race between decrement and removal + for m in markets { + if let Entry::Occupied(mut entry) = self.subscribed_markets.entry(*m) { + let refcount = entry.get_mut(); + *refcount = refcount.saturating_sub(1); + if *refcount == 0 { + entry.remove(); + to_unsubscribe.push(*m); + } + } + } + + // Send unsubscribe only for zero-refcount markets + if !to_unsubscribe.is_empty() { + #[cfg(feature = "tracing")] + tracing::debug!( + count = to_unsubscribe.len(), + ?to_unsubscribe, + "Unsubscribing from user markets" + ); + + // Get auth for unsubscribe request + let auth = self + .last_auth + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + .ok_or(WsError::AuthenticationFailed)?; + + let request = SubscriptionRequest::user_unsubscribe(to_unsubscribe); + self.connection.send_authenticated(&request, &auth)?; + } + + // Remove active_subs entries where all markets are now unsubscribed + self.active_subs.retain(|_, info| { + if let SubscriptionTarget::Markets(markets) = &info.target { + // Keep entry only if at least one market is still subscribed + markets + .iter() + .any(|m| self.subscribed_markets.contains_key(m)) + } else { + true // Keep non-user subscriptions + } + }); + + Ok(()) + } +} diff --git a/polymarket-client-sdk/src/clob/ws/types/mod.rs b/polymarket-client-sdk/src/clob/ws/types/mod.rs new file mode 100644 index 0000000..e006218 --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/types/mod.rs @@ -0,0 +1,2 @@ +pub mod request; +pub mod response; diff --git a/polymarket-client-sdk/src/clob/ws/types/request.rs b/polymarket-client-sdk/src/clob/ws/types/request.rs new file mode 100644 index 0000000..23e9900 --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/types/request.rs @@ -0,0 +1,114 @@ +use serde::Serialize; +use serde_with::{DisplayFromStr, serde_as}; +use strum_macros::Display; + +use crate::types::{B256, U256}; +use crate::ws::WithCredentials; + +#[non_exhaustive] +#[derive(Clone, Debug, Serialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Operation { + Subscribe, + Unsubscribe, +} + +#[non_exhaustive] +#[derive(Clone, Debug, Serialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Channel { + User, + Market, +} + +/// Subscription request message sent to the WebSocket server. +#[non_exhaustive] +#[serde_as] +#[derive(Clone, Debug, Serialize)] +pub struct SubscriptionRequest { + /// Subscription type ("market" or "user") + pub r#type: Channel, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + /// List of market IDs + #[serde_as(as = "Vec")] + pub markets: Vec, + /// List of asset IDs + #[serde(rename = "assets_ids")] + #[serde_as(as = "Vec")] + pub asset_ids: Vec, + /// Request initial state dump + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_dump: Option, + /// Enable custom features (`best_bid_ask`, `new_market`, `market_resolved`) + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_feature_enabled: Option, +} + +impl WithCredentials for SubscriptionRequest {} + +impl SubscriptionRequest { + /// Create a market subscription request. + #[must_use] + pub fn market(asset_ids: Vec) -> Self { + Self { + r#type: Channel::Market, + operation: Some(Operation::Subscribe), + markets: vec![], + asset_ids, + initial_dump: Some(true), + custom_feature_enabled: None, + } + } + + /// Create a market unsubscribe request. + #[must_use] + pub fn market_unsubscribe(asset_ids: Vec) -> Self { + Self { + r#type: Channel::Market, + operation: Some(Operation::Unsubscribe), + markets: vec![], + asset_ids, + initial_dump: None, + custom_feature_enabled: None, + } + } + + /// Create a user subscription request. + #[must_use] + pub fn user(markets: Vec) -> Self { + Self { + r#type: Channel::User, + operation: Some(Operation::Subscribe), + markets, + asset_ids: vec![], + initial_dump: Some(true), + custom_feature_enabled: None, + } + } + + /// Create a user unsubscribe request. + #[must_use] + pub fn user_unsubscribe(markets: Vec) -> Self { + Self { + r#type: Channel::User, + operation: Some(Operation::Unsubscribe), + markets, + asset_ids: vec![], + initial_dump: None, + custom_feature_enabled: None, + } + } + + /// Enable custom features on this subscription request. + /// + /// Enables receiving additional message types: `best_bid_ask`, `new_market`, + /// `market_resolved`. + #[must_use] + pub fn with_custom_features(mut self, enabled: bool) -> Self { + self.custom_feature_enabled = Some(enabled); + self + } +} diff --git a/polymarket-client-sdk/src/clob/ws/types/response.rs b/polymarket-client-sdk/src/clob/ws/types/response.rs new file mode 100644 index 0000000..c599ffa --- /dev/null +++ b/polymarket-client-sdk/src/clob/ws/types/response.rs @@ -0,0 +1,1175 @@ +use bon::Builder; +use serde::Deserialize; +use serde_json::Value; +use serde_with::{DefaultOnNull, DisplayFromStr, NoneAsEmptyString, serde_as}; +#[cfg(feature = "tracing")] +use tracing::warn; + +use crate::auth::ApiKey; +use crate::clob::types::{OrderStatusType, Side, TraderSide}; +use crate::clob::ws::interest::MessageInterest; +use crate::error::Kind; +use crate::types::{B256, Decimal, U256}; + +/// Top-level WebSocket message wrapper. +/// +/// All messages received from the WebSocket connection are deserialized into this enum. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "event_type")] +pub enum WsMessage { + /// Full or incremental orderbook update + #[serde(rename = "book")] + Book(BookUpdate), + /// Price change notification + #[serde(rename = "price_change")] + PriceChange(PriceChange), + /// Tick size change notification + #[serde(rename = "tick_size_change")] + TickSizeChange(TickSizeChange), + /// Last trade price update + #[serde(rename = "last_trade_price")] + LastTradePrice(LastTradePrice), + /// Best bid/ask update (requires `custom_feature_enabled`) + #[serde(rename = "best_bid_ask")] + BestBidAsk(BestBidAsk), + /// New market created (requires `custom_feature_enabled`) + #[serde(rename = "new_market")] + NewMarket(NewMarket), + /// Market resolved (requires `custom_feature_enabled`) + #[serde(rename = "market_resolved")] + MarketResolved(MarketResolved), + /// User trade execution (authenticated channel) + #[serde(rename = "trade")] + Trade(TradeMessage), + /// User order update (authenticated channel) + #[serde(rename = "order")] + Order(OrderMessage), +} + +impl WsMessage { + /// Check if the message is a user-specific message. + #[must_use] + pub const fn is_user(&self) -> bool { + matches!(self, WsMessage::Trade(_) | WsMessage::Order(_)) + } + + /// Check if the message is a market data message. + #[must_use] + pub const fn is_market(&self) -> bool { + !self.is_user() + } +} + +/// Orderbook update message (full snapshot or delta). +/// +/// When first subscribing or when trades occur, this message contains the current +/// state of the orderbook with bids and asks arrays. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct BookUpdate { + /// Asset/token identifier + pub asset_id: U256, + /// Market condition ID + pub market: B256, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, + /// Current bid levels (price descending) + #[serde(default)] + pub bids: Vec, + /// Current ask levels (price ascending) + #[serde(default)] + pub asks: Vec, + /// Hash for orderbook validation + pub hash: Option, +} + +/// Individual price level in an orderbook. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct OrderBookLevel { + /// Price at this level + pub price: Decimal, + /// Total size available at this price + pub size: Decimal, +} + +/// Unified wire format for `price_change` events. +/// +/// The server sends either a single price change or a batch. This struct captures both shapes. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct PriceChange { + /// Market condition ID + pub market: B256, + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, + #[serde(default)] + pub price_changes: Vec, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct PriceChangeBatchEntry { + /// Asset/token identifier + pub asset_id: U256, + /// New price + pub price: Decimal, + /// Total size affected by this price change (if provided) + #[serde(default)] + pub size: Option, + /// Side of the price change (BUY or SELL) + pub side: Side, + /// Hash for validation (if present) + #[serde(default)] + pub hash: Option, + /// Best bid price after this change + #[serde(default)] + pub best_bid: Option, + /// Best ask price after this change + #[serde(default)] + pub best_ask: Option, +} + +/// Tick size change event (triggered when price crosses thresholds). +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct TickSizeChange { + /// Asset/token identifier + pub asset_id: U256, + /// Market condition ID + pub market: B256, + /// Previous tick size + pub old_tick_size: Decimal, + /// New tick size + pub new_tick_size: Decimal, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// Last trade price update. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct LastTradePrice { + /// Asset/token identifier + pub asset_id: U256, + /// Market condition ID + pub market: B256, + /// Last trade price + pub price: Decimal, + /// Side of the last trade + pub side: Option, + /// Size of the last trade + pub size: Option, + /// Fee rate in basis points + pub fee_rate_bps: Option, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// Best bid/ask update (requires `custom_feature_enabled` flag). +/// +/// Emitted when the best bid and ask prices for a market change. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct BestBidAsk { + /// Market condition ID + pub market: B256, + /// Asset/token identifier + pub asset_id: U256, + /// Current best bid price + pub best_bid: Decimal, + /// Current best ask price + pub best_ask: Decimal, + /// Spread between best bid and ask + pub spread: Decimal, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// New market created event (requires `custom_feature_enabled` flag). +/// +/// Emitted when a new market is created. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct NewMarket { + /// Market ID + pub id: String, + /// Market question + pub question: String, + /// Market condition ID + pub market: B256, + /// Market slug + pub slug: String, + /// Market description + pub description: String, + /// List of asset IDs + #[serde(rename = "assets_ids", alias = "asset_ids")] + pub asset_ids: Vec, + /// List of outcomes (e.g., `["Yes", "No"]`) + pub outcomes: Vec, + /// Event message object + #[serde(default)] + pub event_message: Option, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// Market resolved event (requires `custom_feature_enabled` flag). +/// +/// Emitted when a market is resolved. +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct MarketResolved { + /// Market ID + pub id: String, + /// Market question + #[serde(default)] + pub question: Option, + /// Market condition ID + pub market: B256, + /// Market slug + #[serde(default)] + pub slug: Option, + /// Market description + #[serde(default)] + pub description: Option, + /// List of asset IDs + #[serde(rename = "assets_ids", alias = "asset_ids")] + pub asset_ids: Vec, + /// List of outcomes (e.g., `["Yes", "No"]`) + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub outcomes: Vec, + /// Winning asset ID + pub winning_asset_id: U256, + /// Winning outcome (e.g., "Yes" or "No") + pub winning_outcome: String, + /// Event message object + #[serde(default)] + pub event_message: Option, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// Event message object for market events. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct EventMessage { + /// Event message ID + pub id: String, + /// Event message ticker + pub ticker: String, + /// Event message slug + pub slug: String, + /// Event message title + pub title: String, + /// Event message description + pub description: String, +} + +/// Maker order details within a trade message. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct MakerOrder { + /// Asset/token identifier of the maker order + pub asset_id: U256, + /// Amount of maker order matched in trade + pub matched_amount: Decimal, + /// Maker order ID + pub order_id: String, + /// Outcome (Yes/No) + pub outcome: String, + /// Owner (API key) of maker order + pub owner: ApiKey, + /// Price of maker order + pub price: Decimal, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub enum TradeMessageType { + #[serde(alias = "trade", alias = "TRADE")] + Trade, + #[serde(untagged)] + Unknown(String), +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub enum TradeMessageStatus { + #[serde(alias = "matched", alias = "MATCHED")] + Matched, + #[serde(alias = "mined", alias = "MINED")] + Mined, + #[serde(alias = "confirmed", alias = "CONFIRMED")] + Confirmed, + #[serde(untagged)] + Unknown(String), +} + +/// User trade execution message (authenticated channel only). +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct TradeMessage { + /// Trade identifier + pub id: String, + /// Market condition ID + pub market: B256, + /// Asset/token identifier + pub asset_id: U256, + /// Side of the trade + pub side: Side, + /// Size of the trade + pub size: Decimal, + /// Execution price + pub price: Decimal, + /// Trade status + pub status: TradeMessageStatus, + /// Message type + #[serde(rename = "type", default)] + pub msg_type: Option, + /// Timestamp of last trade modification + #[serde(default)] + #[serde_as(as = "Option")] + pub last_update: Option, + /// Time trade was matched + #[serde(default, alias = "match_time")] + #[serde_as(as = "Option")] + pub matchtime: Option, + /// Unix timestamp of event + #[serde(default)] + #[serde_as(as = "Option")] + pub timestamp: Option, + /// Outcome (Yes/No) + #[serde(default)] + pub outcome: Option, + /// API key of event owner + #[serde(default)] + pub owner: Option, + /// API key of trade owner + #[serde(default)] + pub trade_owner: Option, + /// ID of taker order + #[serde(default)] + pub taker_order_id: Option, + /// Array of maker order details + #[serde(default)] + pub maker_orders: Vec, + /// Fee rate in basis points (string in API response) + #[serde(default)] + pub fee_rate_bps: Option, + /// On-chain transaction hash + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub transaction_hash: Option, + /// Whether user was maker or taker + #[serde(default)] + pub trader_side: Option, +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub enum OrderMessageType { + #[serde(alias = "placement", alias = "PLACEMENT")] + Placement, + #[serde(alias = "update", alias = "UPDATE")] + Update, + #[serde(alias = "cancellation", alias = "CANCELLATION")] + Cancellation, + #[serde(untagged)] + Unknown(String), +} + +/// User order update message (authenticated channel only). +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct OrderMessage { + /// Order identifier + pub id: String, + /// Market condition ID + pub market: B256, + /// Asset/token identifier + pub asset_id: U256, + /// Side of the order (BUY or SELL) + pub side: Side, + /// Order price + pub price: Decimal, + /// Message type + #[serde(rename = "type", default)] + pub msg_type: Option, + /// Outcome (Yes/No) + #[serde(default)] + pub outcome: Option, + /// Owner (API key) + #[serde(default)] + pub owner: Option, + /// Order owner (API key of order originator) + #[serde(default)] + pub order_owner: Option, + /// Original order size + #[serde(default)] + pub original_size: Option, + /// Amount matched so far + #[serde(default)] + pub size_matched: Option, + /// Unix timestamp of event + #[serde(default)] + #[serde_as(as = "Option")] + pub timestamp: Option, + /// Associated trade IDs + #[serde(default)] + pub associate_trades: Option>, + /// Order status + #[serde(default)] + pub status: Option, +} + +/// Order status for WebSocket order messages. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum OrderStatus { + /// Order is open and active + Open, + /// Order has been matched with a counterparty + Matched, + /// Order has been partially filled + PartiallyFilled, + /// Order has been cancelled + Cancelled, + /// Order has been placed (initial status) + Placement, + /// Order update (partial match) + Update, + /// Order cancellation in progress + Cancellation, + /// Unknown order status from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +/// Calculated midpoint update (derived from orderbook). +#[non_exhaustive] +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct MidpointUpdate { + /// Asset/token identifier + pub asset_id: U256, + /// Market condition ID + pub market: B256, + /// Calculated midpoint price + pub midpoint: Decimal, + /// Unix timestamp in milliseconds + #[serde_as(as = "DisplayFromStr")] + pub timestamp: i64, +} + +/// Deserialize messages from the byte slice, filtering by interest. +/// +/// For single objects, the JSON is parsed once into a `Value`, then the `event_type` is +/// extracted to check interest before final deserialization via `from_value()`. +/// This avoids re-parsing the JSON text twice. +/// +/// For arrays, messages are processed one-by-one with tolerant parsing: unknown or invalid +/// event types are skipped rather than causing the entire batch to fail. +pub fn parse_if_interested( + bytes: &[u8], + interest: &MessageInterest, +) -> crate::Result> { + // Parse JSON once into Value + let value: Value = serde_json::from_slice(bytes) + .map_err(|err| crate::error::Error::with_source(Kind::Internal, Box::new(err)))?; + + match &value { + Value::Object(map) => { + // Single message: check event_type before full deserialization + let event_type = map.get("event_type").and_then(Value::as_str); + + match event_type { + None => Ok(vec![]), + Some(event_type) if !interest.is_interested_in_event(event_type) => Ok(vec![]), + Some(_) => { + // Interested: deserialize from cached Value (no re-parsing) + let msg: WsMessage = serde_json::from_value(value)?; + Ok(vec![msg]) + } + } + } + Value::Array(arr) => Ok(arr + .iter() + .filter_map(|elem| { + let obj = elem.as_object()?; + let event_type = obj.get("event_type").and_then(Value::as_str)?; + + if !interest.is_interested_in_event(event_type) { + return None; + } + + serde_json::from_value(elem.clone()) + .inspect_err(|err| { + #[cfg(feature = "tracing")] + warn!( + event_type = %event_type, + error = %err, + "Skipping unknown/invalid WS event in batch" + ); + }) + .ok() + }) + .collect()), + _ => Ok(vec![]), + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use rust_decimal_macros::dec; + + use super::*; + use crate::types::b256; + + // Test market condition ID + const TEST_MARKET: B256 = + b256!("0000000000000000000000000000000000000000000000000000000000000001"); + + fn matches_interest(msg: &WsMessage, interest: MessageInterest) -> bool { + match msg { + WsMessage::Book(_) => interest.contains(MessageInterest::BOOK), + WsMessage::PriceChange(_) => interest.contains(MessageInterest::PRICE_CHANGE), + WsMessage::TickSizeChange(_) => interest.contains(MessageInterest::TICK_SIZE), + WsMessage::LastTradePrice(_) => interest.contains(MessageInterest::LAST_TRADE_PRICE), + WsMessage::BestBidAsk(_) => interest.contains(MessageInterest::BEST_BID_ASK), + WsMessage::NewMarket(_) => interest.contains(MessageInterest::NEW_MARKET), + WsMessage::MarketResolved(_) => interest.contains(MessageInterest::MARKET_RESOLVED), + WsMessage::Trade(_) => interest.contains(MessageInterest::TRADE), + WsMessage::Order(_) => interest.contains(MessageInterest::ORDER), + } + } + + #[test] + fn parse_book_message() { + let json = r#"{ + "event_type": "book", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890", + "bids": [{"price": "0.5", "size": "100"}], + "asks": [{"price": "0.51", "size": "50"}] + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::Book(book) => { + assert_eq!(book.asset_id, U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()); + assert_eq!(book.market, TEST_MARKET); + assert_eq!(book.bids.len(), 1); + assert_eq!(book.asks.len(), 1); + } + _ => panic!("Expected Book message"), + } + } + + #[test] + fn parse_price_change_message() { + let json = r#"{ + "event_type": "price_change", + "market": "0x0000000000000000000000000000000000000000000000000000000000000002", + "timestamp": "1234567890", + "price_changes": [{ + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "price": "0.52", + "size": "10", + "side": "BUY" + }] + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::PriceChange(price) => { + let changes = &price.price_changes[0]; + + assert_eq!(changes.asset_id, U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()); + assert_eq!(changes.side, Side::Buy); + assert_eq!(changes.size.unwrap(), Decimal::TEN); + } + _ => panic!("Expected PriceChange message"), + } + } + + #[test] + fn parse_price_change_interest_message() { + let json = r#"{ + "event_type": "price_change", + "market": "0x0000000000000000000000000000000000000000000000000000000000000003", + "timestamp": "1234567890", + "price_changes": [ + { + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "price": "0.10", + "side": "BUY", + "hash": "abc", + "best_bid": "0.11", + "best_ask": "0.12" + }, + { + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "price": "0.90", + "size": "5", + "side": "SELL" + } + ] + }"#; + + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::ALL).unwrap(); + assert_eq!(msgs.len(), 1); + + match &msgs[0] { + WsMessage::PriceChange(price) => { + let expected = + b256!("0000000000000000000000000000000000000000000000000000000000000003"); + assert_eq!(price.market, expected); + + let changes = &price.price_changes; + assert_eq!(changes.len(), 2); + + assert_eq!(changes[0].asset_id, U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()); + assert_eq!(changes[0].best_bid, Some(dec!(0.11))); + assert_eq!(changes[0].price, dec!(0.10)); + assert!(changes[0].size.is_none()); + + assert_eq!(changes[1].asset_id, U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()); + assert_eq!(changes[1].best_bid, None); + assert_eq!(changes[1].size, Some(dec!(5))); + assert_eq!(changes[1].price, dec!(0.90)); + } + _ => panic!("Expected first price change"), + } + } + + #[test] + fn parse_batch_messages() { + let json = r#"[ + { + "event_type": "book", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890", + "bids": [{"price": "0.5", "size": "100"}], + "asks": [] + }, + { + "event_type": "price_change", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567891", + "price_changes": [{ + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "price": "0.51", + "side": "BUY" + }] + }, + { + "event_type": "last_trade_price", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "price": "0.6", + "timestamp": "1234567892" + } + ]"#; + + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::ALL).unwrap(); + assert_eq!(msgs.len(), 3); + + assert!( + matches!(&msgs[0], WsMessage::Book(b) if b.asset_id == U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()) + ); + assert!(matches!(&msgs[1], WsMessage::PriceChange(p) if p.market == TEST_MARKET)); + assert!( + matches!(&msgs[2], WsMessage::LastTradePrice(l) if l.asset_id == U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap()) + ); + } + + #[test] + fn parse_batch_filters_by_interest() { + let json = r#"[ + { + "event_type": "book", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890", + "bids": [], + "asks": [] + }, + { + "event_type": "trade", + "id": "trade1", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "side": "BUY", + "size": "10", + "price": "0.5", + "status": "MATCHED" + } + ]"#; + + // Only interested in BOOK, not TRADE + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::BOOK).unwrap(); + assert_eq!(msgs.len(), 1); + assert!(matches!(&msgs[0], WsMessage::Book(_))); + + // Only interested in TRADE, not BOOK + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::TRADE).unwrap(); + assert_eq!(msgs.len(), 1); + assert!(matches!(&msgs[0], WsMessage::Trade(_))); + + // Interested in both + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::ALL).unwrap(); + assert_eq!(msgs.len(), 2); + } + + #[test] + fn parse_best_bid_ask_message() { + let json = r#"{ + "event_type": "best_bid_ask", + "market": "0x0005c0d312de0be897668695bae9f32b624b4a1ae8b140c49f08447fcc74f442", + "asset_id": "85354956062430465315924116860125388538595433819574542752031640332592237464430", + "best_bid": "0.73", + "best_ask": "0.77", + "spread": "0.04", + "timestamp": "1766789469958" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::BestBidAsk(bba) => { + assert_eq!(bba.best_bid, dec!(0.73)); + assert_eq!(bba.best_ask, dec!(0.77)); + assert_eq!(bba.spread, dec!(0.04)); + } + _ => panic!("Expected BestBidAsk message"), + } + } + + #[test] + fn parse_new_market_message() { + let json = r#"{ + "id": "1031769", + "question": "Will NVIDIA (NVDA) close above $240 end of January?", + "market": "0x311d0c4b6671ab54af4970c06fcf58662516f5168997bdda209ec3db5aa6b0c1", + "slug": "nvda-above-240-on-january-30-2026", + "description": "This market will resolve to Yes or No.", + "assets_ids": [ + "76043073756653678226373981964075571318267289248134717369284518995922789326425", + "31690934263385727664202099278545688007799199447969475608906331829650099442770" + ], + "outcomes": ["Yes", "No"], + "event_message": { + "id": "125819", + "ticker": "nvda-above-in-january-2026", + "slug": "nvda-above-in-january-2026", + "title": "Will NVIDIA (NVDA) close above ___ end of January?", + "description": "Market description" + }, + "timestamp": "1766790415550", + "event_type": "new_market" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::NewMarket(nm) => { + assert_eq!(nm.id, "1031769"); + assert_eq!( + nm.question, + "Will NVIDIA (NVDA) close above $240 end of January?" + ); + assert_eq!(nm.asset_ids.len(), 2); + assert_eq!(nm.outcomes, vec!["Yes", "No"]); + assert!(nm.event_message.is_some()); + let event = nm.event_message.unwrap(); + assert_eq!(event.id, "125819"); + assert_eq!(event.ticker, "nvda-above-in-january-2026"); + } + _ => panic!("Expected NewMarket message"), + } + } + + #[test] + fn parse_market_resolved_message() { + let json = r#"{ + "id": "1031769", + "question": "Will NVIDIA (NVDA) close above $240 end of January?", + "market": "0x311d0c4b6671ab54af4970c06fcf58662516f5168997bdda209ec3db5aa6b0c1", + "slug": "nvda-above-240-on-january-30-2026", + "description": "This market will resolve to Yes or No.", + "assets_ids": [ + "76043073756653678226373981964075571318267289248134717369284518995922789326425", + "31690934263385727664202099278545688007799199447969475608906331829650099442770" + ], + "outcomes": ["Yes", "No"], + "winning_asset_id": "76043073756653678226373981964075571318267289248134717369284518995922789326425", + "winning_outcome": "Yes", + "event_message": { + "id": "125819", + "ticker": "nvda-above-in-january-2026", + "slug": "nvda-above-in-january-2026", + "title": "Will NVIDIA (NVDA) close above ___ end of January?", + "description": "Market description" + }, + "timestamp": "1766790415550", + "event_type": "market_resolved" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::MarketResolved(mr) => { + assert_eq!(mr.id, "1031769"); + assert_eq!(mr.winning_outcome, "Yes"); + assert_eq!( + mr.winning_asset_id, + U256::from_str("76043073756653678226373981964075571318267289248134717369284518995922789326425").unwrap() + ); + assert_eq!(mr.asset_ids.len(), 2); + } + _ => panic!("Expected MarketResolved message"), + } + } + + #[test] + fn parse_last_trade_price_with_new_fields() { + let json = r#"{ + "asset_id": "114122071509644379678018727908709560226618148003371446110114509806601493071694", + "event_type": "last_trade_price", + "fee_rate_bps": "0", + "market": "0x6a67b9d828d53862160e470329ffea5246f338ecfffdf2cab45211ec578b0347", + "price": "0.456", + "side": "BUY", + "size": "219.217767", + "timestamp": "1750428146322" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::LastTradePrice(ltp) => { + assert_eq!(ltp.price, dec!(0.456)); + assert_eq!(ltp.size, Some(dec!(219.217767))); + assert_eq!(ltp.fee_rate_bps, Some(Decimal::ZERO)); + assert_eq!(ltp.side, Some(Side::Buy)); + } + _ => panic!("Expected LastTradePrice message"), + } + } + + #[test] + fn parse_custom_feature_messages_filter_by_interest() { + let json = r#"[ + { + "event_type": "best_bid_ask", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "best_bid": "0.5", + "best_ask": "0.6", + "spread": "0.1", + "timestamp": "1234567890" + }, + { + "event_type": "book", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890", + "bids": [], + "asks": [] + } + ]"#; + + // Only interested in BEST_BID_ASK + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::BEST_BID_ASK).unwrap(); + assert_eq!(msgs.len(), 1); + assert!(matches!(&msgs[0], WsMessage::BestBidAsk(_))); + + // Only interested in BOOK + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::BOOK).unwrap(); + assert_eq!(msgs.len(), 1); + assert!(matches!(&msgs[0], WsMessage::Book(_))); + + // MARKET includes both + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::MARKET).unwrap(); + assert_eq!(msgs.len(), 2); + } + + #[test] + fn parse_new_market_without_event_message() { + let json = r#"{ + "id": "1031769", + "question": "Will NVIDIA (NVDA) close above $240 end of January?", + "market": "0x311d0c4b6671ab54af4970c06fcf58662516f5168997bdda209ec3db5aa6b0c1", + "slug": "nvda-above-240-on-january-30-2026", + "description": "This market will resolve to Yes or No.", + "assets_ids": ["106585164761922456203746651621390029417453862034640469075081961934906147433548", "106585164761922456203746651621390029417453862034640469075081961934906147433548"], + "outcomes": ["Yes", "No"], + "timestamp": "1766790415550", + "event_type": "new_market" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::NewMarket(nm) => { + assert_eq!(nm.id, "1031769"); + assert!(nm.event_message.is_none()); + } + _ => panic!("Expected NewMarket message"), + } + } + + #[test] + fn parse_market_resolved_without_event_message() { + let json = r#"{ + "id": "1031769", + "question": "Will NVIDIA (NVDA) close above $240 end of January?", + "market": "0x311d0c4b6671ab54af4970c06fcf58662516f5168997bdda209ec3db5aa6b0c1", + "slug": "nvda-above-240-on-january-30-2026", + "description": "This market will resolve to Yes or No.", + "assets_ids": ["106585164761922456203746651621390029417453862034640469075081961934906147433548", "106585164761922456203746651621390029417453862034640469075081961934906147433548"], + "outcomes": ["Yes", "No"], + "winning_asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "winning_outcome": "Yes", + "timestamp": "1766790415550", + "event_type": "market_resolved" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::MarketResolved(mr) => { + assert_eq!(mr.id, "1031769"); + assert!(mr.event_message.is_none()); + assert_eq!(mr.winning_outcome, "Yes"); + } + _ => panic!("Expected MarketResolved message"), + } + } + + #[test] + fn parse_last_trade_price_without_optional_fields() { + let json = r#"{ + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "event_type": "last_trade_price", + "market": "0x0000000000000000000000000000000000000000000000000000000000000123", + "price": "0.5", + "timestamp": "1750428146322" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::LastTradePrice(ltp) => { + assert_eq!(ltp.price, dec!(0.5)); + assert!(ltp.size.is_none()); + assert!(ltp.fee_rate_bps.is_none()); + assert!(ltp.side.is_none()); + } + _ => panic!("Expected LastTradePrice message"), + } + } + + #[test] + fn matches_interest_custom_feature_messages() { + let bba = WsMessage::BestBidAsk(BestBidAsk { + market: TEST_MARKET, + asset_id: U256::from_str( + "106585164761922456203746651621390029417453862034640469075081961934906147433548", + ) + .unwrap(), + best_bid: dec!(0.5), + best_ask: dec!(0.6), + spread: dec!(0.1), + timestamp: 0, + }); + assert!(matches_interest(&bba, MessageInterest::BEST_BID_ASK)); + assert!(!matches_interest(&bba, MessageInterest::BOOK)); + assert!(matches_interest(&bba, MessageInterest::MARKET)); + + let nm = WsMessage::NewMarket(NewMarket { + id: "1".to_owned(), + question: "q".to_owned(), + market: TEST_MARKET, + slug: "s".to_owned(), + description: "d".to_owned(), + asset_ids: vec![], + outcomes: vec![], + event_message: None, + timestamp: 0, + }); + assert!(matches_interest(&nm, MessageInterest::NEW_MARKET)); + assert!(matches_interest(&nm, MessageInterest::MARKET)); + + let mr = WsMessage::MarketResolved(MarketResolved { + id: "1".to_owned(), + question: Some("q".to_owned()), + market: TEST_MARKET, + slug: Some("s".to_owned()), + description: Some("d".to_owned()), + asset_ids: vec![], + outcomes: vec![], + winning_asset_id: U256::from_str( + "106585164761922456203746651621390029417453862034640469075081961934906147433548", + ) + .unwrap(), + winning_outcome: "Yes".to_owned(), + event_message: None, + timestamp: 0, + }); + assert!(matches_interest(&mr, MessageInterest::MARKET_RESOLVED)); + assert!(matches_interest(&mr, MessageInterest::MARKET)); + } + + #[test] + fn parse_if_interested_returns_empty_for_missing_event_type() { + // Object without event_type field + let json = r#"{"some_field": "value"}"#; + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::ALL).unwrap(); + assert!(msgs.is_empty()); + } + + #[test] + fn parse_if_interested_returns_empty_for_primitive_json() { + // JSON primitives (not object or array) should return empty + let msgs = parse_if_interested(b"null", &MessageInterest::ALL).unwrap(); + assert!(msgs.is_empty()); + + let msgs = parse_if_interested(b"42", &MessageInterest::ALL).unwrap(); + assert!(msgs.is_empty()); + + let msgs = parse_if_interested(b"\"string\"", &MessageInterest::ALL).unwrap(); + assert!(msgs.is_empty()); + + let msgs = parse_if_interested(b"true", &MessageInterest::ALL).unwrap(); + assert!(msgs.is_empty()); + } + + // New test: Batch with mixed known + unknown event_type + #[test] + fn parse_batch_with_unknown_event_type() { + let json = r#"[ + { + "event_type": "book", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "1234567890", + "bids": [{"price": "0.5", "size": "100"}], + "asks": [] + }, + { + "event_type": "SOME_NEW_EVENT", + "unknown_field": "arbitrary data", + "another_field": 123 + } + ]"#; + + let msgs = parse_if_interested(json.as_bytes(), &MessageInterest::ALL).unwrap(); + // Should successfully parse the known message and skip the unknown one + assert_eq!(msgs.len(), 1); + assert!(matches!(&msgs[0], WsMessage::Book(_))); + } + + // New test: TradeMessageType Unknown variant + #[test] + fn parse_trade_message_with_unknown_type() { + let json = r#"{ + "event_type": "trade", + "id": "trade123", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "side": "BUY", + "size": "10", + "price": "0.5", + "status": "MATCHED", + "type": "NEW_TYPE" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::Trade(trade) => { + assert_eq!(trade.id, "trade123"); + assert_eq!( + trade.msg_type, + Some(TradeMessageType::Unknown("NEW_TYPE".to_owned())) + ); + } + _ => panic!("Expected Trade message"), + } + } + + // New test: Test asset_ids alias + #[test] + fn parse_new_market_with_asset_ids_alias() { + let json = r#"{ + "id": "test123", + "question": "Test question?", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "slug": "test-slug", + "description": "Test description", + "asset_ids": [ + "106585164761922456203746651621390029417453862034640469075081961934906147433548" + ], + "outcomes": ["Yes", "No"], + "timestamp": "1234567890", + "event_type": "new_market" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::NewMarket(nm) => { + assert_eq!(nm.id, "test123"); + assert_eq!(nm.asset_ids.len(), 1); + assert_eq!( + nm.asset_ids[0], + U256::from_str("106585164761922456203746651621390029417453862034640469075081961934906147433548").unwrap() + ); + } + _ => panic!("Expected NewMarket message"), + } + } + + #[test] + fn parse_market_resolved_with_asset_ids_alias() { + let json = r#"{ + "id": "test123", + "question": "Test question?", + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "slug": "test-slug", + "description": "Test description", + "asset_ids": [ + "106585164761922456203746651621390029417453862034640469075081961934906147433548" + ], + "outcomes": ["Yes", "No"], + "winning_asset_id": "106585164761922456203746651621390029417453862034640469075081961934906147433548", + "winning_outcome": "Yes", + "timestamp": "1234567890", + "event_type": "market_resolved" + }"#; + + let msg: WsMessage = serde_json::from_str(json).unwrap(); + match msg { + WsMessage::MarketResolved(mr) => { + assert_eq!(mr.id, "test123"); + assert_eq!(mr.asset_ids.len(), 1); + } + _ => panic!("Expected MarketResolved message"), + } + } +} diff --git a/polymarket-client-sdk/src/ctf/client.rs b/polymarket-client-sdk/src/ctf/client.rs new file mode 100644 index 0000000..aae4d8e --- /dev/null +++ b/polymarket-client-sdk/src/ctf/client.rs @@ -0,0 +1,500 @@ +//! CTF (Conditional Token Framework) client for interacting with the Gnosis CTF contract. +//! +//! The CTF contract is deployed at `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` on Polygon. +//! +//! # Operations +//! +//! - **ID Calculation**: Compute condition IDs, collection IDs, and position IDs +//! - **Split**: Convert USDC collateral into outcome token pairs (YES/NO) +//! - **Merge**: Combine outcome token pairs back into USDC +//! - **Redeem**: Redeem winning outcome tokens after market resolution +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::ctf::Client; +//! use alloy::providers::ProviderBuilder; +//! +//! # async fn example() -> Result<(), Box> { +//! let provider = ProviderBuilder::new() +//! .connect("https://polygon-rpc.com") +//! .await?; +//! +//! let client = Client::new(provider, 137)?; +//! # Ok(()) +//! # } +//! ``` + +#![allow( + clippy::exhaustive_structs, + clippy::exhaustive_enums, + reason = "Alloy sol! macro generates code that triggers these lints" +)] + +use alloy::primitives::ChainId; +use alloy::providers::Provider; +use alloy::sol; + +use super::error::CtfError; +use super::types::{ + CollectionIdRequest, CollectionIdResponse, ConditionIdRequest, ConditionIdResponse, + MergePositionsRequest, MergePositionsResponse, PositionIdRequest, PositionIdResponse, + RedeemNegRiskRequest, RedeemNegRiskResponse, RedeemPositionsRequest, RedeemPositionsResponse, + SplitPositionRequest, SplitPositionResponse, +}; +use crate::{Result, contract_config}; + +// CTF (Conditional Token Framework) contract interface +// +// This interface is based on the Gnosis CTF contract. +// +// Source: https://github.com/gnosis/conditional-tokens-contracts +// Documentation: https://docs.polymarket.com/developers/CTF/overview +// +// Key functions implemented: +// - getConditionId, getCollectionId, getPositionId: Pure/view functions for ID calculations +// - splitPosition: Convert collateral into outcome tokens +// - mergePositions: Combine outcome tokens back into collateral +// - redeemPositions: Redeem winning tokens after resolution +// - prepareCondition: Initialize a new condition (included for completeness) +sol! { + #[sol(rpc)] + interface IConditionalTokens { + /// Prepares a condition by initializing it with an oracle, question hash, and outcome slot count. + function prepareCondition( + address oracle, + bytes32 questionId, + uint256 outcomeSlotCount + ) external; + + /// Calculates the condition ID from oracle, question hash, and outcome slot count. + function getConditionId( + address oracle, + bytes32 questionId, + uint256 outcomeSlotCount + ) external pure returns (bytes32); + + /// Calculates the collection ID from parent collection, condition ID, and index set. + function getCollectionId( + bytes32 parentCollectionId, + bytes32 conditionId, + uint256 indexSet + ) external view returns (bytes32); + + /// Calculates the position ID (ERC1155 token ID) from collateral token and collection ID. + function getPositionId( + address collateralToken, + bytes32 collectionId + ) external pure returns (uint256); + + /// Splits collateral into outcome tokens. + function splitPosition( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata partition, + uint256 amount + ) external; + + /// Merges outcome tokens back into collateral. + function mergePositions( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata partition, + uint256 amount + ) external; + + /// Redeems winning outcome tokens for collateral. + function redeemPositions( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata indexSets + ) external; + } + + #[sol(rpc)] + interface INegRiskAdapter { + /// Redeems positions from negative risk markets with specific amounts. + function redeemPositions( + bytes32 conditionId, + uint256[] calldata amounts + ) external; + } +} + +/// Client for interacting with the Conditional Token Framework contract. +/// +/// The CTF contract handles tokenization of market outcomes as ERC1155 tokens. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub struct Client { + contract: IConditionalTokens::IConditionalTokensInstance

, + neg_risk_adapter: Option>, + provider: P, +} + +impl Client

{ + /// Creates a new CTF client for the specified chain. + /// + /// # Arguments + /// + /// * `provider` - An alloy provider instance + /// * `chain_id` - The chain ID (137 for Polygon mainnet, 80002 for Amoy testnet) + /// + /// # Errors + /// + /// Returns an error if the contract configuration is not found for the given chain. + pub fn new(provider: P, chain_id: ChainId) -> Result { + let config = contract_config(chain_id, false).ok_or_else(|| { + CtfError::ContractCall(format!( + "CTF contract configuration not found for chain ID {chain_id}" + )) + })?; + + let contract = IConditionalTokens::new(config.conditional_tokens, provider.clone()); + + Ok(Self { + contract, + neg_risk_adapter: None, + provider, + }) + } + + /// Creates a new CTF client with `NegRisk` adapter support. + /// + /// Use this constructor when you need to work with negative risk markets. + /// + /// # Arguments + /// + /// * `provider` - An alloy provider instance + /// * `chain_id` - The chain ID (137 for Polygon mainnet, 80002 for Amoy testnet) + /// + /// # Errors + /// + /// Returns an error if the contract configuration is not found for the given chain, + /// or if the `NegRisk` adapter is not configured for the chain. + pub fn with_neg_risk(provider: P, chain_id: ChainId) -> Result { + let config = contract_config(chain_id, true).ok_or_else(|| { + CtfError::ContractCall(format!( + "NegRisk contract configuration not found for chain ID {chain_id}" + )) + })?; + + let contract = IConditionalTokens::new(config.conditional_tokens, provider.clone()); + + let neg_risk_adapter = config + .neg_risk_adapter + .map(|addr| INegRiskAdapter::new(addr, provider.clone())); + + Ok(Self { + contract, + neg_risk_adapter, + provider, + }) + } + + /// Calculates a condition ID. + /// + /// The condition ID is derived from the oracle address, question hash, and number of outcome slots. + /// + /// # Errors + /// + /// Returns an error if the contract call fails. + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + oracle = %request.oracle, + question_id = %request.question_id, + outcome_slot_count = %request.outcome_slot_count + )) + )] + pub async fn condition_id(&self, request: &ConditionIdRequest) -> Result { + let condition_id = self + .contract + .getConditionId( + request.oracle, + request.question_id, + request.outcome_slot_count, + ) + .call() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get condition ID: {e}")))?; + + Ok(ConditionIdResponse { condition_id }) + } + + /// Calculates a collection ID. + /// + /// Creates collection identifiers using parent collection, condition ID, and index set. + /// + /// # Errors + /// + /// Returns an error if the contract call fails. + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + parent_collection_id = %request.parent_collection_id, + condition_id = %request.condition_id, + index_set = %request.index_set + )) + )] + pub async fn collection_id( + &self, + request: &CollectionIdRequest, + ) -> Result { + let collection_id = self + .contract + .getCollectionId( + request.parent_collection_id, + request.condition_id, + request.index_set, + ) + .call() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get collection ID: {e}")))?; + + Ok(CollectionIdResponse { collection_id }) + } + + /// Calculates a position ID (ERC1155 token ID). + /// + /// Generates final token IDs from collateral token and collection ID. + /// + /// # Errors + /// + /// Returns an error if the contract call fails. + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + collateral_token = %request.collateral_token, + collection_id = %request.collection_id + )) + )] + pub async fn position_id(&self, request: &PositionIdRequest) -> Result { + let position_id = self + .contract + .getPositionId(request.collateral_token, request.collection_id) + .call() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get position ID: {e}")))?; + + Ok(PositionIdResponse { position_id }) + } + + /// Splits collateral into outcome tokens. + /// + /// Converts USDC collateral into matched outcome token pairs (YES/NO). + /// + /// # Errors + /// + /// Returns an error if: + /// - The transaction fails to send + /// - The transaction fails to be mined + /// - The wallet doesn't have sufficient collateral + /// - The condition hasn't been prepared + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + collateral_token = %request.collateral_token, + condition_id = %request.condition_id, + amount = %request.amount + )) + )] + pub async fn split_position( + &self, + request: &SplitPositionRequest, + ) -> Result { + let pending_tx = self + .contract + .splitPosition( + request.collateral_token, + request.parent_collection_id, + request.condition_id, + request.partition.clone(), + request.amount, + ) + .send() + .await + .map_err(|e| { + CtfError::ContractCall(format!("Failed to send split transaction: {e}")) + })?; + + let transaction_hash = *pending_tx.tx_hash(); + + let receipt = pending_tx + .get_receipt() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get split receipt: {e}")))?; + + Ok(SplitPositionResponse { + transaction_hash, + block_number: receipt.block_number.ok_or_else(|| { + CtfError::ContractCall("Block number not available in receipt".to_owned()) + })?, + }) + } + + /// Merges outcome tokens back into collateral. + /// + /// Combines matched outcome token pairs back into USDC. + /// + /// # Errors + /// + /// Returns an error if: + /// - The transaction fails to send + /// - The transaction fails to be mined + /// - The wallet doesn't have sufficient outcome tokens + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + collateral_token = %request.collateral_token, + condition_id = %request.condition_id, + amount = %request.amount + )) + )] + pub async fn merge_positions( + &self, + request: &MergePositionsRequest, + ) -> Result { + let pending_tx = self + .contract + .mergePositions( + request.collateral_token, + request.parent_collection_id, + request.condition_id, + request.partition.clone(), + request.amount, + ) + .send() + .await + .map_err(|e| { + CtfError::ContractCall(format!("Failed to send merge transaction: {e}")) + })?; + + let transaction_hash = *pending_tx.tx_hash(); + + let receipt = pending_tx + .get_receipt() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get merge receipt: {e}")))?; + + Ok(MergePositionsResponse { + transaction_hash, + block_number: receipt.block_number.ok_or_else(|| { + CtfError::ContractCall("Block number not available in receipt".to_owned()) + })?, + }) + } + + /// Redeems winning outcome tokens for collateral. + /// + /// After a condition is resolved, burns winning tokens to recover USDC. + /// + /// # Errors + /// + /// Returns an error if: + /// - The transaction fails to send + /// - The transaction fails to be mined + /// - The condition hasn't been resolved + /// - The wallet doesn't have the specified outcome tokens + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + collateral_token = %request.collateral_token, + condition_id = %request.condition_id + )) + )] + pub async fn redeem_positions( + &self, + request: &RedeemPositionsRequest, + ) -> Result { + let pending_tx = self + .contract + .redeemPositions( + request.collateral_token, + request.parent_collection_id, + request.condition_id, + request.index_sets.clone(), + ) + .send() + .await + .map_err(|e| { + CtfError::ContractCall(format!("Failed to send redeem transaction: {e}")) + })?; + + let transaction_hash = *pending_tx.tx_hash(); + + let receipt = pending_tx + .get_receipt() + .await + .map_err(|e| CtfError::ContractCall(format!("Failed to get redeem receipt: {e}")))?; + + Ok(RedeemPositionsResponse { + transaction_hash, + block_number: receipt.block_number.ok_or_else(|| { + CtfError::ContractCall("Block number not available in receipt".to_owned()) + })?, + }) + } + + /// Redeems positions from negative risk markets. + /// + /// This method uses the `NegRisk` adapter to redeem positions by specifying + /// the exact amounts of each outcome token to redeem. This is different from + /// the standard `redeem_positions` which uses index sets. + /// + /// # Errors + /// + /// Returns an error if: + /// - The client was not created with `with_neg_risk()` (adapter not available) + /// - The transaction fails to send + /// - The transaction fails to be mined + /// - The condition hasn't been resolved + /// - The wallet doesn't have the specified outcome token amounts + #[cfg_attr( + feature = "tracing", + tracing::instrument(level = "debug", skip(self), fields( + condition_id = %request.condition_id, + amounts_len = request.amounts.len() + )) + )] + pub async fn redeem_neg_risk( + &self, + request: &RedeemNegRiskRequest, + ) -> Result { + let adapter = self.neg_risk_adapter.as_ref().ok_or_else(|| { + CtfError::ContractCall( + "NegRisk adapter not available. Use Client::with_neg_risk() to enable NegRisk support".to_owned() + ) + })?; + + let pending_tx = adapter + .redeemPositions(request.condition_id, request.amounts.clone()) + .send() + .await + .map_err(|e| { + CtfError::ContractCall(format!("Failed to send NegRisk redeem transaction: {e}")) + })?; + + let transaction_hash = *pending_tx.tx_hash(); + + let receipt = pending_tx.get_receipt().await.map_err(|e| { + CtfError::ContractCall(format!("Failed to get NegRisk redeem receipt: {e}")) + })?; + + Ok(RedeemNegRiskResponse { + transaction_hash, + block_number: receipt.block_number.ok_or_else(|| { + CtfError::ContractCall("Block number not available in receipt".to_owned()) + })?, + }) + } + + /// Returns a reference to the underlying provider. + #[must_use] + pub const fn provider(&self) -> &P { + &self.provider + } +} diff --git a/polymarket-client-sdk/src/ctf/error.rs b/polymarket-client-sdk/src/ctf/error.rs new file mode 100644 index 0000000..7839250 --- /dev/null +++ b/polymarket-client-sdk/src/ctf/error.rs @@ -0,0 +1,27 @@ +//! CTF-specific error types. + +use std::error::Error as StdError; +use std::fmt; + +/// CTF-specific errors. +#[derive(Debug)] +pub enum CtfError { + /// Contract call failed + ContractCall(String), +} + +impl fmt::Display for CtfError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContractCall(msg) => write!(f, "CTF contract call failed: {msg}"), + } + } +} + +impl StdError for CtfError {} + +impl From for crate::error::Error { + fn from(err: CtfError) -> Self { + crate::error::Error::with_source(crate::error::Kind::Internal, err) + } +} diff --git a/polymarket-client-sdk/src/ctf/mod.rs b/polymarket-client-sdk/src/ctf/mod.rs new file mode 100644 index 0000000..fdb5295 --- /dev/null +++ b/polymarket-client-sdk/src/ctf/mod.rs @@ -0,0 +1,70 @@ +//! CTF (Conditional Token Framework) API client. +//! +//! **Feature flag:** `ctf` (required to use this module) +//! +//! The Conditional Token Framework is Gnosis's smart contract system that tokenizes +//! all Polymarket outcomes as binary ERC1155 tokens on Polygon. Each market has two +//! outcome tokens ("YES" and "NO") backed by USDC collateral. +//! +//! # Features +//! +//! - **ID Calculation**: Compute condition IDs, collection IDs, and position IDs +//! - **Splitting**: Convert USDC collateral into outcome token pairs (YES/NO) +//! - **Merging**: Combine outcome token pairs back into USDC +//! - **Redemption**: Redeem winning outcome tokens after market resolution +//! +//! # Example +//! +//! ```ignore +//! use polymarket_client_sdk::ctf::{Client, types::*}; +//! use polymarket_client_sdk::types::address; +//! use polymarket_client_sdk::POLYGON; +//! use alloy::providers::ProviderBuilder; +//! use alloy::primitives::{B256, U256}; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create a provider (requires a wallet for state-changing operations) +//! let provider = ProviderBuilder::new() +//! .connect("https://polygon-rpc.com") +//! .await?; +//! +//! let client = Client::new(provider, POLYGON)?; +//! +//! let condition_id_req = ConditionIdRequest::builder() +//! .oracle(address!("")) +//! .question_id(B256::default()) +//! .outcome_slot_count(U256::from(2)) +//! .build(); +//! +//! let condition_id = client.condition_id(&condition_id_req).await?; +//! println!("Condition ID: {}", condition_id.condition_id); +//! +//! // Split USDC into outcome tokens +//! let split_req = SplitPositionRequest::builder() +//! .collateral_token(address!("")) +//! .condition_id(condition_id.condition_id) +//! .partition(vec![U256::from(1), U256::from(2)]) +//! .amount(U256::from(1_000_000)) // 1 USDC (6 decimals) +//! .build(); +//! +//! let result = client.split_position(&split_req).await?; +//! println!("Split tx: {}", result.transaction_hash); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Contract Address +//! +//! - Polygon Mainnet: `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` +//! - Polygon Amoy Testnet: Available in contract configuration +//! +//! # Resources +//! +//! - [CTF Documentation](https://docs.polymarket.com/developers/CTF/overview) +//! - [Gnosis CTF Source Code](https://github.com/gnosis/conditional-tokens-contracts) + +pub mod client; +mod error; +pub mod types; + +pub use client::Client; diff --git a/polymarket-client-sdk/src/ctf/types/mod.rs b/polymarket-client-sdk/src/ctf/types/mod.rs new file mode 100644 index 0000000..e25274a --- /dev/null +++ b/polymarket-client-sdk/src/ctf/types/mod.rs @@ -0,0 +1,13 @@ +//! Types for CTF (Conditional Token Framework) operations. + +mod request; +mod response; + +pub use request::{ + BINARY_PARTITION, CollectionIdRequest, ConditionIdRequest, MergePositionsRequest, + PositionIdRequest, RedeemNegRiskRequest, RedeemPositionsRequest, SplitPositionRequest, +}; +pub use response::{ + CollectionIdResponse, ConditionIdResponse, MergePositionsResponse, PositionIdResponse, + RedeemNegRiskResponse, RedeemPositionsResponse, SplitPositionResponse, +}; diff --git a/polymarket-client-sdk/src/ctf/types/request.rs b/polymarket-client-sdk/src/ctf/types/request.rs new file mode 100644 index 0000000..457d211 --- /dev/null +++ b/polymarket-client-sdk/src/ctf/types/request.rs @@ -0,0 +1,208 @@ +//! Request types for CTF operations. + +use alloy::primitives::{B256, U256}; +use bon::Builder; + +use crate::types::Address; + +/// Standard partition for binary markets (YES/NO). +/// Index 1 (0b01) represents the first outcome (typically YES). +/// Index 2 (0b10) represents the second outcome (typically NO). +pub const BINARY_PARTITION: [u64; 2] = [1, 2]; + +/// Request to calculate a condition ID. +/// +/// The condition ID is derived from the oracle address, question hash, and number of outcome slots. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct ConditionIdRequest { + /// The oracle address that will report the outcome + pub oracle: Address, + /// Hash of the question being resolved + pub question_id: B256, + /// Number of outcome slots (typically 2 for binary markets) + pub outcome_slot_count: U256, +} + +/// Request to calculate a collection ID. +/// +/// Creates collection identifiers using parent collection, condition ID, and index set. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct CollectionIdRequest { + /// Parent collection ID (typically zero for top-level positions) + pub parent_collection_id: B256, + /// The condition ID + pub condition_id: B256, + /// Index set representing outcome slots (e.g., 0b01 = 1, 0b10 = 2) + pub index_set: U256, +} + +/// Request to calculate a position ID. +/// +/// Generates final ERC1155 token IDs from collateral token and collection ID. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct PositionIdRequest { + /// The collateral token address (e.g., USDC) + pub collateral_token: Address, + /// The collection ID + pub collection_id: B256, +} + +/// Request to split collateral into outcome tokens. +/// +/// Converts USDC collateral into matched outcome token pairs (YES/NO). +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct SplitPositionRequest { + /// The collateral token address (e.g., USDC) + pub collateral_token: Address, + /// Parent collection ID (typically zero for Polymarket) + #[builder(default)] + pub parent_collection_id: B256, + /// The condition ID to split on + pub condition_id: B256, + /// Array of disjoint index sets representing outcome slots. + /// For binary markets: [1, 2] where 1 = 0b01 (YES) and 2 = 0b10 (NO) + pub partition: Vec, + /// Amount of collateral to split + pub amount: U256, +} + +/// Request to merge outcome tokens back into collateral. +/// +/// Combines matched outcome token pairs back into USDC. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct MergePositionsRequest { + /// The collateral token address (e.g., USDC) + pub collateral_token: Address, + /// Parent collection ID (typically zero for Polymarket) + #[builder(default)] + pub parent_collection_id: B256, + /// The condition ID to merge on + pub condition_id: B256, + /// Array of disjoint index sets representing outcome slots. + /// For binary markets: [1, 2] where 1 = 0b01 (YES) and 2 = 0b10 (NO) + pub partition: Vec, + /// Amount of full sets to merge + pub amount: U256, +} + +/// Request to redeem winning outcome tokens for collateral. +/// +/// After a condition is resolved, burns winning tokens to recover USDC. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct RedeemPositionsRequest { + /// The collateral token address (e.g., USDC) + pub collateral_token: Address, + /// Parent collection ID (typically zero for Polymarket) + #[builder(default)] + pub parent_collection_id: B256, + /// The condition ID to redeem + pub condition_id: B256, + /// Array of disjoint index sets representing outcome slots to redeem + pub index_sets: Vec, +} + +/// Request to redeem positions using the `NegRisk` adapter. +/// +/// This is used for negative risk markets where redemption requires specifying +/// the amounts of each outcome token to redeem. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct RedeemNegRiskRequest { + /// The condition ID to redeem + pub condition_id: B256, + /// Array of amounts for each outcome token [yesAmount, noAmount] + /// For binary markets, this should have 2 elements + pub amounts: Vec, +} + +// Convenience methods for binary markets +impl SplitPositionRequest { + /// Creates a split request for a binary market (YES/NO). + /// + /// This is a convenience method that automatically uses the standard binary partition [1, 2]. + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::ctf::types::SplitPositionRequest; + /// # use polymarket_client_sdk::types::address; + /// # use alloy::primitives::{B256, U256}; + /// let request = SplitPositionRequest::for_binary_market( + /// address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), // USDC + /// B256::default(), + /// U256::from(1_000_000), // 1 USDC (6 decimals) + /// ); + /// ``` + #[must_use] + pub fn for_binary_market(collateral_token: Address, condition_id: B256, amount: U256) -> Self { + Self { + collateral_token, + parent_collection_id: B256::default(), + condition_id, + partition: BINARY_PARTITION.iter().map(|&i| U256::from(i)).collect(), + amount, + } + } +} + +impl MergePositionsRequest { + /// Creates a merge request for a binary market (YES/NO). + /// + /// This is a convenience method that automatically uses the standard binary partition [1, 2]. + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::ctf::types::MergePositionsRequest; + /// # use polymarket_client_sdk::types::address; + /// # use alloy::primitives::{B256, U256}; + /// let request = MergePositionsRequest::for_binary_market( + /// address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), // USDC + /// B256::default(), + /// U256::from(1_000_000), // 1 full set + /// ); + /// ``` + #[must_use] + pub fn for_binary_market(collateral_token: Address, condition_id: B256, amount: U256) -> Self { + Self { + collateral_token, + parent_collection_id: B256::default(), + condition_id, + partition: BINARY_PARTITION.iter().map(|&i| U256::from(i)).collect(), + amount, + } + } +} + +impl RedeemPositionsRequest { + /// Creates a redeem request for a binary market (YES/NO). + /// + /// This is a convenience method that automatically uses the standard binary index sets [1, 2]. + /// + /// # Example + /// + /// ```no_run + /// # use polymarket_client_sdk::ctf::types::RedeemPositionsRequest; + /// # use polymarket_client_sdk::types::address; + /// # use alloy::primitives::{B256, U256}; + /// let request = RedeemPositionsRequest::for_binary_market( + /// address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), // USDC + /// B256::default(), + /// ); + /// ``` + #[must_use] + pub fn for_binary_market(collateral_token: Address, condition_id: B256) -> Self { + Self { + collateral_token, + parent_collection_id: B256::default(), + condition_id, + index_sets: BINARY_PARTITION.iter().map(|&i| U256::from(i)).collect(), + } + } +} diff --git a/polymarket-client-sdk/src/ctf/types/response.rs b/polymarket-client-sdk/src/ctf/types/response.rs new file mode 100644 index 0000000..b20069f --- /dev/null +++ b/polymarket-client-sdk/src/ctf/types/response.rs @@ -0,0 +1,68 @@ +//! Response types for CTF operations. + +use alloy::primitives::{B256, U256}; +use bon::Builder; + +/// Response from calculating a condition ID. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct ConditionIdResponse { + /// The calculated condition ID + pub condition_id: B256, +} + +/// Response from calculating a collection ID. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct CollectionIdResponse { + /// The calculated collection ID + pub collection_id: B256, +} + +/// Response from calculating a position ID. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct PositionIdResponse { + /// The calculated position ID (ERC1155 token ID) + pub position_id: U256, +} + +/// Response from a split position transaction. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct SplitPositionResponse { + /// Transaction hash + pub transaction_hash: B256, + /// Block number where the transaction was mined + pub block_number: u64, +} + +/// Response from a merge positions transaction. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct MergePositionsResponse { + /// Transaction hash + pub transaction_hash: B256, + /// Block number where the transaction was mined + pub block_number: u64, +} + +/// Response from a redeem positions transaction. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct RedeemPositionsResponse { + /// Transaction hash + pub transaction_hash: B256, + /// Block number where the transaction was mined + pub block_number: u64, +} + +/// Response from a `NegRisk` redeem transaction. +#[non_exhaustive] +#[derive(Debug, Clone, Builder)] +pub struct RedeemNegRiskResponse { + /// Transaction hash + pub transaction_hash: B256, + /// Block number where the transaction was mined + pub block_number: u64, +} diff --git a/polymarket-client-sdk/src/data/client.rs b/polymarket-client-sdk/src/data/client.rs new file mode 100644 index 0000000..9a0d068 --- /dev/null +++ b/polymarket-client-sdk/src/data/client.rs @@ -0,0 +1,277 @@ +//! Client for the Polymarket Data API. +//! +//! This module provides an HTTP client for interacting with the Polymarket Data API, +//! which offers endpoints for querying user positions, trades, activity, and market data. +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::types::address; +//! use polymarket_client_sdk::data::{Client, types::request::PositionsRequest}; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = Client::default(); +//! +//! let request = PositionsRequest::builder() +//! .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +//! .build(); +//! +//! let positions = client.positions(&request).await?; +//! for position in positions { +//! println!("{}: {} tokens", position.title, position.size); +//! } +//! # Ok(()) +//! # } +//! ``` + +use reqwest::{ + Client as ReqwestClient, Method, + header::{HeaderMap, HeaderValue}, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use url::Url; + +use super::types::request::{ + ActivityRequest, BuilderLeaderboardRequest, BuilderVolumeRequest, ClosedPositionsRequest, + HoldersRequest, LiveVolumeRequest, OpenInterestRequest, PositionsRequest, TradedRequest, + TraderLeaderboardRequest, TradesRequest, ValueRequest, +}; +use super::types::response::{ + Activity, BuilderLeaderboardEntry, BuilderVolumeEntry, ClosedPosition, Health, LiveVolume, + MetaHolder, OpenInterest, Position, Trade, Traded, TraderLeaderboardEntry, Value, +}; +use crate::{Result, ToQueryParams as _}; + +/// HTTP client for the Polymarket Data API. +/// +/// Provides methods for querying user positions, trades, activity, market holders, +/// open interest, volume data, and leaderboards. +/// +/// # API Base URL +/// +/// The default API endpoint is `https://data-api.polymarket.com`. +/// +/// # Example +/// +/// ```no_run +/// use polymarket_client_sdk::data::Client; +/// +/// // Create client with default endpoint +/// let client = Client::default(); +/// +/// // Or with a custom endpoint +/// let client = Client::new("https://custom-api.example.com").unwrap(); +/// ``` +#[derive(Clone, Debug)] +pub struct Client { + host: Url, + client: ReqwestClient, +} + +impl Default for Client { + fn default() -> Self { + Client::new("https://data-api.polymarket.com") + .expect("Client with default endpoint should succeed") + } +} + +impl Client { + /// Creates a new Data API client with a custom host URL. + /// + /// # Arguments + /// + /// * `host` - The base URL for the Data API (e.g., `https://data-api.polymarket.com`). + /// + /// # Errors + /// + /// Returns an error if the URL is invalid or the HTTP client cannot be created. + pub fn new(host: &str) -> Result { + let mut headers = HeaderMap::new(); + + headers.insert("User-Agent", HeaderValue::from_static("rs_clob_client")); + headers.insert("Accept", HeaderValue::from_static("*/*")); + headers.insert("Connection", HeaderValue::from_static("keep-alive")); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + let client = ReqwestClient::builder().default_headers(headers).build()?; + + Ok(Self { + host: Url::parse(host)?, + client, + }) + } + + /// Returns the base URL of the API. + #[must_use] + pub fn host(&self) -> &Url { + &self.host + } + + async fn get( + &self, + path: &str, + req: &Req, + ) -> Result { + let query = req.query_params(None); + let request = self + .client + .request(Method::GET, format!("{}{path}{query}", self.host)) + .build()?; + crate::request(&self.client, request, None).await + } + + /// Performs a health check on the API. + /// + /// Returns "OK" when the API is healthy and operational. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn health(&self) -> Result { + self.get("", &()).await + } + + /// Fetches current (open) positions for a user. + /// + /// Positions represent holdings of outcome tokens in prediction markets. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn positions(&self, req: &PositionsRequest) -> Result> { + self.get("positions", req).await + } + + /// Fetches trade history for a user or markets. + /// + /// Trades represent executed orders where outcome tokens were bought or sold. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn trades(&self, req: &TradesRequest) -> Result> { + self.get("trades", req).await + } + + /// Fetches on-chain activity for a user. + /// + /// Returns various on-chain operations including trades, splits, merges, + /// redemptions, rewards, and conversions. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn activity(&self, req: &ActivityRequest) -> Result> { + self.get("activity", req).await + } + + /// Fetches top token holders for specified markets. + /// + /// Returns holders grouped by token (outcome) for each market. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn holders(&self, req: &HoldersRequest) -> Result> { + self.get("holders", req).await + } + + /// Fetches the total value of a user's positions. + /// + /// Optionally filtered by specific markets. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn value(&self, req: &ValueRequest) -> Result> { + self.get("value", req).await + } + + /// Fetches closed (historical) positions for a user. + /// + /// These are positions that have been fully sold or redeemed. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn closed_positions( + &self, + req: &ClosedPositionsRequest, + ) -> Result> { + self.get("closed-positions", req).await + } + + /// Fetches trader leaderboard rankings. + /// + /// Returns trader rankings filtered by category, time period, and ordering. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn leaderboard( + &self, + req: &TraderLeaderboardRequest, + ) -> Result> { + self.get("v1/leaderboard", req).await + } + + /// Fetches the total count of unique markets a user has traded. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn traded(&self, req: &TradedRequest) -> Result { + self.get("traded", req).await + } + + /// Fetches open interest for markets. + /// + /// Open interest represents the total value of outstanding positions in a market. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn open_interest(&self, req: &OpenInterestRequest) -> Result> { + self.get("oi", req).await + } + + /// Fetches live trading volume for an event. + /// + /// Includes total volume and per-market breakdown. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn live_volume(&self, req: &LiveVolumeRequest) -> Result> { + self.get("live-volume", req).await + } + + /// Fetches aggregated builder leaderboard rankings. + /// + /// Builders are third-party applications that integrate with Polymarket. + /// Returns one entry per builder with aggregated totals for the specified time period. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn builder_leaderboard( + &self, + req: &BuilderLeaderboardRequest, + ) -> Result> { + self.get("v1/builders/leaderboard", req).await + } + + /// Fetches daily time-series volume data for builders. + /// + /// Returns multiple entries per builder (one per day), each including a timestamp. + /// + /// # Errors + /// + /// Returns an error if the request fails or the API returns an error response. + pub async fn builder_volume( + &self, + req: &BuilderVolumeRequest, + ) -> Result> { + self.get("v1/builders/volume", req).await + } +} diff --git a/polymarket-client-sdk/src/data/mod.rs b/polymarket-client-sdk/src/data/mod.rs new file mode 100644 index 0000000..02fc177 --- /dev/null +++ b/polymarket-client-sdk/src/data/mod.rs @@ -0,0 +1,68 @@ +//! Polymarket Data API client and types. +//! +//! **Feature flag:** `data` (required to use this module) +//! +//! This module provides a client for interacting with the Polymarket Data API, +//! which offers HTTP endpoints for querying user positions, trades, activity, +//! market holders, open interest, volume data, and leaderboards. +//! +//! # Overview +//! +//! The Data API is a read-only HTTP API that provides access to Polymarket data. +//! It is separate from the CLOB (Central Limit Order Book) API which handles trading. +//! +//! ## Available Endpoints +//! +//! | Endpoint | Description | +//! |----------|-------------| +//! | `/` | Health check | +//! | `/positions` | Get current positions for a user | +//! | `/trades` | Get trades for a user or markets | +//! | `/activity` | Get on-chain activity for a user | +//! | `/holders` | Get top holders for markets | +//! | `/value` | Get total value of a user's positions | +//! | `/closed-positions` | Get closed positions for a user | +//! | `/traded` | Get total markets a user has traded | +//! | `/oi` | Get open interest for markets | +//! | `/live-volume` | Get live volume for an event | +//! | `/v1/leaderboard` | Get trader leaderboard rankings | +//! | `/v1/builders/leaderboard` | Get builder leaderboard rankings | +//! | `/v1/builders/volume` | Get daily builder volume time-series | +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::types::address; +//! use polymarket_client_sdk::data::{Client, types::request::PositionsRequest}; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create a client with the default endpoint +//! let client = Client::default(); +//! +//! // Build a request for user positions +//! let request = PositionsRequest::builder() +//! .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +//! .build(); +//! +//! // Fetch positions +//! let positions = client.positions(&request).await?; +//! +//! for position in positions { +//! println!("{}: {} tokens at ${:.2}", +//! position.title, +//! position.size, +//! position.current_value +//! ); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # API Base URL +//! +//! The default API endpoint is `https://data-api.polymarket.com`. + +pub mod client; +pub mod types; + +pub use client::Client; diff --git a/polymarket-client-sdk/src/data/types/mod.rs b/polymarket-client-sdk/src/data/types/mod.rs new file mode 100644 index 0000000..02e0187 --- /dev/null +++ b/polymarket-client-sdk/src/data/types/mod.rs @@ -0,0 +1,420 @@ +use std::fmt; + +use serde::de::StdError; +use serde::{Deserialize, Serialize}; +use serde_with::{StringWithSeparator, formats::CommaSeparator, serde_as}; + +use crate::types::{B256, Decimal}; + +pub mod request; +pub mod response; + +/// The side of a trade (buy or sell). +/// +/// Used to indicate whether a trade was a purchase or sale of outcome tokens. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum Side { + /// Buying outcome tokens (going long on an outcome). + Buy, + /// Selling outcome tokens (going short or closing a long position). + Sell, + /// Unknown side from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +/// The type of on-chain activity for a user. +/// +/// Activities represent various operations that users can perform on the Polymarket protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum ActivityType { + /// A trade (buy or sell) of outcome tokens. + Trade, + /// Splitting collateral into outcome token sets. + Split, + /// Merging outcome token sets back into collateral. + Merge, + /// Redeeming winning outcome tokens for collateral after market resolution. + Redeem, + /// Receiving a reward (e.g., liquidity mining rewards). + Reward, + /// Converting between token types. + Conversion, + /// Yield + Yield, + /// Maker rebate (fee rebate for providing liquidity). + MakerRebate, + /// Unknown activity type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +/// Sort criteria for position queries. +/// +/// Determines how positions are ordered in the response. Default is [`Tokens`](Self::Tokens). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[non_exhaustive] +pub enum PositionSortBy { + /// Sort by current value of the position. + #[serde(rename = "CURRENT")] + #[strum(serialize = "CURRENT")] + Current, + /// Sort by initial value (cost basis) of the position. + #[serde(rename = "INITIAL")] + #[strum(serialize = "INITIAL")] + Initial, + /// Sort by number of tokens held (default). + #[default] + #[serde(rename = "TOKENS")] + #[strum(serialize = "TOKENS")] + Tokens, + /// Sort by cash profit and loss. + #[serde(rename = "CASHPNL")] + #[strum(serialize = "CASHPNL")] + CashPnl, + /// Sort by percentage profit and loss. + #[serde(rename = "PERCENTPNL")] + #[strum(serialize = "PERCENTPNL")] + PercentPnl, + /// Sort alphabetically by market title. + #[serde(rename = "TITLE")] + #[strum(serialize = "TITLE")] + Title, + /// Sort by markets that are resolving soon. + #[serde(rename = "RESOLVING")] + #[strum(serialize = "RESOLVING")] + Resolving, + /// Sort by current market price. + #[serde(rename = "PRICE")] + #[strum(serialize = "PRICE")] + Price, + /// Sort by average entry price. + #[serde(rename = "AVGPRICE")] + #[strum(serialize = "AVGPRICE")] + AvgPrice, +} + +/// Sort criteria for closed position queries. +/// +/// Determines how closed positions are ordered in the response. Default is [`RealizedPnl`](Self::RealizedPnl). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[non_exhaustive] +pub enum ClosedPositionSortBy { + /// Sort by realized profit and loss (default). + #[default] + #[serde(rename = "REALIZEDPNL")] + #[strum(serialize = "REALIZEDPNL")] + RealizedPnl, + /// Sort alphabetically by market title. + #[serde(rename = "TITLE")] + #[strum(serialize = "TITLE")] + Title, + /// Sort by final market price. + #[serde(rename = "PRICE")] + #[strum(serialize = "PRICE")] + Price, + /// Sort by average entry price. + #[serde(rename = "AVGPRICE")] + #[strum(serialize = "AVGPRICE")] + AvgPrice, + /// Sort by timestamp when the position was closed. + #[serde(rename = "TIMESTAMP")] + #[strum(serialize = "TIMESTAMP")] + Timestamp, +} + +/// Sort criteria for activity queries. +/// +/// Determines how activity records are ordered in the response. Default is [`Timestamp`](Self::Timestamp). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum ActivitySortBy { + /// Sort by activity timestamp (default). + #[default] + Timestamp, + /// Sort by number of tokens involved in the activity. + Tokens, + /// Sort by cash (USDC) value of the activity. + Cash, +} + +/// Sort direction for query results. +/// +/// Default is [`Desc`](Self::Desc) (descending) for most endpoints. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum SortDirection { + /// Ascending order (smallest/earliest first). + Asc, + /// Descending order (largest/latest first, default). + #[default] + Desc, +} + +/// Filter type for trade queries. +/// +/// Used with `filterAmount` to filter trades by minimum value. +/// Both `filterType` and `filterAmount` must be provided together. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum FilterType { + /// Filter by USDC cash value. + Cash, + /// Filter by number of tokens. + Tokens, +} + +/// Time period for aggregating leaderboard and volume data. +/// +/// Default is [`Day`](Self::Day) for most endpoints. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum TimePeriod { + /// Last 24 hours (default). + #[default] + Day, + /// Last 7 days. + Week, + /// Last 30 days. + Month, + /// All time. + All, +} + +/// Market category for filtering trader leaderboard results. +/// +/// Default is [`Overall`](Self::Overall) which includes all categories. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum LeaderboardCategory { + /// All categories combined (default). + #[default] + Overall, + /// Politics and elections markets. + Politics, + /// Sports betting markets. + Sports, + /// Cryptocurrency markets. + Crypto, + /// Pop culture and entertainment markets. + Culture, + /// Social media mentions markets. + Mentions, + /// Weather prediction markets. + Weather, + /// Economic indicator markets. + Economics, + /// Technology markets. + Tech, + /// Financial markets. + Finance, +} + +/// Ordering criteria for trader leaderboard results. +/// +/// Default is [`Pnl`](Self::Pnl) (profit and loss). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display, +)] +#[serde(rename_all = "UPPERCASE")] +#[strum(serialize_all = "UPPERCASE")] +#[non_exhaustive] +pub enum LeaderboardOrderBy { + /// Order by profit and loss (default). + #[default] + Pnl, + /// Order by trading volume. + Vol, +} + +/// A filter for querying by markets or events. +/// +/// The API allows filtering by either condition IDs (markets) or event IDs, +/// but not both simultaneously. This enum enforces that mutual exclusivity. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::types::MarketFilter; +/// use polymarket_client_sdk::types::b256; +/// +/// // Filter by specific markets (condition IDs) +/// let by_markets = MarketFilter::markets([b256!("dd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917")]); +/// +/// // Or filter by events (which may contain multiple markets) +/// let by_events = MarketFilter::event_ids(["123".to_owned()]); +/// ``` +#[serde_as] +#[derive(Debug, Clone, Serialize)] +#[non_exhaustive] +pub enum MarketFilter { + /// Filter by condition IDs (market identifiers). + #[serde(rename = "market")] + Markets(#[serde_as(as = "StringWithSeparator::")] Vec), + /// Filter by event IDs (groups of related markets). + #[serde(rename = "eventId")] + EventIds(#[serde_as(as = "StringWithSeparator::")] Vec), +} + +impl MarketFilter { + /// Creates a filter for specific markets by their condition IDs. + #[must_use] + pub fn markets>(ids: I) -> Self { + Self::Markets(ids.into_iter().collect()) + } + + /// Creates a filter for all markets within the specified events. + #[must_use] + pub fn event_ids>(ids: I) -> Self { + Self::EventIds(ids.into_iter().collect()) + } +} + +/// Error type for bounded integer values that are out of range. +#[derive(Debug)] +#[non_exhaustive] +pub struct BoundedIntError { + /// The value that was out of range. + pub value: i32, + /// The minimum allowed value. + pub min: i32, + /// The maximum allowed value. + pub max: i32, + /// The name of the parameter. + pub param_name: &'static str, +} + +impl BoundedIntError { + /// Creates a new `BoundedIntError`. + #[must_use] + pub const fn new(value: i32, min: i32, max: i32, param_name: &'static str) -> Self { + Self { + value, + min, + max, + param_name, + } + } +} + +impl fmt::Display for BoundedIntError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} must be between {} and {} (got {})", + self.param_name, self.min, self.max, self.value + ) + } +} + +impl StdError for BoundedIntError {} + +/// A filter for minimum trade size. +/// +/// Used to filter trades by a minimum value, either in USDC (cash) or tokens. +/// Both `filter_type` and `filter_amount` must be provided together to the API. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::types::TradeFilter; +/// use rust_decimal_macros::dec; +/// +/// // Filter trades with at least $100 USDC value +/// let filter = TradeFilter::cash(dec!(100)).unwrap(); +/// +/// // Filter trades with at least 50 tokens +/// let filter = TradeFilter::tokens(dec!(50)).unwrap(); +/// ``` +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct TradeFilter { + /// The type of filter (cash or tokens). + pub filter_type: FilterType, + /// The minimum amount to filter by (must be >= 0). + pub filter_amount: Decimal, +} + +impl TradeFilter { + /// Creates a new trade filter with the specified type and amount. + /// + /// # Errors + /// + /// Returns [`TradeFilterError`] if the amount is negative. + pub fn new(filter_type: FilterType, filter_amount: Decimal) -> Result { + if filter_amount.is_sign_negative() { + return Err(TradeFilterError::NegativeAmount(filter_amount)); + } + Ok(Self { + filter_type, + filter_amount, + }) + } + + /// Creates a cash (USDC) value filter. + /// + /// # Errors + /// + /// Returns [`TradeFilterError`] if the amount is negative. + pub fn cash(amount: Decimal) -> Result { + Self::new(FilterType::Cash, amount) + } + + /// Creates a token quantity filter. + /// + /// # Errors + /// + /// Returns [`TradeFilterError`] if the amount is negative. + pub fn tokens(amount: Decimal) -> Result { + Self::new(FilterType::Tokens, amount) + } +} + +/// Error type for invalid trade filter values. +#[derive(Debug)] +#[non_exhaustive] +pub enum TradeFilterError { + /// The filter amount was negative. + NegativeAmount(Decimal), +} + +impl fmt::Display for TradeFilterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NegativeAmount(amount) => { + write!(f, "filter amount must be >= 0 (got {amount})") + } + } + } +} + +impl StdError for TradeFilterError {} diff --git a/polymarket-client-sdk/src/data/types/request.rs b/polymarket-client-sdk/src/data/types/request.rs new file mode 100644 index 0000000..88770e7 --- /dev/null +++ b/polymarket-client-sdk/src/data/types/request.rs @@ -0,0 +1,523 @@ +//! Request types for the Polymarket Data API. +//! +//! This module contains builder-pattern structs for each API endpoint. +//! All request types use the [`bon`](https://docs.rs/bon) crate for the builder pattern. + +#![allow( + clippy::module_name_repetitions, + reason = "Request suffix is intentional for clarity" +)] + +use bon::Builder; +use serde::Serialize; +use serde_with::{StringWithSeparator, formats::CommaSeparator, serde_as, skip_serializing_none}; + +use super::{ + ActivitySortBy, ActivityType, BoundedIntError, ClosedPositionSortBy, LeaderboardCategory, + LeaderboardOrderBy, MarketFilter, PositionSortBy, Side, SortDirection, TimePeriod, TradeFilter, +}; +use crate::types::{Address, B256, Decimal}; + +/// Validates that an i32 value is within the specified bounds. +fn validate_bound( + value: i32, + min: i32, + max: i32, + param_name: &'static str, +) -> Result { + if (min..=max).contains(&value) { + Ok(value) + } else { + Err(BoundedIntError::new(value, min, max, param_name)) + } +} + +/// Request parameters for the `/positions` endpoint. +/// +/// Fetches current (open) positions for a user. Positions represent holdings +/// of outcome tokens in prediction markets. +/// +/// # Required Parameters +/// +/// - `user`: The Ethereum address of the user whose positions to retrieve. +/// +/// # Optional Parameters +/// +/// - `filter`: Filter by specific markets (condition IDs) or events. +/// Cannot specify both markets and events. +/// - `size_threshold`: Minimum position size to include (default: 1). +/// - `redeemable`: If true, only return positions that can be redeemed. +/// - `mergeable`: If true, only return positions that can be merged. +/// - `limit`: Maximum positions to return (0-500, default: 100). +/// - `offset`: Pagination offset (0-10000, default: 0). +/// - `sort_by`: Sort criteria (default: TOKENS). +/// - `sort_direction`: Sort order (default: DESC). +/// - `title`: Filter by market title substring. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::data::{types::request::PositionsRequest, types::{PositionSortBy, SortDirection}}; +/// +/// let request = PositionsRequest::builder() +/// .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .sort_by(PositionSortBy::CashPnl) +/// .sort_direction(SortDirection::Desc) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct PositionsRequest { + /// User address (required). + #[builder(into)] + pub user: Address, + /// Filter by markets or events. Mutually exclusive options. + #[serde(flatten, skip_serializing_if = "filter_is_none_or_empty")] + pub filter: Option, + /// Minimum position size to include (default: 1). + #[serde(rename = "sizeThreshold")] + pub size_threshold: Option, + /// Only return positions that can be redeemed (default: false). + pub redeemable: Option, + /// Only return positions that can be merged (default: false). + pub mergeable: Option, + /// Maximum number of positions to return (0-500, default: 100). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 500, "limit") })] + pub limit: Option, + /// Pagination offset (0-10000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 10000, "offset") })] + pub offset: Option, + /// Sort criteria (default: TOKENS). + #[serde(rename = "sortBy")] + pub sort_by: Option, + /// Sort direction (default: DESC). + #[serde(rename = "sortDirection")] + pub sort_direction: Option, + /// Filter by market title substring (max 100 chars). + #[builder(into)] + pub title: Option, +} + +#[expect(clippy::ref_option, reason = "Need an explicit reference for serde")] +fn filter_is_none_or_empty(f: &Option) -> bool { + match f { + None => true, + Some(MarketFilter::Markets(v)) => v.is_empty(), + Some(MarketFilter::EventIds(v)) => v.is_empty(), + } +} + +/// Request parameters for the `/trades` endpoint. +/// +/// Fetches trade history for a user or markets. Trades represent executed +/// orders where outcome tokens were bought or sold. +/// +/// # Optional Parameters +/// +/// - `user`: Filter by user address. +/// - `filter`: Filter by specific markets (condition IDs) or events. +/// - `limit`: Maximum trades to return (0-10000, default: 100). +/// - `offset`: Pagination offset (0-10000, default: 0). +/// - `taker_only`: If true, only return taker trades (default: true). +/// - `trade_filter`: Filter by minimum trade size (cash or tokens). +/// - `side`: Filter by trade side (BUY or SELL). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::data::{types::request::TradesRequest, types::{Side, TradeFilter}}; +/// use rust_decimal_macros::dec; +/// +/// let request = TradesRequest::builder() +/// .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .side(Side::Buy) +/// .trade_filter(TradeFilter::cash(dec!(100)).unwrap()) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct TradesRequest { + /// Filter by user address. + #[builder(into)] + pub user: Option

, + /// Filter by markets or events. Mutually exclusive options. + #[serde(flatten)] + pub filter: Option, + /// Maximum number of trades to return (0-10000, default: 100). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 10000, "limit") })] + pub limit: Option, + /// Pagination offset (0-10000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 10000, "offset") })] + pub offset: Option, + /// Only return taker trades (default: true). + #[serde(rename = "takerOnly")] + pub taker_only: Option, + /// Filter by minimum trade size. Must provide both type and amount. + #[serde(flatten)] + pub trade_filter: Option, + /// Filter by trade side (BUY or SELL). + pub side: Option, +} + +/// Request parameters for the `/activity` endpoint. +/// +/// Fetches on-chain activity for a user, including trades, splits, merges, +/// redemptions, rewards, and conversions. +/// +/// # Required Parameters +/// +/// - `user`: The Ethereum address of the user whose activity to retrieve. +/// +/// # Optional Parameters +/// +/// - `filter`: Filter by specific markets (condition IDs) or events. +/// - `activity_types`: Filter by activity types (TRADE, SPLIT, MERGE, etc.). +/// - `limit`: Maximum activities to return (0-500, default: 100). +/// - `offset`: Pagination offset (0-10000, default: 0). +/// - `start`: Start timestamp filter (Unix timestamp). +/// - `end`: End timestamp filter (Unix timestamp). +/// - `sort_by`: Sort criteria (default: TIMESTAMP). +/// - `sort_direction`: Sort order (default: DESC). +/// - `side`: Filter by trade side (only applies to TRADE activities). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::data::{types::request::ActivityRequest, types::ActivityType}; +/// +/// let request = ActivityRequest::builder() +/// .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .activity_types(vec![ActivityType::Trade, ActivityType::Redeem]) +/// .build(); +/// ``` +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct ActivityRequest { + /// User address (required). + #[builder(into)] + pub user: Address, + /// Filter by markets or events. Mutually exclusive options. + #[serde(flatten)] + pub filter: Option, + /// Filter by activity types. + #[serde_as(as = "StringWithSeparator::")] + #[builder(default)] + #[serde(rename = "type", skip_serializing_if = "Vec::is_empty")] + pub activity_types: Vec, + /// Maximum number of activities to return (0-500, default: 100). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 500, "limit") })] + pub limit: Option, + /// Pagination offset (0-10000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 10000, "offset") })] + pub offset: Option, + /// Start timestamp filter (Unix timestamp, minimum: 0). + pub start: Option, + /// End timestamp filter (Unix timestamp, minimum: 0). + pub end: Option, + /// Sort criteria (default: TIMESTAMP). + #[serde(rename = "sortBy")] + pub sort_by: Option, + /// Sort direction (default: DESC). + #[serde(rename = "sortDirection")] + pub sort_direction: Option, + /// Filter by trade side (only applies to TRADE activities). + pub side: Option, +} + +/// Request parameters for the `/holders` endpoint. +/// +/// Fetches top token holders for specified markets. Returns holders grouped +/// by token (outcome) for each market. +/// +/// # Required Parameters +/// +/// - `markets`: List of condition IDs (market identifiers) to query. +/// +/// # Optional Parameters +/// +/// - `limit`: Maximum holders to return per token (0-20, default: 20). +/// - `min_balance`: Minimum balance to include (0-999999, default: 1). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::types::request::HoldersRequest; +/// use polymarket_client_sdk::types::b256; +/// +/// let request = HoldersRequest::builder() +/// .markets(vec![b256!("dd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917")]) +/// .build(); +/// ``` +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct HoldersRequest { + /// Condition IDs of markets to query (required). + #[serde_as(as = "StringWithSeparator::")] + #[serde(rename = "market", skip_serializing_if = "Vec::is_empty")] + pub markets: Vec, + /// Maximum holders to return per token (0-20, default: 20). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 20, "limit") })] + pub limit: Option, + /// Minimum balance to include (0-999999, default: 1). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 999_999, "min_balance") })] + #[serde(rename = "minBalance")] + pub min_balance: Option, +} + +/// Request parameters for the `/traded` endpoint. +/// +/// Fetches the total count of unique markets a user has traded. +/// +/// # Required Parameters +/// +/// - `user`: The Ethereum address of the user to query. +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct TradedRequest { + /// User address (required). + #[builder(into)] + pub user: Address, +} + +/// Request parameters for the `/value` endpoint. +/// +/// Fetches the total value of a user's positions, optionally filtered by markets. +/// +/// # Required Parameters +/// +/// - `user`: The Ethereum address of the user to query. +/// +/// # Optional Parameters +/// +/// - `markets`: Filter by specific condition IDs. +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct ValueRequest { + /// User address (required). + #[builder(into)] + pub user: Address, + /// Optional list of condition IDs to filter by. + #[serde_as(as = "StringWithSeparator::")] + #[builder(default)] + #[serde(rename = "market", skip_serializing_if = "Vec::is_empty")] + pub markets: Vec, +} + +/// Request parameters for the `/oi` (open interest) endpoint. +/// +/// Fetches open interest for markets. Open interest represents the total +/// value of outstanding positions in a market. +/// +/// # Optional Parameters +/// +/// - `markets`: Filter by specific condition IDs. If not provided, returns +/// open interest for all markets. +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct OpenInterestRequest { + /// Optional list of condition IDs to filter by. + #[serde_as(as = "StringWithSeparator::")] + #[builder(default)] + #[serde(rename = "market", skip_serializing_if = "Vec::is_empty")] + pub markets: Vec, +} + +/// Request parameters for the `/live-volume` endpoint. +/// +/// Fetches live trading volume for an event, including total volume +/// and per-market breakdown. +/// +/// # Required Parameters +/// +/// - `id`: The event ID to query. +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct LiveVolumeRequest { + /// Event ID (required). + pub id: u64, +} + +/// Request parameters for the `/closed-positions` endpoint. +/// +/// Fetches closed (historical) positions for a user. These are positions +/// that have been fully sold or redeemed. +/// +/// # Required Parameters +/// +/// - `user`: The Ethereum address of the user to query. +/// +/// # Optional Parameters +/// +/// - `filter`: Filter by specific markets (condition IDs) or events. +/// - `title`: Filter by market title substring. +/// - `limit`: Maximum positions to return (0-50, default: 10). +/// - `offset`: Pagination offset (0-100000, default: 0). +/// - `sort_by`: Sort criteria (default: REALIZEDPNL). +/// - `sort_direction`: Sort order (default: DESC). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::types::address; +/// use polymarket_client_sdk::data::{types::request::ClosedPositionsRequest, types::ClosedPositionSortBy}; +/// +/// let request = ClosedPositionsRequest::builder() +/// .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) +/// .sort_by(ClosedPositionSortBy::Timestamp) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct ClosedPositionsRequest { + /// User address (required). + #[builder(into)] + pub user: Address, + /// Filter by markets or events. Mutually exclusive options. + #[serde(flatten)] + pub filter: Option, + /// Filter by market title substring (max 100 chars). + #[builder(into)] + pub title: Option, + /// Maximum number of positions to return (0-50, default: 10). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 50, "limit") })] + pub limit: Option, + /// Pagination offset (0-100000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 100_000, "offset") })] + pub offset: Option, + /// Sort criteria (default: REALIZEDPNL). + #[serde(rename = "sortBy")] + pub sort_by: Option, + /// Sort direction (default: DESC). + #[serde(rename = "sortDirection")] + pub sort_direction: Option, +} + +/// Request parameters for the `/v1/builders/leaderboard` endpoint. +/// +/// Fetches aggregated builder leaderboard rankings. Builders are third-party +/// applications that integrate with Polymarket. Returns one entry per builder +/// with aggregated totals for the specified time period. +/// +/// # Optional Parameters +/// +/// - `time_period`: Time period to aggregate over (default: DAY). +/// - `limit`: Maximum builders to return (0-50, default: 25). +/// - `offset`: Pagination offset (0-1000, default: 0). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::{types::request::BuilderLeaderboardRequest, types::TimePeriod}; +/// +/// let request = BuilderLeaderboardRequest::builder() +/// .time_period(TimePeriod::Week) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct BuilderLeaderboardRequest { + /// Time period to aggregate results over (default: DAY). + #[serde(rename = "timePeriod")] + pub time_period: Option, + /// Maximum number of builders to return (0-50, default: 25). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 50, "limit") })] + pub limit: Option, + /// Pagination offset (0-1000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 1000, "offset") })] + pub offset: Option, +} + +/// Request parameters for the `/v1/builders/volume` endpoint. +/// +/// Fetches daily time-series volume data for builders. Returns multiple +/// entries per builder (one per day), each including a timestamp. No pagination. +/// +/// # Optional Parameters +/// +/// - `time_period`: Time period to fetch daily records for (default: DAY). +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::{types::request::BuilderVolumeRequest, types::TimePeriod}; +/// +/// let request = BuilderVolumeRequest::builder() +/// .time_period(TimePeriod::Month) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct BuilderVolumeRequest { + /// Time period to fetch daily records for (default: DAY). + #[serde(rename = "timePeriod")] + pub time_period: Option, +} + +/// Request parameters for the `/v1/leaderboard` endpoint. +/// +/// Fetches trader leaderboard rankings filtered by category, time period, +/// and ordering. Returns ranked traders with their volume and `PnL` stats. +/// +/// # Optional Parameters +/// +/// - `category`: Market category filter (default: OVERALL). +/// - `time_period`: Time period for results (default: DAY). +/// - `order_by`: Ordering criteria - PNL or VOL (default: PNL). +/// - `limit`: Maximum traders to return (1-50, default: 25). +/// - `offset`: Pagination offset (0-1000, default: 0). +/// - `user`: Filter to a single user by address. +/// - `user_name`: Filter to a single user by username. +/// +/// # Example +/// +/// ``` +/// use polymarket_client_sdk::data::{types::request::TraderLeaderboardRequest, types::{LeaderboardCategory, TimePeriod, LeaderboardOrderBy}}; +/// +/// let request = TraderLeaderboardRequest::builder() +/// .category(LeaderboardCategory::Politics) +/// .time_period(TimePeriod::Week) +/// .order_by(LeaderboardOrderBy::Vol) +/// .build(); +/// ``` +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct TraderLeaderboardRequest { + /// Market category filter (default: OVERALL). + pub category: Option, + /// Time period for leaderboard results (default: DAY). + #[serde(rename = "timePeriod")] + pub time_period: Option, + /// Ordering criteria (default: PNL). + #[serde(rename = "orderBy")] + pub order_by: Option, + /// Maximum number of traders to return (1-50, default: 25). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 1, 50, "limit") })] + pub limit: Option, + /// Pagination offset (0-1000, default: 0). + #[builder(with = |v: i32| -> Result<_, BoundedIntError> { validate_bound(v, 0, 1000, "offset") })] + pub offset: Option, + /// Filter to a single user by address. + #[builder(into)] + pub user: Option
, + /// Filter to a single user by username. + #[builder(into)] + #[serde(rename = "userName")] + pub user_name: Option, +} diff --git a/polymarket-client-sdk/src/data/types/response.rs b/polymarket-client-sdk/src/data/types/response.rs new file mode 100644 index 0000000..85d8cac --- /dev/null +++ b/polymarket-client-sdk/src/data/types/response.rs @@ -0,0 +1,516 @@ +//! Response types for the Polymarket Data API. +//! +//! This module contains structs representing API responses from the Data API endpoints. + +use bon::Builder; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Deserializer}; +use serde_with::{DefaultOnNull, DisplayFromStr, NoneAsEmptyString, serde_as}; + +use super::{ActivityType, Side}; +use crate::types::{Address, B256, Decimal, U256}; + +/// Deserializes an optional Side, treating empty strings as None. +fn deserialize_optional_side<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(s) if s.is_empty() => Ok(None), + Some(s) => match s.to_uppercase().as_str() { + "BUY" => Ok(Some(Side::Buy)), + "SELL" => Ok(Some(Side::Sell)), + _ => Ok(None), + }, + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub enum Market { + /// All markets + #[serde(alias = "global", alias = "GLOBAL")] + Global, + /// Specific market condition ID + #[serde(untagged)] + Market(B256), +} + +/// Response from the health check endpoint (`/`). +/// +/// Returns "OK" when the API is healthy and operational. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct Health { + /// Health status message (typically "OK"). + pub data: String, +} + +/// Error response returned by the API on failure. +/// +/// Contains an error message describing what went wrong. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct ApiError { + /// Human-readable error message. + pub error: String, +} + +/// A user's current (open) position in a prediction market. +/// +/// Returned by the `/positions` endpoint. Represents holdings of outcome tokens +/// with associated profit/loss calculations. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Position { + /// The user's proxy wallet address. + pub proxy_wallet: Address, + /// The outcome token asset identifier + pub asset: U256, + /// The market condition ID (unique market identifier). + pub condition_id: B256, + /// Number of outcome tokens held. + pub size: Decimal, + /// Average entry price for the position. + pub avg_price: Decimal, + /// Initial value (cost basis) of the position. + pub initial_value: Decimal, + /// Current market value of the position. + pub current_value: Decimal, + /// Unrealized cash profit/loss. + pub cash_pnl: Decimal, + /// Unrealized percentage profit/loss. + pub percent_pnl: Decimal, + /// Total amount bought (cumulative). + pub total_bought: Decimal, + /// Realized profit/loss from closed portions. + pub realized_pnl: Decimal, + /// Realized percentage profit/loss. + pub percent_realized_pnl: Decimal, + /// Current market price of the outcome. + pub cur_price: Decimal, + /// Whether the position can be redeemed (market resolved). + pub redeemable: bool, + /// Whether the position can be merged with opposite outcome. + pub mergeable: bool, + /// Market title/question. + pub title: String, + /// Market URL slug. + pub slug: String, + /// Market icon URL. + pub icon: String, + /// Parent event URL slug. + pub event_slug: String, + /// Parent event ID. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub event_id: Option, + /// Outcome name (e.g., "Yes", "No", candidate name). + pub outcome: String, + /// Outcome index within the market (0 or 1 for binary markets). + pub outcome_index: i32, + /// Name of the opposite outcome. + pub opposite_outcome: String, + /// Asset identifier of the opposite outcome. + pub opposite_asset: U256, + /// Market end/resolution date (if set). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub end_date: Option, + /// Whether this is a negative risk market. + pub negative_risk: bool, +} + +/// A user's closed (historical) position in a prediction market. +/// +/// Returned by the `/closed-positions` endpoint. Represents positions that +/// have been fully sold or redeemed, with final profit/loss figures. +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ClosedPosition { + /// The user's proxy wallet address. + pub proxy_wallet: Address, + /// The outcome token asset identifier (decimal string from API). + pub asset: U256, + /// The market condition ID (unique market identifier). + pub condition_id: B256, + /// Average entry price for the position. + pub avg_price: Decimal, + /// Total amount bought (cumulative). + pub total_bought: Decimal, + /// Realized profit/loss from the closed position. + pub realized_pnl: Decimal, + /// Final market price when position was closed. + pub cur_price: Decimal, + /// Unix timestamp when the position was closed. + pub timestamp: i64, + /// Market title/question. + pub title: String, + /// Market URL slug. + pub slug: String, + /// Market icon URL. + pub icon: String, + /// Parent event URL slug. + pub event_slug: String, + /// Outcome name (e.g., "Yes", "No", candidate name). + pub outcome: String, + /// Outcome index within the market (0 or 1 for binary markets). + pub outcome_index: i32, + /// Name of the opposite outcome. + pub opposite_outcome: String, + /// Asset identifier of the opposite outcome. + pub opposite_asset: U256, + /// Market end/resolution date. + pub end_date: DateTime, +} + +/// A trade (buy or sell) of outcome tokens. +/// +/// Returned by the `/trades` endpoint. Represents an executed order where +/// outcome tokens were bought or sold. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Trade { + /// The trader's proxy wallet address. + pub proxy_wallet: Address, + /// Trade side (BUY or SELL). + pub side: Side, + /// The outcome token asset identifier (decimal string from API). + pub asset: U256, + /// The market condition ID (unique market identifier). + pub condition_id: B256, + /// Number of tokens traded. + pub size: Decimal, + /// Execution price per token. + pub price: Decimal, + /// Unix timestamp when the trade occurred. + pub timestamp: i64, + /// Market title/question. + pub title: String, + /// Market URL slug. + pub slug: String, + /// Market icon URL. + pub icon: String, + /// Parent event URL slug. + pub event_slug: String, + /// Outcome name (e.g., "Yes", "No", candidate name). + pub outcome: String, + /// Outcome index within the market (0 or 1 for binary markets). + pub outcome_index: i32, + /// Trader's display name (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub name: Option, + /// Trader's pseudonym (if set). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub pseudonym: Option, + /// Trader's bio (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub bio: Option, + /// Trader's profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image: Option, + /// Trader's optimized profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image_optimized: Option, + /// On-chain transaction hash. + pub transaction_hash: B256, +} + +/// An on-chain activity record for a user. +/// +/// Returned by the `/activity` endpoint. Represents various on-chain operations +/// including trades, splits, merges, redemptions, rewards, and conversions. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Activity { + /// The user's proxy wallet address. + pub proxy_wallet: Address, + /// Unix timestamp when the activity occurred. + pub timestamp: i64, + /// The market condition ID (unique market identifier). + /// Can be empty for some activity types (e.g., rewards, conversions). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub condition_id: Option, + /// Type of activity (TRADE, SPLIT, MERGE, REDEEM, REWARD, CONVERSION). + #[serde(rename = "type")] + pub activity_type: ActivityType, + /// Number of tokens involved in the activity. + pub size: Decimal, + /// USDC value of the activity. + pub usdc_size: Decimal, + /// On-chain transaction hash. + pub transaction_hash: B256, + /// Price per token (for trades). + pub price: Option, + /// Outcome token asset identifier + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub asset: Option, + /// Trade side (for trades only). + #[serde(default, deserialize_with = "deserialize_optional_side")] + pub side: Option, + /// Outcome index (for trades). + pub outcome_index: Option, + /// Market title/question. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub title: Option, + /// Market URL slug. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub slug: Option, + /// Market icon URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub icon: Option, + /// Parent event URL slug. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub event_slug: Option, + /// Outcome name. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub outcome: Option, + /// User's display name (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub name: Option, + /// User's pseudonym (if set). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub pseudonym: Option, + /// User's bio (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub bio: Option, + /// User's profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image: Option, + /// User's optimized profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image_optimized: Option, +} + +/// A holder of outcome tokens in a market. +/// +/// Represents a user who holds a position in a specific outcome. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Holder { + /// The holder's proxy wallet address. + pub proxy_wallet: Address, + /// Holder's bio (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub bio: Option, + /// The outcome token asset identifier (decimal string from API). + pub asset: U256, + /// Holder's pseudonym (if set). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub pseudonym: Option, + /// Amount of tokens held. + pub amount: Decimal, + /// Whether the holder's username is publicly visible. + pub display_username_public: Option, + /// Outcome index within the market (0 or 1 for binary markets). + pub outcome_index: i32, + /// Holder's display name (if public). + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub name: Option, + /// Holder's profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image: Option, + /// Holder's optimized profile image URL. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image_optimized: Option, + /// Whether the holder is verified. + pub verified: Option, +} + +/// Container for holders grouped by token. +/// +/// Returned by the `/holders` endpoint. Groups holders by outcome token. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct MetaHolder { + /// The outcome token identifier + pub token: U256, + /// List of holders for this token. + pub holders: Vec, +} + +/// Count of unique markets a user has traded. +/// +/// Returned by the `/traded` endpoint. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct Traded { + /// The user's address. + pub user: Address, + /// Number of unique markets traded. + pub traded: i32, +} + +/// Total value of a user's positions. +/// +/// Returned by the `/value` endpoint. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct Value { + /// The user's address. + pub user: Address, + /// Total value of positions in USDC. + pub value: Decimal, +} + +/// Open interest for a market. +/// +/// Returned by the `/oi` endpoint. Open interest represents the total +/// value of outstanding positions in a market. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct OpenInterest { + /// The market condition ID + pub market: Market, + /// Open interest value in USDC. + pub value: Decimal, +} + +/// Trading volume for a specific market. +/// +/// Used within [`LiveVolume`] to show per-market volume breakdown. +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct MarketVolume { + /// The market condition ID + pub market: Market, + /// Trading volume in USDC. + pub value: Decimal, +} + +/// Live trading volume for an event. +/// +/// Returned by the `/live-volume` endpoint. Includes total volume +/// and per-market breakdown. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[non_exhaustive] +pub struct LiveVolume { + /// Total trading volume across all markets in the event. + pub total: Decimal, + /// Per-market volume breakdown. + #[serde(default)] + #[serde_as(deserialize_as = "DefaultOnNull")] + pub markets: Vec, +} + +/// A builder's entry in the aggregated leaderboard. +/// +/// Returned by the `/v1/builders/leaderboard` endpoint. Builders are third-party +/// applications that integrate with Polymarket. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct BuilderLeaderboardEntry { + /// Rank position in the leaderboard. + #[serde_as(as = "DisplayFromStr")] + pub rank: i32, + /// Builder name or identifier. + pub builder: String, + /// Total trading volume attributed to this builder. + pub volume: Decimal, + /// Number of active users for this builder. + pub active_users: i32, + /// Whether the builder is verified. + pub verified: bool, + /// URL to the builder's logo image. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub builder_logo: Option, +} + +/// A builder's daily volume data point. +/// +/// Returned by the `/v1/builders/volume` endpoint. Each entry represents +/// a single day's volume and activity for a builder. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct BuilderVolumeEntry { + /// Timestamp for this entry in ISO 8601 format (e.g., "2025-11-15T00:00:00Z"). + pub dt: DateTime, + /// Builder name or identifier. + pub builder: String, + /// URL to the builder's logo image. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub builder_logo: Option, + /// Whether the builder is verified. + pub verified: bool, + /// Trading volume for this builder on this date. + pub volume: Decimal, + /// Number of active users for this builder on this date. + pub active_users: i32, + /// Rank position on this date. + #[serde_as(as = "DisplayFromStr")] + pub rank: i32, +} + +/// A trader's entry in the leaderboard. +/// +/// Returned by the `/v1/leaderboard` endpoint. Shows trader rankings +/// by profit/loss or volume. +#[serde_as] +#[derive(Debug, Clone, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct TraderLeaderboardEntry { + /// Rank position in the leaderboard. + #[serde_as(as = "DisplayFromStr")] + pub rank: i32, + /// The trader's proxy wallet address. + pub proxy_wallet: Address, + /// The trader's username. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub user_name: Option, + /// Trading volume for this trader. + pub vol: Decimal, + /// Profit and loss for this trader. + pub pnl: Decimal, + /// URL to the trader's profile image. + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub profile_image: Option, + /// The trader's X (Twitter) username + #[serde(default)] + #[serde_as(as = "NoneAsEmptyString")] + pub x_username: Option, + /// Whether the trader has a verified badge. + pub verified_badge: Option, +} diff --git a/polymarket-client-sdk/src/error.rs b/polymarket-client-sdk/src/error.rs new file mode 100644 index 0000000..c553712 --- /dev/null +++ b/polymarket-client-sdk/src/error.rs @@ -0,0 +1,314 @@ +use std::backtrace::Backtrace; +use std::error::Error as StdError; +use std::fmt; + +use alloy::primitives::ChainId; +use alloy::primitives::ruint::ParseError; +use hmac::digest::InvalidLength; +/// HTTP method type, re-exported for use with error inspection. +pub use reqwest::Method; +/// HTTP status code type, re-exported for use with error inspection. +pub use reqwest::StatusCode; +use reqwest::header; + +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Error related to non-successful HTTP call + Status, + /// Error related to invalid state within polymarket-client-sdk + Validation, + /// Error related to synchronization of authenticated clients logging in and out + Synchronization, + /// Internal error from dependencies + Internal, + /// Error related to WebSocket connections + WebSocket, + /// Error related to geographic restrictions blocking access + Geoblock, +} + +#[derive(Debug)] +pub struct Error { + kind: Kind, + source: Option>, + backtrace: Backtrace, +} + +impl Error { + pub fn with_source(kind: Kind, source: S) -> Self { + Self { + kind, + source: Some(Box::new(source)), + backtrace: Backtrace::capture(), + } + } + + pub fn kind(&self) -> Kind { + self.kind + } + + pub fn backtrace(&self) -> &Backtrace { + &self.backtrace + } + + pub fn inner(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> { + self.source.as_deref() + } + + pub fn downcast_ref(&self) -> Option<&E> { + let e = self.source.as_deref()?; + e.downcast_ref::() + } + + pub fn validation>(message: S) -> Self { + Validation { + reason: message.into(), + } + .into() + } + + pub fn status>( + status_code: StatusCode, + method: Method, + path: String, + message: S, + ) -> Self { + Status { + status_code, + method, + path, + message: message.into(), + } + .into() + } + + #[must_use] + pub fn missing_contract_config(chain_id: ChainId, neg_risk: bool) -> Self { + MissingContractConfig { chain_id, neg_risk }.into() + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.source { + Some(src) => write!(f, "{:?}: {}", self.kind, src), + None => write!(f, "{:?}", self.kind), + } + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_deref() + .map(|e| e as &(dyn StdError + 'static)) + } +} + +#[non_exhaustive] +#[derive(Debug)] +pub struct Status { + pub status_code: StatusCode, + pub method: Method, + pub path: String, + pub message: String, +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "error({}) making {} call to {} with {}", + self.status_code, self.method, self.path, self.message + ) + } +} + +impl StdError for Status {} + +#[non_exhaustive] +#[derive(Debug)] +pub struct Validation { + pub reason: String, +} + +impl fmt::Display for Validation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid: {}", self.reason) + } +} + +impl StdError for Validation {} + +#[non_exhaustive] +#[derive(Debug)] +pub struct Synchronization; + +impl fmt::Display for Synchronization { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "synchronization error: multiple threads are attempting to log in or log out" + ) + } +} + +impl StdError for Synchronization {} + +#[non_exhaustive] +#[derive(Debug, Clone, Copy)] +pub struct MissingContractConfig { + pub chain_id: ChainId, + pub neg_risk: bool, +} + +impl fmt::Display for MissingContractConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "missing contract config for chain id {} with neg_risk = {}", + self.chain_id, self.neg_risk, + ) + } +} + +impl std::error::Error for MissingContractConfig {} + +impl From for Error { + fn from(err: MissingContractConfig) -> Self { + Error::with_source(Kind::Internal, err) + } +} + +/// Error indicating that the user is blocked from accessing Polymarket due to geographic +/// restrictions. +/// +/// This error contains information about the user's detected location. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct Geoblock { + /// The detected IP address + pub ip: String, + /// ISO 3166-1 alpha-2 country code + pub country: String, + /// Region/state code + pub region: String, +} + +impl fmt::Display for Geoblock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "access blocked from country: {}, region: {}, ip: {}", + self.country, self.region, self.ip + ) + } +} + +impl StdError for Geoblock {} + +impl From for Error { + fn from(err: Geoblock) -> Self { + Error::with_source(Kind::Geoblock, err) + } +} + +impl From for Error { + fn from(e: base64::DecodeError) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: header::InvalidHeaderValue) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: InvalidLength) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: alloy::signers::Error) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: url::ParseError) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(e: ParseError) -> Self { + Error::with_source(Kind::Internal, e) + } +} + +impl From for Error { + fn from(err: Validation) -> Self { + Error::with_source(Kind::Validation, err) + } +} + +impl From for Error { + fn from(err: Status) -> Self { + Error::with_source(Kind::Status, err) + } +} + +impl From for Error { + fn from(err: Synchronization) -> Self { + Error::with_source(Kind::Synchronization, err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geoblock_display_should_succeed() { + let geoblock = Geoblock { + ip: "192.168.1.1".to_owned(), + country: "US".to_owned(), + region: "NY".to_owned(), + }; + + assert_eq!( + geoblock.to_string(), + "access blocked from country: US, region: NY, ip: 192.168.1.1" + ); + } + + #[test] + fn geoblock_into_error_should_succeed() { + let geoblock = Geoblock { + ip: "10.0.0.1".to_owned(), + country: "CU".to_owned(), + region: "HAV".to_owned(), + }; + + let error: Error = geoblock.into(); + + assert_eq!(error.kind(), Kind::Geoblock); + assert!(error.to_string().contains("CU")); + } +} diff --git a/polymarket-client-sdk/src/gamma/client.rs b/polymarket-client-sdk/src/gamma/client.rs new file mode 100644 index 0000000..7c3cccf --- /dev/null +++ b/polymarket-client-sdk/src/gamma/client.rs @@ -0,0 +1,596 @@ +//! Client for the Polymarket Gamma API. +//! +//! This module provides an HTTP client for interacting with the Polymarket Gamma API, +//! which offers endpoints for querying events, markets, tags, series, comments, and more. +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::gamma::{Client, types::request::EventsRequest}; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = Client::default(); +//! +//! // List active events +//! let request = EventsRequest::builder() +//! .active(true) +//! .limit(10) +//! .build(); +//! +//! let events = client.events(&request).await?; +//! for event in events { +//! println!("{}: {:?}", event.id, event.title); +//! } +//! # Ok(()) +//! # } +//! ``` + +use std::future::Future; + +use async_stream::try_stream; +use futures::Stream; +use reqwest::{ + Client as ReqwestClient, Method, + header::{HeaderMap, HeaderValue}, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +#[cfg(feature = "tracing")] +use tracing::warn; +use url::Url; + +use super::types::request::{ + CommentsByIdRequest, CommentsByUserAddressRequest, CommentsRequest, EventByIdRequest, + EventBySlugRequest, EventTagsRequest, EventsRequest, MarketByIdRequest, MarketBySlugRequest, + MarketTagsRequest, MarketsRequest, PublicProfileRequest, RelatedTagsByIdRequest, + RelatedTagsBySlugRequest, SearchRequest, SeriesByIdRequest, SeriesListRequest, TagByIdRequest, + TagBySlugRequest, TagsRequest, TeamsRequest, +}; +use super::types::response::{ + Comment, Event, HealthResponse, Market, PublicProfile, RelatedTag, SearchResults, Series, + SportsMarketTypesResponse, SportsMetadata, Tag, Team, +}; +use crate::error::Error; +use crate::{Result, ToQueryParams as _}; + +const MAX_LIMIT: i32 = 500; + +/// HTTP client for the Polymarket Gamma API. +/// +/// Provides methods for querying events, markets, tags, series, comments, +/// profiles, and search functionality. +/// +/// # API Base URL +/// +/// The default API endpoint is `https://gamma-api.polymarket.com`. +/// +/// # Example +/// +/// ```no_run +/// use polymarket_client_sdk::gamma::Client; +/// +/// // Create client with default endpoint +/// let client = Client::default(); +/// +/// // Or with a custom endpoint +/// let client = Client::new("https://custom-api.example.com").unwrap(); +/// ``` +#[derive(Clone, Debug)] +pub struct Client { + host: Url, + client: ReqwestClient, +} + +impl Default for Client { + fn default() -> Self { + Client::new("https://gamma-api.polymarket.com") + .expect("Client with default endpoint should succeed") + } +} + +impl Client { + /// Creates a new Gamma API client with a custom host URL. + /// + /// # Arguments + /// + /// * `host` - The base URL for the Gamma API. + /// + /// # Errors + /// + /// Returns an error if the URL is invalid or the HTTP client cannot be created. + pub fn new(host: &str) -> Result { + let mut headers = HeaderMap::new(); + + headers.insert("User-Agent", HeaderValue::from_static("rs_clob_client")); + headers.insert("Accept", HeaderValue::from_static("*/*")); + headers.insert("Connection", HeaderValue::from_static("keep-alive")); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + let client = ReqwestClient::builder().default_headers(headers).build()?; + + Ok(Self { + host: Url::parse(host)?, + client, + }) + } + + /// Returns the base URL of the API. + #[must_use] + pub fn host(&self) -> &Url { + &self.host + } + + async fn get( + &self, + path: &str, + req: &Req, + ) -> Result { + let query = req.query_params(None); + let request = self + .client + .request(Method::GET, format!("{}{path}{query}", self.host)) + .build()?; + crate::request(&self.client, request, None).await + } + + /// Performs a health check on the Gamma API. + /// + /// Returns "OK" when the API is healthy and operational. Use this for monitoring + /// and verifying the API's availability. + /// + /// # Errors + /// + /// Returns an error if the API is unreachable or returns a non-200 status code. + pub async fn status(&self) -> Result { + let request = self + .client + .request(Method::GET, format!("{}status", self.host)) + .build()?; + + let response = self.client.execute(request).await?; + let status_code = response.status(); + + if !status_code.is_success() { + let message = response.text().await.unwrap_or_default(); + return Err(Error::status( + status_code, + Method::GET, + "status".to_owned(), + message, + )); + } + + Ok(response.text().await?) + } + + /// Retrieves a list of sports teams with optional filtering. + /// + /// Returns teams participating in sports markets. Use filters to narrow results + /// by sport type, league, or other criteria. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn teams(&self, request: &TeamsRequest) -> Result> { + self.get("teams", request).await + } + + /// Retrieves metadata for all supported sports. + /// + /// Returns information about sports categories available on Polymarket, + /// including sports like NFL, NBA, MLB, etc. Useful for discovering + /// what sports markets are available. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn sports(&self) -> Result> { + self.get("sports", &()).await + } + + /// Retrieves valid market types for sports. + /// + /// Returns the different types of sports markets available (e.g., moneyline, + /// spread, over/under). Use this to understand what formats are supported. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn sports_market_types(&self) -> Result { + self.get("sports/market-types", &()).await + } + + /// Retrieves a list of tags with optional filtering. + /// + /// Tags categorize markets and events (e.g., "Politics", "Crypto", "Sports"). + /// Use filters to search for specific tag types or categories. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn tags(&self, request: &TagsRequest) -> Result> { + self.get("tags", request).await + } + + /// Retrieves a single tag by its unique ID. + /// + /// Returns detailed information about a specific tag including its name, + /// description, and associated markets. + /// + /// # Errors + /// + /// Returns an error if the tag ID is invalid or the request fails. + pub async fn tag_by_id(&self, request: &TagByIdRequest) -> Result { + self.get(&format!("tags/{}", request.id), request).await + } + + /// Retrieves a single tag by its URL-friendly slug. + /// + /// Returns the same information as [`Self::tag_by_id`] but uses a human-readable + /// slug identifier instead of a numeric ID. + /// + /// # Errors + /// + /// Returns an error if the slug is invalid or the request fails. + pub async fn tag_by_slug(&self, request: &TagBySlugRequest) -> Result { + self.get(&format!("tags/slug/{}", request.slug), request) + .await + } + + /// Retrieves related tag relationships for a tag by ID. + /// + /// Returns tags that are semantically related to the specified tag, including + /// the relationship type (e.g., parent, child, related). Useful for discovering + /// related markets and topics. + /// + /// # Errors + /// + /// Returns an error if the tag ID is invalid or the request fails. + pub async fn related_tags_by_id( + &self, + request: &RelatedTagsByIdRequest, + ) -> Result> { + self.get(&format!("tags/{}/related-tags", request.id), request) + .await + } + + /// Retrieves related tag relationships for a tag by slug. + /// + /// Same as [`Self::related_tags_by_id`] but uses a slug identifier instead of an ID. + /// + /// # Errors + /// + /// Returns an error if the slug is invalid or the request fails. + pub async fn related_tags_by_slug( + &self, + request: &RelatedTagsBySlugRequest, + ) -> Result> { + self.get(&format!("tags/slug/{}/related-tags", request.slug), request) + .await + } + + /// Retrieves tags that are related to a specified tag by ID. + /// + /// Returns the actual tag objects (not just relationships) for tags related to + /// the specified tag. This provides full tag details for related topics. + /// + /// # Errors + /// + /// Returns an error if the tag ID is invalid or the request fails. + pub async fn tags_related_to_tag_by_id( + &self, + request: &RelatedTagsByIdRequest, + ) -> Result> { + self.get(&format!("tags/{}/related-tags/tags", request.id), request) + .await + } + + /// Retrieves tags that are related to a specified tag by slug. + /// + /// Same as [`Self::tags_related_to_tag_by_id`] but uses a slug identifier instead of an ID. + /// + /// # Errors + /// + /// Returns an error if the slug is invalid or the request fails. + pub async fn tags_related_to_tag_by_slug( + &self, + request: &RelatedTagsBySlugRequest, + ) -> Result> { + self.get( + &format!("tags/slug/{}/related-tags/tags", request.slug), + request, + ) + .await + } + + /// Retrieves a list of events with optional filtering. + /// + /// Events are collections of related markets (e.g., "2024 Presidential Election"). + /// Use filters to search by tags, active status, or other criteria. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn events(&self, request: &EventsRequest) -> Result> { + self.get("events", request).await + } + + /// Retrieves a single event by its unique ID. + /// + /// Returns detailed information about an event including its markets, + /// description, and associated tags. + /// + /// # Errors + /// + /// Returns an error if the event ID is invalid or the request fails. + pub async fn event_by_id(&self, request: &EventByIdRequest) -> Result { + self.get(&format!("events/{}", request.id), request).await + } + + /// Retrieves a single event by its URL-friendly slug. + /// + /// Returns the same information as [`Self::event_by_id`] but uses a slug + /// identifier instead of a numeric ID. + /// + /// # Errors + /// + /// Returns an error if the slug is invalid or the request fails. + pub async fn event_by_slug(&self, request: &EventBySlugRequest) -> Result { + self.get(&format!("events/slug/{}", request.slug), request) + .await + } + + /// Retrieves all tags associated with an event. + /// + /// Returns the categorization tags for a specific event, helping understand + /// the event's topics and categories. + /// + /// # Errors + /// + /// Returns an error if the event ID is invalid or the request fails. + pub async fn event_tags(&self, request: &EventTagsRequest) -> Result> { + self.get(&format!("events/{}/tags", request.id), request) + .await + } + + /// Retrieves a list of prediction markets with optional filtering. + /// + /// Markets are the core trading instruments on Polymarket. Use filters to search + /// by tags, events, active status, or CLOB token IDs. Returns market details + /// including current prices, volume, and outcome information. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn markets(&self, request: &MarketsRequest) -> Result> { + self.get("markets", request).await + } + + /// Retrieves a single market by its unique ID. + /// + /// Returns detailed information about a specific market including outcomes, + /// current prices, volume, and resolution details. + /// + /// # Errors + /// + /// Returns an error if the market ID is invalid or the request fails. + pub async fn market_by_id(&self, request: &MarketByIdRequest) -> Result { + self.get(&format!("markets/{}", request.id), request).await + } + + /// Retrieves a single market by its URL-friendly slug. + /// + /// Returns the same information as [`Self::market_by_id`] but uses a slug + /// identifier instead of a numeric ID. + /// + /// # Errors + /// + /// Returns an error if the slug is invalid or the request fails. + pub async fn market_by_slug(&self, request: &MarketBySlugRequest) -> Result { + self.get(&format!("markets/slug/{}", request.slug), request) + .await + } + + /// Retrieves all tags associated with a market. + /// + /// Returns the categorization tags for a specific market, helping understand + /// the market's topics and categories. + /// + /// # Errors + /// + /// Returns an error if the market ID is invalid or the request fails. + pub async fn market_tags(&self, request: &MarketTagsRequest) -> Result> { + self.get(&format!("markets/{}/tags", request.id), request) + .await + } + + /// Retrieves a list of market series with optional filtering. + /// + /// Series are groups of related markets that follow a pattern (e.g., weekly + /// sports outcomes, monthly economic indicators). Useful for tracking recurring + /// predictions over time. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn series(&self, request: &SeriesListRequest) -> Result> { + self.get("series", request).await + } + + /// Retrieves a single series by its unique ID. + /// + /// Returns detailed information about a series including all markets in the series + /// and their resolution history. + /// + /// # Errors + /// + /// Returns an error if the series ID is invalid or the request fails. + pub async fn series_by_id(&self, request: &SeriesByIdRequest) -> Result { + self.get(&format!("series/{}", request.id), request).await + } + + /// Retrieves a list of user comments with optional filtering. + /// + /// Comments are user-generated discussions and analysis on markets and events. + /// Use filters to search by market, event, or other criteria. + /// + /// # Errors + /// + /// Returns an error if the request fails. + pub async fn comments(&self, request: &CommentsRequest) -> Result> { + self.get("comments", request).await + } + + /// Retrieves comments by their unique comment ID. + /// + /// Returns comments with the specified ID, including nested replies and + /// associated metadata. + /// + /// # Errors + /// + /// Returns an error if the comment ID is invalid or the request fails. + pub async fn comments_by_id(&self, request: &CommentsByIdRequest) -> Result> { + self.get(&format!("comments/{}", request.id), request).await + } + + /// Retrieves all comments authored by a specific wallet address. + /// + /// Returns comments posted by a particular user, useful for viewing a user's + /// contribution history and market analysis. + /// + /// # Errors + /// + /// Returns an error if the address is invalid or the request fails. + pub async fn comments_by_user_address( + &self, + request: &CommentsByUserAddressRequest, + ) -> Result> { + self.get( + &format!("comments/user_address/{}", request.user_address), + request, + ) + .await + } + + /// Retrieves a public trading profile for a wallet address. + /// + /// Returns public statistics about a trader including their trading history, + /// win rate, and other performance metrics. Only publicly visible information + /// is returned. + /// + /// # Errors + /// + /// Returns an error if the address is invalid or the request fails. + pub async fn public_profile(&self, request: &PublicProfileRequest) -> Result { + self.get("public-profile", request).await + } + + /// Searches across markets, events, and user profiles. + /// + /// Performs a text search to find markets, events, or users matching the query. + /// Useful for discovery and finding specific content across the platform. + /// + /// # Errors + /// + /// Returns an error if the request fails or the search query is invalid. + pub async fn search(&self, request: &SearchRequest) -> Result { + self.get("public-search", request).await + } + + /// Returns a stream of results using offset-based pagination. + /// + /// This method repeatedly invokes the provided closure `call`, which takes the + /// client and pagination parameters (limit and offset) to fetch data. Each page + /// of results is flattened into individual items in the stream. + /// + /// The stream continues fetching pages until: + /// - An empty page is returned, or + /// - A page with fewer items than the requested limit is returned (indicating the last page) + /// + /// # Arguments + /// + /// * `call` - A closure that takes `&Client`, `limit: i32`, and `offset: i32`, + /// returning a future that resolves to a `Result>` + /// * `limit` - The number of items to fetch per page (default: 100) + /// + /// # Example + /// + /// ```no_run + /// use futures::StreamExt; + /// use polymarket_client_sdk::gamma::{Client, types::request::EventsRequest}; + /// use tokio::pin; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::default(); + /// + /// // Stream all active events + /// let mut stream = client.stream_data( + /// |client, limit, offset| { + /// let request = EventsRequest::builder() + /// .active(true) + /// .limit(limit) + /// .offset(offset) + /// .build(); + /// async move { client.events(&request).await } + /// }, + /// 100, // page size + /// ); + /// + /// pin!(stream); + /// + /// while let Some(result) = stream.next().await { + /// match result { + /// Ok(event) => println!("Event: {}", event.id), + /// Err(e) => eprintln!("Error: {}", e), + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn stream_data<'client, Call, Fut, Data>( + &'client self, + call: Call, + limit: i32, + ) -> impl Stream> + 'client + where + Call: Fn(&'client Client, i32, i32) -> Fut + 'client, + Fut: Future>> + 'client, + Data: 'client, + { + let limit = if limit > MAX_LIMIT { + #[cfg(feature = "tracing")] + warn!( + "Supplied {limit} limit, Gamma only allows for maximum {MAX_LIMIT} responses per call, defaulting to {MAX_LIMIT}" + ); + + MAX_LIMIT + } else { + limit + }; + + try_stream! { + let mut offset = 0; + + loop { + let data = call(self, limit, offset).await?; + + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "We shouldn't ever truncate/wrap since we'll never return that many records in one call") + ] + let count = data.len() as i32; + + for item in data { + yield item; + } + + // Stop if we received fewer items than requested (last page) + if count < limit { + break; + } + + offset += count; + } + } + } +} diff --git a/polymarket-client-sdk/src/gamma/mod.rs b/polymarket-client-sdk/src/gamma/mod.rs new file mode 100644 index 0000000..a388f34 --- /dev/null +++ b/polymarket-client-sdk/src/gamma/mod.rs @@ -0,0 +1,73 @@ +//! Polymarket Gamma API client and types. +//! +//! **Feature flag:** `gamma` (required to use this module) +//! +//! This module provides a client for interacting with the Polymarket Gamma API, +//! which offers HTTP endpoints for querying events, markets, tags, series, +//! comments, profiles, and search functionality. +//! +//! # Overview +//! +//! The Gamma API provides market and event metadata for Polymarket. It is +//! separate from the CLOB (Central Limit Order Book) API which handles trading. +//! +//! ## Available Endpoints +//! +//! | Endpoint | Description | +//! |----------|-------------| +//! | `/status` | Health check | +//! | `/teams` | List sports teams | +//! | `/sports` | Get sports metadata | +//! | `/sports/market-types` | Get valid sports market types | +//! | `/tags` | List tags | +//! | `/tags/{id}` | Get tag by ID | +//! | `/tags/slug/{slug}` | Get tag by slug | +//! | `/tags/{id}/related-tags` | Get related tag relationships | +//! | `/events` | List events | +//! | `/events/{id}` | Get event by ID | +//! | `/events/slug/{slug}` | Get event by slug | +//! | `/events/{id}/tags` | Get event tags | +//! | `/markets` | List markets | +//! | `/markets/{id}` | Get market by ID | +//! | `/markets/slug/{slug}` | Get market by slug | +//! | `/markets/{id}/tags` | Get market tags | +//! | `/series` | List series | +//! | `/series/{id}` | Get series by ID | +//! | `/comments` | List comments | +//! | `/comments/{id}` | Get comments by ID | +//! | `/public-profile` | Get public profile | +//! | `/public-search` | Search markets, events, and profiles | +//! +//! # Example +//! +//! ```no_run +//! use polymarket_client_sdk::gamma::{Client, types::request::EventsRequest}; +//! +//! # async fn example() -> Result<(), Box> { +//! // Create a client with the default endpoint +//! let client = Client::default(); +//! +//! // Build a request for active events +//! let request = EventsRequest::builder() +//! .active(true) +//! .limit(10) +//! .build(); +//! +//! // Fetch events +//! let events = client.events(&request).await?; +//! +//! for event in events { +//! println!("{}: {:?}", event.id, event.title); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # API Base URL +//! +//! The default API endpoint is `https://gamma-api.polymarket.com`. + +pub mod client; +pub mod types; + +pub use client::Client; diff --git a/polymarket-client-sdk/src/gamma/types/mod.rs b/polymarket-client-sdk/src/gamma/types/mod.rs new file mode 100644 index 0000000..1be144c --- /dev/null +++ b/polymarket-client-sdk/src/gamma/types/mod.rs @@ -0,0 +1,60 @@ +//! Types for the Polymarket Gamma API. +//! +//! This module contains all types used by the Gamma API client, organized into: +//! +//! - **Common types**: Shared data structures used across requests and responses, +//! as well as enums for filtering and categorization. +//! +//! - **Request types**: Builder-pattern structs for each API endpoint +//! (e.g., [`request::EventsRequest`], [`request::MarketsRequest`]). +//! +//! - **Response types**: Structs representing API responses +//! (e.g., [`response::Event`], [`response::Market`], [`response::Tag`]). +//! +//! # Request Building +//! +//! All request types use the builder pattern via the [`bon`](https://docs.rs/bon) crate: +//! +//! ``` +//! use polymarket_client_sdk::gamma::types::request::{EventsRequest, MarketsRequest}; +//! +//! // Simple request with defaults +//! let events = EventsRequest::builder().build(); +//! +//! // Request with filters +//! let markets = MarketsRequest::builder() +//! .limit(10) +//! .closed(false) +//! .build(); +//! ``` + +use serde::{Deserialize, Serialize}; + +pub mod request; +pub mod response; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[non_exhaustive] +pub enum RelatedTagsStatus { + Active, + Closed, + All, + /// Unknown status from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum_macros::Display)] +#[non_exhaustive] +pub enum ParentEntityType { + Event, + Series, + #[serde(rename = "market")] + #[strum(serialize = "market")] + Market, + /// Unknown entity type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} diff --git a/polymarket-client-sdk/src/gamma/types/request.rs b/polymarket-client-sdk/src/gamma/types/request.rs new file mode 100644 index 0000000..a6ea9db --- /dev/null +++ b/polymarket-client-sdk/src/gamma/types/request.rs @@ -0,0 +1,334 @@ +#![allow( + clippy::module_name_repetitions, + reason = "Request suffix is intentional for clarity" +)] + +use bon::Builder; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_with::{DisplayFromStr, serde_as, skip_serializing_none}; + +use crate::gamma::types::{ParentEntityType, RelatedTagsStatus}; +use crate::types::{Address, B256, Decimal, U256}; + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct TeamsRequest { + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub league: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub name: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub abbreviation: Vec, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct TagsRequest { + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, + pub include_template: Option, + pub is_carousel: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct TagByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub include_template: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct TagBySlugRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub slug: String, + pub include_template: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct RelatedTagsByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub omit_empty: Option, + pub status: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct RelatedTagsBySlugRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub slug: String, + pub omit_empty: Option, + pub status: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct EventsRequest { + pub limit: Option, + pub offset: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub order: Vec, + pub ascending: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub id: Vec, + #[builder(into)] + pub tag_id: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub exclude_tag_id: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub slug: Vec, + pub tag_slug: Option, + pub related_tags: Option, + pub active: Option, + pub archived: Option, + pub featured: Option, + pub cyom: Option, + pub include_chat: Option, + pub include_template: Option, + pub recurrence: Option, + pub closed: Option, + pub liquidity_min: Option, + pub liquidity_max: Option, + pub volume_min: Option, + pub volume_max: Option, + pub start_date_min: Option>, + pub start_date_max: Option>, + pub end_date_min: Option>, + pub end_date_max: Option>, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct EventByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub include_chat: Option, + pub include_template: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct EventBySlugRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub slug: String, + pub include_chat: Option, + pub include_template: Option, +} + +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct EventTagsRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, +} + +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct MarketsRequest { + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub id: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub slug: Vec, + #[serde_as(as = "Vec")] + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub clob_token_ids: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub condition_ids: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub market_maker_address: Vec
, + pub liquidity_num_min: Option, + pub liquidity_num_max: Option, + pub volume_num_min: Option, + pub volume_num_max: Option, + pub start_date_min: Option>, + pub start_date_max: Option>, + pub end_date_min: Option>, + pub end_date_max: Option>, + #[builder(into)] + pub tag_id: Option, + pub related_tags: Option, + pub cyom: Option, + pub uma_resolution_status: Option, + pub game_id: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub sports_market_types: Vec, + pub rewards_min_size: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub question_ids: Vec, + pub include_tag: Option, + pub closed: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct MarketByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub include_tag: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct MarketBySlugRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub slug: String, + pub include_tag: Option, +} + +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct MarketTagsRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Default, Serialize)] +#[non_exhaustive] +pub struct SeriesListRequest { + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub slug: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub categories_ids: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub categories_labels: Vec, + pub closed: Option, + pub include_chat: Option, + pub recurrence: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct SeriesByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub include_chat: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct CommentsRequest { + pub parent_entity_type: ParentEntityType, + #[builder(into)] + pub parent_entity_id: String, + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, + pub get_positions: Option, + pub holders_only: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct CommentsByIdRequest { + #[serde(skip_serializing)] + #[builder(into)] + pub id: String, + pub get_positions: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct CommentsByUserAddressRequest { + #[serde(skip_serializing)] + pub user_address: Address, + pub limit: Option, + pub offset: Option, + pub order: Option, + pub ascending: Option, +} + +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct PublicProfileRequest { + pub address: Address, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Builder, Serialize)] +#[non_exhaustive] +pub struct SearchRequest { + #[builder(into)] + pub q: String, + pub cache: Option, + pub events_status: Option, + pub limit_per_type: Option, + pub page: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub events_tag: Vec, + pub keep_closed_markets: Option, + pub sort: Option, + pub ascending: Option, + pub search_tags: Option, + pub search_profiles: Option, + pub recurrence: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub exclude_tag_id: Vec, + pub optimized: Option, +} diff --git a/polymarket-client-sdk/src/gamma/types/response.rs b/polymarket-client-sdk/src/gamma/types/response.rs new file mode 100644 index 0000000..4a3beec --- /dev/null +++ b/polymarket-client-sdk/src/gamma/types/response.rs @@ -0,0 +1,746 @@ +#![allow( + clippy::module_name_repetitions, + reason = "Response suffix is intentional for clarity" +)] + +use bon::Builder; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::NoneAsEmptyString; +use serde_with::json::JsonString; +use serde_with::{DisplayFromStr, StringWithSeparator, formats::CommaSeparator, serde_as}; + +use crate::serde_helpers::StringFromAny; +use crate::types::{Address, B256, Decimal, U256}; + +/// Image optimization metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ImageOptimization { + pub id: Option, + pub image_url_source: Option, + pub image_url_optimized: Option, + pub image_size_kb_source: Option, + pub image_size_kb_optimized: Option, + pub image_optimized_complete: Option, + pub image_optimized_last_updated: Option, + #[serde(rename = "relID")] + pub rel_id: Option, + pub field: Option, + pub relname: Option, +} + +/// Pagination information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Pagination { + pub has_more: Option, + pub total_results: Option, +} + +/// Health check response. +pub type HealthResponse = String; + +/// A sports team. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Team { + pub id: i32, + pub name: Option, + pub league: Option, + pub record: Option, + pub logo: Option, + pub abbreviation: Option, + pub alias: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub color: Option, + pub provider_id: Option, +} + +/// Sports metadata information. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SportsMetadata { + pub id: Option, + pub sport: String, + pub image: String, + pub resolution: String, + pub ordering: String, + #[serde_as(as = "StringWithSeparator::")] + pub tags: Vec, + pub series: String, + pub created_at: Option>, +} + +/// Sports market types response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SportsMarketTypesResponse { + pub market_types: Vec, +} + +/// A tag for categorizing content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Tag { + pub id: String, + pub label: Option, + pub slug: Option, + pub force_show: Option, + pub published_at: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub force_hide: Option, + pub is_carousel: Option, + pub requires_translation: Option, +} + +/// A relationship between tags. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RelatedTag { + #[serde_as(as = "StringFromAny")] + pub id: String, + #[serde_as(as = "Option")] + #[serde(rename = "tagID")] + pub tag_id: Option, + #[serde_as(as = "Option")] + #[serde(rename = "relatedTagID")] + pub related_tag_id: Option, + pub rank: Option, +} + +/// A category for organizing content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Category { + pub id: String, + pub label: Option, + pub parent_category: Option, + pub slug: Option, + pub published_at: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +/// An event creator. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct EventCreator { + pub id: String, + pub creator_name: Option, + pub creator_handle: Option, + pub creator_url: Option, + pub creator_image: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +/// A chat/live stream associated with an event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Chat { + pub id: String, + pub channel_id: Option, + pub channel_name: Option, + pub channel_image: Option, + pub live: Option, + pub start_time: Option>, + pub end_time: Option>, +} + +/// A template for creating events/markets. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Template { + pub id: String, + pub event_title: Option, + pub event_slug: Option, + pub event_image: Option, + pub market_title: Option, + pub description: Option, + pub resolution_source: Option, + pub neg_risk: Option, + pub sort_by: Option, + pub show_market_images: Option, + pub series_slug: Option, + pub outcomes: Option, +} + +/// A collection of events. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Collection { + pub id: String, + pub ticker: Option, + pub slug: Option, + pub title: Option, + pub subtitle: Option, + pub collection_type: Option, + pub description: Option, + pub tags: Option, + pub image: Option, + pub icon: Option, + pub header_image: Option, + pub layout: Option, + pub active: Option, + pub closed: Option, + pub archived: Option, + pub new: Option, + pub featured: Option, + pub restricted: Option, + pub is_template: Option, + pub template_variables: Option, + pub published_at: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub comments_enabled: Option, + pub image_optimized: Option, + pub icon_optimized: Option, + pub header_image_optimized: Option, +} + +/// A prediction market event. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Event { + pub id: String, + pub ticker: Option, + pub slug: Option, + pub title: Option, + pub subtitle: Option, + pub description: Option, + pub resolution_source: Option, + pub start_date: Option>, + pub creation_date: Option>, + pub end_date: Option>, + pub image: Option, + pub icon: Option, + pub active: Option, + pub closed: Option, + pub archived: Option, + pub new: Option, + pub featured: Option, + pub restricted: Option, + pub liquidity: Option, + pub volume: Option, + pub open_interest: Option, + pub sort_by: Option, + pub category: Option, + pub subcategory: Option, + pub is_template: Option, + pub template_variables: Option, + #[serde(alias = "published_at")] + pub published_at: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub comments_enabled: Option, + pub competitive: Option, + pub volume_24hr: Option, + pub volume_1wk: Option, + pub volume_1mo: Option, + pub volume_1yr: Option, + pub featured_image: Option, + pub disqus_thread: Option, + pub parent_event: Option, + #[serde_as(as = "Option")] + pub parent_event_id: Option, + pub sportsradar_match_id: Option, + #[serde_as(as = "Option")] + pub turn_provider_id: Option, + pub enable_order_book: Option, + pub liquidity_amm: Option, + pub liquidity_clob: Option, + pub neg_risk: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default, rename = "negRiskMarketID")] + pub neg_risk_market_id: Option, + pub neg_risk_fee_bips: Option, + pub comment_count: Option, + pub image_optimized: Option, + pub icon_optimized: Option, + pub featured_image_optimized: Option, + pub sub_events: Option>, + pub markets: Option>, + pub series: Option>, + pub categories: Option>, + pub collections: Option>, + pub tags: Option>, + pub cyom: Option, + pub closed_time: Option>, + pub show_all_outcomes: Option, + pub show_market_images: Option, + pub automatically_resolved: Option, + pub enable_neg_risk: Option, + pub automatically_active: Option, + pub event_date: Option, + pub start_time: Option>, + pub event_week: Option, + pub series_slug: Option, + pub score: Option, + pub elapsed: Option, + pub period: Option, + pub live: Option, + pub ended: Option, + pub finished_timestamp: Option>, + pub gmp_chart_mode: Option, + pub event_creators: Option>, + pub tweet_count: Option, + pub chats: Option>, + pub featured_order: Option, + pub estimate_value: Option, + pub cant_estimate: Option, + pub estimated_value: Option, + pub templates: Option>, + pub spreads_main_line: Option, + pub totals_main_line: Option, + pub carousel_map: Option, + pub pending_deployment: Option, + pub deploying: Option, + pub deploying_timestamp: Option>, + pub scheduled_deployment_timestamp: Option>, + pub game_status: Option, + pub requires_translation: Option, + pub neg_risk_augmented: Option, + pub game_id: Option, + pub election_type: Option, + pub country_name: Option, + pub color: Option, + pub cumulative_markets: Option, + pub away_team_name: Option, + pub home_team_name: Option, +} + +/// A prediction market. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Market { + pub id: String, + pub question: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub condition_id: Option, + pub slug: Option, + pub twitter_card_image: Option, + pub resolution_source: Option, + pub end_date: Option>, + pub category: Option, + pub amm_type: Option, + pub liquidity: Option, + pub sponsor_name: Option, + pub sponsor_image: Option, + pub start_date: Option>, + pub x_axis_value: Option, + pub y_axis_value: Option, + pub denomination_token: Option, + pub fee: Option, + pub image: Option, + pub icon: Option, + pub lower_bound: Option, + pub upper_bound: Option, + pub description: Option, + #[serde_as(as = "Option")] + pub outcomes: Option>, + #[serde_as(as = "Option")] + pub outcome_prices: Option>, + pub volume: Option, + pub active: Option, + pub market_type: Option, + pub format_type: Option, + pub lower_bound_date: Option, + pub upper_bound_date: Option, + pub closed: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub market_maker_address: Option
, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub closed_time: Option, + pub wide_format: Option, + pub new: Option, + pub mailchimp_tag: Option, + pub featured: Option, + pub archived: Option, + pub resolved_by: Option, + pub restricted: Option, + pub market_group: Option, + pub group_item_title: Option, + pub group_item_threshold: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default, rename = "questionID")] + pub question_id: Option, + pub uma_end_date: Option, + pub enable_order_book: Option, + pub order_price_min_tick_size: Option, + pub order_min_size: Option, + pub uma_resolution_status: Option, + pub curation_order: Option, + pub volume_num: Option, + pub liquidity_num: Option, + pub end_date_iso: Option, + pub start_date_iso: Option, + pub uma_end_date_iso: Option, + pub has_reviewed_dates: Option, + pub ready_for_cron: Option, + pub comments_enabled: Option, + pub volume_24hr: Option, + pub volume_1wk: Option, + pub volume_1mo: Option, + pub volume_1yr: Option, + pub game_start_time: Option, + pub seconds_delay: Option, + #[serde_as(as = "Option")] + pub clob_token_ids: Option>, + pub disqus_thread: Option, + pub short_outcomes: Option, + #[serde(rename = "teamAID")] + pub team_a_id: Option, + #[serde(rename = "teamBID")] + pub team_b_id: Option, + pub uma_bond: Option, + pub uma_reward: Option, + pub fpmm_live: Option, + pub volume_24hr_amm: Option, + pub volume_1wk_amm: Option, + pub volume_1mo_amm: Option, + pub volume_1yr_amm: Option, + pub volume_24hr_clob: Option, + pub volume_1wk_clob: Option, + pub volume_1mo_clob: Option, + pub volume_1yr_clob: Option, + pub volume_amm: Option, + pub volume_clob: Option, + pub liquidity_amm: Option, + pub liquidity_clob: Option, + pub maker_base_fee: Option, + pub taker_base_fee: Option, + pub maker_rebates_fee_share_bps: Option, + pub custom_liveness: Option, + pub accepting_orders: Option, + pub notifications_enabled: Option, + pub score: Option, + pub image_optimized: Option, + pub icon_optimized: Option, + pub events: Option>, + pub categories: Option>, + pub tags: Option>, + pub creator: Option, + pub ready: Option, + pub funded: Option, + pub past_slugs: Option, + pub ready_timestamp: Option>, + pub funded_timestamp: Option>, + pub accepting_orders_timestamp: Option>, + pub competitive: Option, + pub rewards_min_size: Option, + pub rewards_max_spread: Option, + pub spread: Option, + pub automatically_resolved: Option, + pub one_day_price_change: Option, + pub one_hour_price_change: Option, + pub one_week_price_change: Option, + pub one_month_price_change: Option, + pub one_year_price_change: Option, + pub last_trade_price: Option, + pub best_bid: Option, + pub best_ask: Option, + pub automatically_active: Option, + pub clear_book_on_start: Option, + pub chart_color: Option, + pub series_color: Option, + pub show_gmp_series: Option, + pub show_gmp_outcome: Option, + pub manual_activation: Option, + pub neg_risk_other: Option, + pub game_id: Option, + pub group_item_range: Option, + pub sports_market_type: Option, + pub line: Option, + pub uma_resolution_statuses: Option, + pub pending_deployment: Option, + pub deploying: Option, + pub deploying_timestamp: Option>, + pub scheduled_deployment_timestamp: Option>, + pub rfq_enabled: Option, + pub event_start_time: Option>, + #[serde(alias = "submitted_by")] + pub submitted_by: Option, + pub requires_translation: Option, + pub pager_duty_notification_enabled: Option, + pub approved: Option, + pub cyom: Option, + pub fees_enabled: Option, + pub holding_rewards_enabled: Option, + pub neg_risk: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default, rename = "negRiskRequestID")] + pub neg_risk_request_id: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default, rename = "negRiskMarketID")] + pub neg_risk_market_id: Option, + pub sent_discord: Option, + #[serde_as(as = "Option")] + pub twitter_card_last_refreshed: Option, + pub twitter_card_location: Option, + pub twitter_card_last_validated: Option, + pub clob_rewards: Option>, + pub category_mailchimp_tag: Option, + pub subcategory: Option, +} + +/// CLOB rewards configuration for a market. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ClobReward { + pub id: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub asset_address: Option
, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub condition_id: Option, + pub start_date: Option, + pub end_date: Option, + pub rewards_amount: Option, + pub rewards_daily_rate: Option, +} + +/// A series of related events. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Series { + pub id: String, + pub ticker: Option, + pub slug: Option, + pub title: Option, + pub subtitle: Option, + pub series_type: Option, + pub recurrence: Option, + pub description: Option, + pub image: Option, + pub icon: Option, + pub layout: Option, + pub active: Option, + pub closed: Option, + pub archived: Option, + pub new: Option, + pub featured: Option, + pub restricted: Option, + pub is_template: Option, + pub template_variables: Option, + pub published_at: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub comments_enabled: Option, + pub competitive: Option, + pub volume_24hr: Option, + pub volume: Option, + pub liquidity: Option, + pub start_date: Option>, + #[serde(rename = "pythTokenID")] + pub pyth_token_id: Option, + pub cg_asset_name: Option, + pub score: Option, + pub events: Option>, + pub collections: Option>, + pub categories: Option>, + pub tags: Option>, + pub comment_count: Option, + pub chats: Option>, + pub requires_translation: Option, +} + +/// A comment position. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CommentPosition { + pub token_id: Option, + pub position_size: Option, +} + +/// A comment profile. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct CommentProfile { + pub name: Option, + pub pseudonym: Option, + pub display_username_public: Option, + pub bio: Option, + pub is_mod: Option, + pub is_creator: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub proxy_wallet: Option
, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub base_address: Option
, + pub profile_image: Option, + pub profile_image_optimized: Option, + pub positions: Option>, +} + +/// A reaction to a comment. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Reaction { + pub id: String, + #[serde(rename = "commentID")] + pub comment_id: Option, + pub reaction_type: Option, + pub icon: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub user_address: Option
, + pub created_at: Option>, + pub profile: Option, +} + +/// A comment on an event, series, or market. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Comment { + pub id: String, + pub body: Option, + pub parent_entity_type: Option, + #[serde(rename = "parentEntityID")] + pub parent_entity_id: Option, + #[serde(rename = "parentCommentID")] + pub parent_comment_id: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub user_address: Option
, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub reply_address: Option
, + pub created_at: Option>, + pub updated_at: Option>, + pub profile: Option, + pub reactions: Option>, + pub report_count: Option, + pub reaction_count: Option, +} + +/// A user associated with a public profile. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[non_exhaustive] +pub struct PublicProfileUser { + pub id: Option, + pub creator: Option, + #[serde(rename = "mod")] + pub is_mod: Option, +} + +/// Public profile response. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct PublicProfile { + pub created_at: Option>, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub proxy_wallet: Option
, + pub profile_image: Option, + pub display_username_public: Option, + pub bio: Option, + pub pseudonym: Option, + pub name: Option, + pub users: Option>, + pub x_username: Option, + pub verified_badge: Option, +} + +/// A search tag result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SearchTag { + pub id: Option, + pub label: Option, + pub slug: Option, + pub event_count: Option, +} + +/// A profile in search results. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Profile { + pub id: String, + pub name: Option, + pub user: Option, + pub referral: Option, + pub created_by: Option, + pub updated_by: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub utm_source: Option, + pub utm_medium: Option, + pub utm_campaign: Option, + pub utm_content: Option, + pub utm_term: Option, + pub wallet_activated: Option, + pub pseudonym: Option, + pub display_username_public: Option, + pub profile_image: Option, + pub bio: Option, + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub proxy_wallet: Option
, + pub profile_image_optimized: Option, + pub is_close_only: Option, + pub is_cert_req: Option, + pub cert_req_date: Option>, +} + +/// Search results. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] +#[non_exhaustive] +pub struct SearchResults { + pub events: Option>, + pub tags: Option>, + pub profiles: Option>, + pub pagination: Option, +} diff --git a/polymarket-client-sdk/src/lib.rs b/polymarket-client-sdk/src/lib.rs new file mode 100644 index 0000000..96d3cb3 --- /dev/null +++ b/polymarket-client-sdk/src/lib.rs @@ -0,0 +1,404 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] + +pub mod auth; +#[cfg(feature = "bridge")] +pub mod bridge; +#[cfg(feature = "clob")] +pub mod clob; +#[cfg(feature = "ctf")] +pub mod ctf; +#[cfg(feature = "data")] +pub mod data; +pub mod error; +#[cfg(feature = "gamma")] +pub mod gamma; +#[cfg(feature = "rtds")] +pub mod rtds; +pub(crate) mod serde_helpers; +pub mod types; +#[cfg(any(feature = "ws", feature = "rtds"))] +pub mod ws; + +use std::fmt::Write as _; + +use alloy::primitives::ChainId; +use alloy::primitives::{B256, b256, keccak256}; +use phf::phf_map; +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +use reqwest::{Request, StatusCode, header::HeaderMap}; +use serde::Serialize; +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +use serde::de::DeserializeOwned; + +use crate::error::Error; +use crate::types::{Address, address}; + +pub type Result = std::result::Result; + +/// [`ChainId`] for Polygon mainnet +pub const POLYGON: ChainId = 137; + +/// [`ChainId`] for Polygon testnet +pub const AMOY: ChainId = 80002; + +pub const PRIVATE_KEY_VAR: &str = "POLYMARKET_PRIVATE_KEY"; + +/// Timestamp in seconds since [`std::time::UNIX_EPOCH`] +pub(crate) type Timestamp = i64; + +static CONFIG: phf::Map = phf_map! { + 137_u64 => ContractConfig { + exchange: address!("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"), + collateral: address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), + conditional_tokens: address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"), + neg_risk_adapter: None, + }, + 80002_u64 => ContractConfig { + exchange: address!("0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40"), + collateral: address!("0x9c4e1703476e875070ee25b56a58b008cfb8fa78"), + conditional_tokens: address!("0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB"), + neg_risk_adapter: None, + }, +}; + +static NEG_RISK_CONFIG: phf::Map = phf_map! { + 137_u64 => ContractConfig { + exchange: address!("0xC5d563A36AE78145C45a50134d48A1215220f80a"), + collateral: address!("0x2791bca1f2de4661ed88a30c99a7a9449aa84174"), + conditional_tokens: address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"), + neg_risk_adapter: Some(address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")), + }, + 80002_u64 => ContractConfig { + exchange: address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"), + collateral: address!("0x9c4e1703476e875070ee25b56a58b008cfb8fa78"), + conditional_tokens: address!("0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB"), + neg_risk_adapter: Some(address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")), + }, +}; + +// Wallet contract configurations for CREATE2 address derivation +// Source: https://github.com/Polymarket/builder-relayer-client +static WALLET_CONFIG: phf::Map = phf_map! { + 137_u64 => WalletContractConfig { + proxy_factory: Some(address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052")), + safe_factory: address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"), + }, + 80002_u64 => WalletContractConfig { + // Proxy factory unsupported on Amoy testnet + proxy_factory: None, + safe_factory: address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"), + }, +}; + +/// Init code hash for Polymarket Proxy wallets (EIP-1167 minimal proxy) +const PROXY_INIT_CODE_HASH: B256 = + b256!("0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"); + +/// Init code hash for Gnosis Safe wallets +const SAFE_INIT_CODE_HASH: B256 = + b256!("0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf"); + +/// Helper struct to group the relevant deployed contract addresses +#[non_exhaustive] +#[derive(Debug)] +pub struct ContractConfig { + pub exchange: Address, + pub collateral: Address, + pub conditional_tokens: Address, + /// The Neg Risk Adapter contract address. Only present for neg-risk market configs. + /// Users must approve this contract for token transfers to trade in neg-risk markets. + pub neg_risk_adapter: Option
, +} + +/// Wallet contract configuration for CREATE2 address derivation +#[non_exhaustive] +#[derive(Debug)] +pub struct WalletContractConfig { + /// Factory contract for Polymarket Proxy wallets (Magic/email wallets). + /// Not available on all networks (e.g., Amoy testnet). + pub proxy_factory: Option
, + /// Factory contract for Gnosis Safe wallets. + pub safe_factory: Address, +} + +/// Given a `chain_id` and `is_neg_risk`, return the relevant [`ContractConfig`] +#[must_use] +pub fn contract_config(chain_id: ChainId, is_neg_risk: bool) -> Option<&'static ContractConfig> { + if is_neg_risk { + NEG_RISK_CONFIG.get(&chain_id) + } else { + CONFIG.get(&chain_id) + } +} + +/// Returns the wallet contract configuration for the given chain ID. +#[must_use] +pub fn wallet_contract_config(chain_id: ChainId) -> Option<&'static WalletContractConfig> { + WALLET_CONFIG.get(&chain_id) +} + +/// Derives the Polymarket Proxy wallet address for an EOA using CREATE2. +/// +/// This is the deterministic address of the EIP-1167 minimal proxy wallet +/// that Polymarket deploys for Magic/email wallet users. +/// +/// # Arguments +/// * `eoa_address` - The externally owned account (EOA) address +/// * `chain_id` - The chain ID (e.g., 137 for Polygon mainnet) +/// +/// # Returns +/// * `Some(Address)` - The derived proxy wallet address +/// * `None` - If the chain doesn't support proxy wallets or config is missing +#[must_use] +pub fn derive_proxy_wallet(eoa_address: Address, chain_id: ChainId) -> Option
{ + let config = wallet_contract_config(chain_id)?; + let factory = config.proxy_factory?; + + // Salt is keccak256(encodePacked(address)) - address is 20 bytes, no padding + let salt = keccak256(eoa_address); + + Some(factory.create2(salt, PROXY_INIT_CODE_HASH)) +} + +/// Derives the Gnosis Safe wallet address for an EOA using CREATE2. +/// +/// This is the deterministic address of the 1-of-1 Gnosis Safe multisig +/// that Polymarket deploys for browser wallet users. +/// +/// # Arguments +/// * `eoa_address` - The externally owned account (EOA) address +/// * `chain_id` - The chain ID (e.g., 137 for Polygon mainnet) +/// +/// # Returns +/// * `Some(Address)` - The derived Safe wallet address +/// * `None` - If the chain config is missing +#[must_use] +pub fn derive_safe_wallet(eoa_address: Address, chain_id: ChainId) -> Option
{ + let config = wallet_contract_config(chain_id)?; + let factory = config.safe_factory; + + // Salt is keccak256(encodeAbiParameters(address)) - address padded to 32 bytes + // ABI encoding pads address to 32 bytes (left-padded with zeros) + let mut padded = [0_u8; 32]; + padded[12..].copy_from_slice(eoa_address.as_slice()); + let salt = keccak256(padded); + + Some(factory.create2(salt, SAFE_INIT_CODE_HASH)) +} + +/// Trait for converting request types to URL query parameters. +/// +/// This trait is automatically implemented for all types that implement [`Serialize`]. +/// It uses [`serde_html_form`] to serialize the struct fields into a query string. +/// Arrays are serialized as repeated keys (`key=val1&key=val2`). +pub trait ToQueryParams: Serialize { + /// Converts the request to a URL query string. + /// + /// Returns an empty string if no parameters are set, otherwise returns + /// a string starting with `?` followed by URL-encoded key-value pairs. + /// Also uses an optional cursor as a parameter, if provided. + fn query_params(&self, next_cursor: Option<&str>) -> String { + let mut params = serde_html_form::to_string(self) + .inspect_err(|e| { + #[cfg(feature = "tracing")] + tracing::error!("Unable to convert to URL-encoded string {e:?}"); + #[cfg(not(feature = "tracing"))] + let _: &serde_html_form::ser::Error = e; + }) + .unwrap_or_default(); + + if let Some(cursor) = next_cursor { + if !params.is_empty() { + params.push('&'); + } + let _ = write!(params, "next_cursor={cursor}"); + } + + if params.is_empty() { + String::new() + } else { + format!("?{params}") + } + } +} + +impl ToQueryParams for T {} + +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +#[cfg_attr( + feature = "tracing", + tracing::instrument( + level = "debug", + skip(client, request, headers), + fields( + method = %request.method(), + path = request.url().path(), + status_code + ) + ) +)] +async fn request( + client: &reqwest::Client, + mut request: Request, + headers: Option, +) -> Result { + let method = request.method().clone(); + let path = request.url().path().to_owned(); + + if let Some(h) = headers { + *request.headers_mut() = h; + } + + let response = client.execute(request).await?; + let status_code = response.status(); + + #[cfg(feature = "tracing")] + tracing::Span::current().record("status_code", status_code.as_u16()); + + if !status_code.is_success() { + let message = response.text().await.unwrap_or_default(); + + #[cfg(feature = "tracing")] + tracing::warn!( + status = %status_code, + method = %method, + path = %path, + message = %message, + "API request failed" + ); + + return Err(Error::status(status_code, method, path, message)); + } + + let json_value = response.json::().await?; + let response_data: Option = serde_helpers::deserialize_with_warnings(json_value)?; + + if let Some(response) = response_data { + Ok(response) + } else { + #[cfg(feature = "tracing")] + tracing::warn!(method = %method, path = %path, "API resource not found"); + Err(Error::status( + StatusCode::NOT_FOUND, + method, + path, + "Unable to find requested resource", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_contains_80002() { + let cfg = contract_config(AMOY, false).expect("missing config"); + assert_eq!( + cfg.exchange, + address!("0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40") + ); + } + + #[test] + fn config_contains_80002_neg() { + let cfg = contract_config(AMOY, true).expect("missing config"); + assert_eq!( + cfg.exchange, + address!("0xd91e80cf2e7be2e162c6513ced06f1dd0da35296") + ); + } + + #[test] + fn wallet_contract_config_polygon() { + let cfg = wallet_contract_config(POLYGON).expect("missing config"); + assert_eq!( + cfg.proxy_factory, + Some(address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052")) + ); + assert_eq!( + cfg.safe_factory, + address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b") + ); + } + + #[test] + fn wallet_contract_config_amoy() { + let cfg = wallet_contract_config(AMOY).expect("missing config"); + // Proxy factory not supported on Amoy + assert_eq!(cfg.proxy_factory, None); + assert_eq!( + cfg.safe_factory, + address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b") + ); + } + + #[test] + fn derive_safe_wallet_polygon() { + // Test address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Foundry/Anvil test key) + let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let safe_addr = derive_safe_wallet(eoa, POLYGON).expect("derivation failed"); + + // This is the deterministic Safe address for this EOA on Polygon + assert_eq!( + safe_addr, + address!("0xd93b25Cb943D14d0d34FBAf01fc93a0F8b5f6e47") + ); + } + + #[test] + fn derive_proxy_wallet_polygon() { + // Test address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Foundry/Anvil test key) + let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let proxy_addr = derive_proxy_wallet(eoa, POLYGON).expect("derivation failed"); + + // This is the deterministic Proxy address for this EOA on Polygon + assert_eq!( + proxy_addr, + address!("0x365f0cA36ae1F641E02Fe3b7743673DA42A13a70") + ); + } + + #[test] + fn derive_proxy_wallet_amoy_not_supported() { + let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + // Proxy wallet derivation should fail on Amoy (no proxy factory) + assert!(derive_proxy_wallet(eoa, AMOY).is_none()); + } + + #[test] + fn derive_safe_wallet_amoy() { + let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + // Safe wallet derivation should work on Amoy + let safe_addr = derive_safe_wallet(eoa, AMOY).expect("derivation failed"); + + // Same Safe factory on both networks, so same derived address + assert_eq!( + safe_addr, + address!("0xd93b25Cb943D14d0d34FBAf01fc93a0F8b5f6e47") + ); + } + + #[test] + fn derive_wallet_unsupported_chain() { + let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + // Unsupported chain should return None + assert!(derive_proxy_wallet(eoa, 1).is_none()); + assert!(derive_safe_wallet(eoa, 1).is_none()); + } +} diff --git a/polymarket-client-sdk/src/rtds/client.rs b/polymarket-client-sdk/src/rtds/client.rs new file mode 100644 index 0000000..25ba383 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/client.rs @@ -0,0 +1,331 @@ +use std::sync::Arc; + +use futures::Stream; +use futures::StreamExt as _; + +use super::subscription::{SimpleParser, SubscriptionManager, TopicType}; +use super::types::request::Subscription; +use super::types::response::{ChainlinkPrice, Comment, CommentType, CryptoPrice, RtdsMessage}; +use crate::Result; +use crate::auth::state::{Authenticated, State, Unauthenticated}; +use crate::auth::{Credentials, Normal}; +use crate::error::Error; +use crate::types::Address; +use crate::ws::ConnectionManager; +use crate::ws::config::Config; +use crate::ws::connection::ConnectionState; + +/// RTDS (Real-Time Data Socket) client for streaming Polymarket data. +/// +/// - [`Client`]: All streams, comments without auth +/// - [`Client>`]: All streams, comments with CLOB auth +/// +/// # Examples +/// +/// ```rust, no_run +/// use polymarket_client_sdk::rtds::Client; +/// use futures::StreamExt; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let client = Client::default(); +/// +/// // Subscribe to BTC and ETH prices from Binance +/// let symbols = vec!["btcusdt".to_owned(), "ethusdt".to_owned()]; +/// let stream = client.subscribe_crypto_prices(Some(symbols))?; +/// let mut stream = Box::pin(stream); +/// +/// while let Some(price) = stream.next().await { +/// println!("Price: {:?}", price?); +/// } +/// +/// Ok(()) +/// } +/// ``` +#[derive(Clone)] +pub struct Client { + inner: Arc>, +} + +impl Default for Client { + fn default() -> Self { + Self::new("wss://ws-live-data.polymarket.com", Config::default()) + .expect("RTDS client with default endpoint should succeed") + } +} + +struct ClientInner { + /// Current state of the client + state: S, + /// Configuration for the RTDS connection + config: Config, + /// Base endpoint for the WebSocket + endpoint: String, + /// Connection manager for the WebSocket + connection: ConnectionManager, + /// Subscription manager for handling subscriptions + subscriptions: Arc, +} + +impl Client { + /// Create a new unauthenticated RTDS client with the specified endpoint and configuration. + pub fn new(endpoint: &str, config: Config) -> Result { + let connection = ConnectionManager::new(endpoint.to_owned(), config.clone(), SimpleParser)?; + let subscriptions = Arc::new(SubscriptionManager::new(connection.clone())); + + // Start reconnection handler to re-subscribe on connection recovery + subscriptions.start_reconnection_handler(); + + Ok(Self { + inner: Arc::new(ClientInner { + state: Unauthenticated, + config, + endpoint: endpoint.to_owned(), + connection, + subscriptions, + }), + }) + } + + /// Authenticate with CLOB credentials. + /// + /// Returns an authenticated client that can subscribe to comments with auth. + pub fn authenticate( + self, + address: Address, + credentials: Credentials, + ) -> Result>> { + let inner = Arc::into_inner(self.inner).ok_or(Error::validation( + "Cannot authenticate while other references to this client exist", + ))?; + + Ok(Client { + inner: Arc::new(ClientInner { + state: Authenticated { + address, + credentials, + kind: Normal, + }, + config: inner.config, + endpoint: inner.endpoint, + connection: inner.connection, + subscriptions: inner.subscriptions, + }), + }) + } + + /// Subscribe to comment events (unauthenticated). + /// + /// # Arguments + /// + /// * `comment_type` - Optional comment event type to filter + pub fn subscribe_comments( + &self, + comment_type: Option, + ) -> Result>> { + let subscription = Subscription::comments(comment_type); + let stream = self.inner.subscriptions.subscribe(subscription)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(msg) => msg.as_comment().map(Ok), + Err(e) => Some(Err(e)), + } + })) + } +} + +// Methods available in any state +impl Client { + /// Subscribes to real-time cryptocurrency price updates from Binance. + /// + /// Returns a stream of cryptocurrency prices for the specified trading pairs. + /// If no symbols are provided, subscribes to all available cryptocurrency pairs. + /// Prices are sourced from Binance and updated in real-time. + /// + /// # Arguments + /// + /// * `symbols` - Optional list of trading pair symbols (e.g., `["BTCUSDT", "ETHUSDT"]`). + /// If `None`, subscribes to all available pairs. + /// + /// # Errors + /// + /// Returns an error if the subscription cannot be created or the WebSocket + /// connection fails. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::rtds::Client; + /// use polymarket_client_sdk::ws::config::Config; + /// use futures::StreamExt; + /// use tokio::pin; + /// + /// # async fn example() -> Result<(), Box> { + /// let client = Client::new("wss://rtds.polymarket.com", Config::default())?; + /// let stream = client.subscribe_crypto_prices(Some(vec!["BTCUSDT".to_string()]))?; + /// + /// pin!(stream); + /// + /// while let Some(price_result) = stream.next().await { + /// let price = price_result?; + /// println!("BTC Price: ${}", price.value); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn subscribe_crypto_prices( + &self, + symbols: Option>, + ) -> Result>> { + let subscription = Subscription::crypto_prices(symbols); + let stream = self.inner.subscriptions.subscribe(subscription)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(msg) => msg.as_crypto_price().map(Ok), + Err(e) => Some(Err(e)), + } + })) + } + + /// Subscribe to Chainlink price feed updates. + pub fn subscribe_chainlink_prices( + &self, + symbol: Option, + ) -> Result>> { + let subscription = Subscription::chainlink_prices(symbol); + let stream = self.inner.subscriptions.subscribe(subscription)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(msg) => msg.as_chainlink_price().map(Ok), + Err(e) => Some(Err(e)), + } + })) + } + + /// Subscribe to raw RTDS messages for a custom topic/type combination. + pub fn subscribe_raw( + &self, + subscription: Subscription, + ) -> Result>> { + self.inner.subscriptions.subscribe(subscription) + } + + /// Get the current connection state. + /// + /// # Returns + /// + /// The current [`ConnectionState`] of the WebSocket connection. + #[must_use] + pub fn connection_state(&self) -> ConnectionState { + self.inner.connection.state() + } + + /// Get the number of active subscriptions. + /// + /// # Returns + /// + /// The count of active subscriptions managed by this client. + #[must_use] + pub fn subscription_count(&self) -> usize { + self.inner.subscriptions.subscription_count() + } + + /// Unsubscribe from Binance crypto price updates. + /// + /// This decrements the reference count for the `crypto_prices` topic. Only sends + /// an unsubscribe request to the server when no other streams are using this topic. + /// + /// # Errors + /// + /// Returns an error if the unsubscribe request fails. + /// + /// # Example + /// + /// ```no_run + /// use polymarket_client_sdk::rtds::Client; + /// + /// # fn example() -> Result<(), Box> { + /// let client = Client::default(); + /// let _stream = client.subscribe_crypto_prices(None)?; + /// // Later... + /// client.unsubscribe_crypto_prices()?; + /// # Ok(()) + /// # } + /// ``` + pub fn unsubscribe_crypto_prices(&self) -> Result<()> { + let topic = TopicType::new("crypto_prices".to_owned(), "update".to_owned()); + self.inner.subscriptions.unsubscribe(&[topic]) + } + + /// Unsubscribe from Chainlink price feed updates. + /// + /// This decrements the reference count for the chainlink topic. Only sends + /// an unsubscribe request to the server when no other streams are using this topic. + /// + /// # Errors + /// + /// Returns an error if the unsubscribe request fails. + pub fn unsubscribe_chainlink_prices(&self) -> Result<()> { + let topic = TopicType::new("crypto_prices_chainlink".to_owned(), "*".to_owned()); + self.inner.subscriptions.unsubscribe(&[topic]) + } + + /// Unsubscribe from comment events. + /// + /// # Arguments + /// + /// * `comment_type` - The comment type to unsubscribe from. Use `None` for wildcard (`*`). + pub fn unsubscribe_comments(&self, comment_type: Option) -> Result<()> { + let msg_type = comment_type.map_or("*".to_owned(), |t| { + serde_json::to_string(&t) + .ok() + .and_then(|s| s.trim_matches('"').to_owned().into()) + .unwrap_or_else(|| "*".to_owned()) + }); + let topic = TopicType::new("comments".to_owned(), msg_type); + self.inner.subscriptions.unsubscribe(&[topic]) + } +} + +impl Client> { + /// Subscribe to comment events with CLOB authentication. + /// + /// # Arguments + /// + /// * `comment_type` - Optional comment event type to filter + pub fn subscribe_comments( + &self, + comment_type: Option, + ) -> Result>> { + let subscription = Subscription::comments(comment_type) + .with_clob_auth(self.inner.state.credentials.clone()); + let stream = self.inner.subscriptions.subscribe(subscription)?; + + Ok(stream.filter_map(|msg_result| async move { + match msg_result { + Ok(msg) => msg.as_comment().map(Ok), + Err(e) => Some(Err(e)), + } + })) + } + + /// Deauthenticate and return to unauthenticated state. + pub fn deauthenticate(self) -> Result> { + let inner = Arc::into_inner(self.inner).ok_or(Error::validation( + "Cannot deauthenticate while other references to this client exist", + ))?; + + Ok(Client { + inner: Arc::new(ClientInner { + state: Unauthenticated, + config: inner.config, + endpoint: inner.endpoint, + connection: inner.connection, + subscriptions: inner.subscriptions, + }), + }) + } +} diff --git a/polymarket-client-sdk/src/rtds/error.rs b/polymarket-client-sdk/src/rtds/error.rs new file mode 100644 index 0000000..94b8a79 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/error.rs @@ -0,0 +1,66 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Error types include the module name to indicate their scope" +)] + +use std::error::Error as StdError; +use std::fmt; + +/// RTDS WebSocket error variants. +#[non_exhaustive] +#[derive(Debug)] +pub enum RtdsError { + /// Error connecting to or communicating with the WebSocket server + Connection(tokio_tungstenite::tungstenite::Error), + /// Error parsing a WebSocket message + MessageParse(serde_json::Error), + /// Subscription request failed + SubscriptionFailed(String), + /// Authentication failed for protected topic + AuthenticationFailed, + /// WebSocket connection was closed + ConnectionClosed, + /// Operation timed out + Timeout, + /// Received an invalid or unexpected message + InvalidMessage(String), + /// Subscription stream lagged and missed messages + Lagged { + /// Number of messages that were missed + count: u64, + }, +} + +impl fmt::Display for RtdsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connection(err) => write!(f, "RTDS WebSocket connection error: {err}"), + Self::MessageParse(err) => write!(f, "Failed to parse RTDS message: {err}"), + Self::SubscriptionFailed(reason) => write!(f, "RTDS subscription failed: {reason}"), + Self::AuthenticationFailed => write!(f, "RTDS WebSocket authentication failed"), + Self::ConnectionClosed => write!(f, "RTDS WebSocket connection closed"), + Self::Timeout => write!(f, "RTDS WebSocket operation timed out"), + Self::InvalidMessage(msg) => write!(f, "Invalid RTDS message: {msg}"), + Self::Lagged { count } => { + write!(f, "RTDS subscription lagged, missed {count} messages") + } + } + } +} + +impl StdError for RtdsError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::Connection(err) => Some(err), + Self::MessageParse(err) => Some(err), + _ => None, + } + } +} + +// Integration with main Error type +impl From for crate::error::Error { + fn from(err: RtdsError) -> Self { + crate::error::Error::with_source(crate::error::Kind::WebSocket, err) + } +} diff --git a/polymarket-client-sdk/src/rtds/mod.rs b/polymarket-client-sdk/src/rtds/mod.rs new file mode 100644 index 0000000..616711c --- /dev/null +++ b/polymarket-client-sdk/src/rtds/mod.rs @@ -0,0 +1,53 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Re-exported names intentionally match their modules for API clarity" +)] + +//! Real-Time Data Socket (RTDS) client for streaming Polymarket data. +//! +//! **Feature flag:** `rtds` (required to use this module) +//! +//! This module provides a WebSocket-based client for subscribing to real-time +//! data streams from Polymarket's RTDS service. +//! +//! # Available Streams +//! +//! - **Crypto Prices (Binance)**: Real-time cryptocurrency price data from Binance +//! - **Crypto Prices (Chainlink)**: Price data from Chainlink oracle networks +//! - **Comments**: Comment events including creations, removals, and reactions +//! +//! # Example +//! +//! ```rust, no_run +//! use polymarket_client_sdk::rtds::Client; +//! use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let client = Client::default(); +//! +//! // Subscribe to BTC prices +//! let stream = client.subscribe_crypto_prices(Some(vec!["btcusdt".to_owned()]))?; +//! let mut stream = Box::pin(stream); +//! +//! while let Some(price) = stream.next().await { +//! println!("BTC Price: {:?}", price?); +//! } +//! +//! Ok(()) +//! } +//! ``` + +pub mod client; +pub mod error; +pub mod subscription; +pub mod types; + +// Re-export commonly used types +pub use client::Client; +pub use error::RtdsError; +pub use subscription::SubscriptionInfo; +pub use types::request::{Subscription, SubscriptionAction, SubscriptionRequest}; +pub use types::response::{ + ChainlinkPrice, Comment, CommentProfile, CommentType, CryptoPrice, RtdsMessage, +}; diff --git a/polymarket-client-sdk/src/rtds/subscription.rs b/polymarket-client-sdk/src/rtds/subscription.rs new file mode 100644 index 0000000..37d8521 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/subscription.rs @@ -0,0 +1,326 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Subscription types deliberately include the module name for clarity" +)] + +use std::sync::{Arc, PoisonError, RwLock}; +use std::time::Instant; + +use async_stream::try_stream; +use dashmap::{DashMap, Entry}; +use futures::Stream; +use tokio::sync::broadcast::error::RecvError; + +use super::error::RtdsError; +use super::types::request::{Subscription, SubscriptionRequest}; +use super::types::response::{RtdsMessage, parse_messages}; +use crate::Result; +use crate::auth::Credentials; +use crate::ws::ConnectionManager; +use crate::ws::connection::ConnectionState; + +#[non_exhaustive] +#[derive(Clone)] +pub struct SimpleParser; + +impl crate::ws::traits::MessageParser for SimpleParser { + fn parse(&self, bytes: &[u8]) -> Result> { + parse_messages(bytes) + } +} + +/// Unique identifier for a topic/type subscription combination. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TopicType { + /// Topic name (e.g., `crypto_prices`, `comments`) + pub topic: String, + /// Message type (e.g., `update`, `comment_created`, `*`) + pub msg_type: String, +} + +impl TopicType { + /// Create a new topic/type identifier. + #[must_use] + pub fn new(topic: String, msg_type: String) -> Self { + Self { topic, msg_type } + } +} + +/// Information about an active subscription. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SubscriptionInfo { + /// Topic and message type this subscription targets + pub topic_type: TopicType, + /// Optional filters for this subscription + pub filters: Option, + /// CLOB authentication if required + pub clob_auth: Option, + /// When the subscription was created + pub created_at: Instant, +} + +/// Manages active subscriptions and routes messages to subscribers. +pub struct SubscriptionManager { + connection: ConnectionManager, + active_subs: DashMap, + /// Subscribed topics with reference counts (for multiplexing) + subscribed_topics: DashMap, + last_auth: RwLock>, +} + +impl SubscriptionManager { + /// Create a new subscription manager. + #[must_use] + pub fn new(connection: ConnectionManager) -> Self { + Self { + connection, + active_subs: DashMap::new(), + subscribed_topics: DashMap::new(), + last_auth: RwLock::new(None), + } + } + + /// Start the reconnection handler that re-subscribes on connection recovery. + pub fn start_reconnection_handler(self: &Arc) { + let this = Arc::clone(self); + + tokio::spawn(async move { + let mut state_rx = this.connection.state_receiver(); + let mut was_connected = state_rx.borrow().is_connected(); + + loop { + // Wait for next state change + if state_rx.changed().await.is_err() { + // Channel closed, connection manager is gone + break; + } + + let state = *state_rx.borrow_and_update(); + + match state { + ConnectionState::Connected { .. } => { + if was_connected { + // Reconnect to subscriptions + #[cfg(feature = "tracing")] + tracing::debug!("RTDS reconnected, re-establishing subscriptions"); + this.resubscribe_all(); + } + was_connected = true; + } + ConnectionState::Disconnected => { + // Connection permanently closed + break; + } + _ => { + // Other states are no-op + } + } + } + }); + } + + /// Re-send subscription requests for all tracked topics. + fn resubscribe_all(&self) { + // Get stored auth for re-subscription on reconnect. + // We can recover from poisoned lock because Option has no inconsistent intermediate state. + let auth = self + .last_auth + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + + let subscriptions: Vec = self + .active_subs + .iter() + .map(|entry| { + let info = entry.value(); + let mut sub = Subscription { + topic: info.topic_type.topic.clone(), + msg_type: info.topic_type.msg_type.clone(), + filters: info.filters.clone(), + clob_auth: None, + }; + // Apply stored auth if subscription originally had auth + if info.clob_auth.is_some() + && let Some(creds) = &auth + { + sub = sub.with_clob_auth(creds.clone()); + } + sub + }) + .collect(); + + if subscriptions.is_empty() { + return; + } + + #[cfg(feature = "tracing")] + tracing::debug!(count = subscriptions.len(), "Re-subscribing to RTDS topics"); + + let request = SubscriptionRequest::subscribe(subscriptions); + if let Err(e) = self.connection.send(&request) { + #[cfg(feature = "tracing")] + tracing::warn!(%e, "Failed to re-subscribe to RTDS topics"); + #[cfg(not(feature = "tracing"))] + let _: &crate::error::Error = &e; + } + } + + /// Subscribe to a topic with the given configuration. + #[expect( + clippy::needless_pass_by_value, + reason = "Subscription is consumed to build SubscriptionInfo" + )] + pub fn subscribe( + &self, + subscription: Subscription, + ) -> Result>> { + let topic_type = TopicType::new(subscription.topic.clone(), subscription.msg_type.clone()); + + // Store auth for re-subscription on reconnect. + // We can recover from poisoned lock because Option has no inconsistent intermediate state. + if let Some(auth) = &subscription.clob_auth { + *self + .last_auth + .write() + .unwrap_or_else(PoisonError::into_inner) = Some(auth.clone()); + } + + // Increment refcount or insert new topic with refcount=1 + // Using Entry API to atomically check and update, with send inside the guard + // to prevent TOCTOU race between refcount check and network send + match self.subscribed_topics.entry(topic_type.clone()) { + Entry::Occupied(mut entry) => { + *entry.get_mut() += 1; + #[cfg(feature = "tracing")] + tracing::debug!( + topic = %subscription.topic, + msg_type = %subscription.msg_type, + "RTDS topic already subscribed, multiplexing" + ); + } + Entry::Vacant(entry) => { + #[cfg(feature = "tracing")] + tracing::debug!( + topic = %subscription.topic, + msg_type = %subscription.msg_type, + "Subscribing to RTDS topic" + ); + + // Send subscribe request while holding the entry lock to prevent + // a concurrent unsubscribe from racing with us + let request = SubscriptionRequest::subscribe(vec![subscription.clone()]); + self.connection.send(&request)?; + // Only insert after successful send + entry.insert(1); + } + } + + // Register subscription info + let sub_id = format!("{}:{}", topic_type.topic, topic_type.msg_type); + self.active_subs.insert( + sub_id, + SubscriptionInfo { + topic_type: topic_type.clone(), + filters: subscription.filters.clone(), + clob_auth: subscription.clob_auth.clone(), + created_at: Instant::now(), + }, + ); + + // Create filtered stream with its own receiver + let mut rx = self.connection.subscribe(); + let target_topic = topic_type.topic; + let target_type = topic_type.msg_type; + + Ok(try_stream! { + loop { + match rx.recv().await { + Ok(msg) => { + // Filter messages by topic and type + let matches_topic = msg.topic == target_topic; + let matches_type = target_type == "*" || msg.msg_type == target_type; + + if matches_topic && matches_type { + yield msg; + } + } + Err(RecvError::Lagged(n)) => { + #[cfg(feature = "tracing")] + tracing::warn!("RTDS subscription lagged, missed {n} messages"); + Err(RtdsError::Lagged { count: n })?; + } + Err(RecvError::Closed) => { + break; + } + } + } + }) + } + + /// Get information about all active subscriptions. + #[must_use] + pub fn active_subscriptions(&self) -> Vec { + self.active_subs + .iter() + .map(|entry| entry.value().clone()) + .collect() + } + + /// Get the number of active subscriptions. + #[must_use] + pub fn subscription_count(&self) -> usize { + self.active_subs.len() + } + + /// Unsubscribe from topics. + /// + /// This decrements the reference count for each topic. Only sends an unsubscribe + /// request to the server when the reference count reaches zero (no other streams + /// are using that topic). + pub fn unsubscribe(&self, topic_types: &[TopicType]) -> Result<()> { + if topic_types.is_empty() { + return Err(RtdsError::SubscriptionFailed( + "topic_types cannot be empty: at least one topic must be provided for unsubscription" + .to_owned(), + ) + .into()); + } + + // Atomically decrement refcounts and send unsubscribe while holding the entry lock + // to prevent TOCTOU race between refcount check and network send + for topic_type in topic_types { + if let Entry::Occupied(mut entry) = self.subscribed_topics.entry(topic_type.clone()) { + let refcount = entry.get_mut(); + *refcount = refcount.saturating_sub(1); + if *refcount == 0 { + #[cfg(feature = "tracing")] + tracing::debug!( + topic = %topic_type.topic, + msg_type = %topic_type.msg_type, + "Unsubscribing from RTDS topic" + ); + + // Send unsubscribe while holding the entry lock to prevent + // a concurrent subscribe from racing with us + let request = SubscriptionRequest::unsubscribe(vec![Subscription { + topic: topic_type.topic.clone(), + msg_type: topic_type.msg_type.clone(), + filters: None, + clob_auth: None, + }]); + self.connection.send(&request)?; + entry.remove(); + } + } + } + + // Remove active_subs entries where all topics are now unsubscribed + self.active_subs + .retain(|_, info| self.subscribed_topics.contains_key(&info.topic_type)); + + Ok(()) + } +} diff --git a/polymarket-client-sdk/src/rtds/types/mod.rs b/polymarket-client-sdk/src/rtds/types/mod.rs new file mode 100644 index 0000000..e006218 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/types/mod.rs @@ -0,0 +1,2 @@ +pub mod request; +pub mod response; diff --git a/polymarket-client-sdk/src/rtds/types/request.rs b/polymarket-client-sdk/src/rtds/types/request.rs new file mode 100644 index 0000000..0cbb4e7 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/types/request.rs @@ -0,0 +1,323 @@ +use bon::Builder; +use secrecy::ExposeSecret as _; +use serde::Serialize; +use serde_json::Value; + +use super::response::CommentType; +use crate::auth::Credentials; + +/// RTDS subscription request message. +#[non_exhaustive] +#[derive(Clone, Debug, Serialize, Builder)] +pub struct SubscriptionRequest { + /// Action type ("subscribe" or "unsubscribe") + pub action: SubscriptionAction, + /// List of subscriptions + pub subscriptions: Vec, +} + +impl SubscriptionRequest { + /// Create a subscribe request. + #[must_use] + pub fn subscribe(subscriptions: Vec) -> Self { + Self { + action: SubscriptionAction::Subscribe, + subscriptions, + } + } + + /// Create an unsubscribe request. + #[must_use] + pub fn unsubscribe(subscriptions: Vec) -> Self { + Self { + action: SubscriptionAction::Unsubscribe, + subscriptions, + } + } +} + +/// Subscription action type. +#[non_exhaustive] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SubscriptionAction { + /// Subscribe to topics + Subscribe, + /// Unsubscribe from topics + Unsubscribe, +} + +/// Individual subscription configuration. +/// +/// # Security +/// +/// When serialized, this struct exposes sensitive credentials (`clob_auth`) in plaintext. +/// Ensure subscription requests are only sent over secure WebSocket connections (`wss://`) +/// and never logged or exposed in error messages. +#[non_exhaustive] +#[derive(Clone, Debug, Builder)] +pub struct Subscription { + /// Topic name (e.g., `crypto_prices`, `comments`) + pub topic: String, + /// Message type filter (e.g., `update`, `comment_created`, or `*` for all) + pub msg_type: String, + /// Optional filters (string or JSON object) + pub filters: Option, + /// CLOB authentication (key, secret, passphrase) + pub clob_auth: Option, +} + +impl Subscription { + /// Create a subscription for Binance crypto prices. + #[must_use] + pub fn crypto_prices(symbols: Option>) -> Self { + // Server expects filters as a JSON array, e.g. ["btcusdt","ethusdt"] + let filters = + symbols.map(|s| serde_json::to_string(&s).unwrap_or_else(|_| "[]".to_owned())); + Self { + topic: "crypto_prices".to_owned(), + msg_type: "update".to_owned(), + filters, + clob_auth: None, + } + } + + /// Create a subscription for Chainlink crypto prices. + #[must_use] + pub fn chainlink_prices(symbol: Option) -> Self { + let filters = symbol.map(|s| format!(r#"{{"symbol":"{s}"}}"#)); + Self { + topic: "crypto_prices_chainlink".to_owned(), + msg_type: "*".to_owned(), + filters, + clob_auth: None, + } + } + + /// Create a subscription for comments. + #[must_use] + pub fn comments(msg_type: Option) -> Self { + let type_str = msg_type.map_or("*".to_owned(), |t| { + serde_json::to_string(&t) + .ok() + .and_then(|s| s.trim_matches('"').to_owned().into()) + .unwrap_or_else(|| "*".to_owned()) + }); + Self { + topic: "comments".to_owned(), + msg_type: type_str, + filters: None, + clob_auth: None, + } + } + + /// Set CLOB authentication for this subscription. + #[must_use] + pub fn with_clob_auth(mut self, credentials: Credentials) -> Self { + self.clob_auth = Some(credentials); + self + } + + /// Set custom filters for this subscription. + #[must_use] + pub fn with_filters(mut self, filters: String) -> Self { + self.filters = Some(filters); + self + } +} + +// Custom Serialize implementation for Subscription to handle auth fields +impl Serialize for Subscription { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap as _; + + let mut map = serializer.serialize_map(None)?; + + map.serialize_entry("topic", &self.topic)?; + map.serialize_entry("type", &self.msg_type)?; + + if let Some(filters) = &self.filters { + // Chainlink endpoint expects filters as a JSON string (escaped), + // while other endpoints (like Binance crypto_prices) expect raw JSON. + // See: https://github.com/Polymarket/rs-clob-client/issues/136 + if self.topic == "crypto_prices_chainlink" { + // Chainlink: emit filters as string, e.g. "{\"symbol\":\"btc/usd\"}" + map.serialize_entry("filters", filters)?; + } else if let Ok(json_value) = serde_json::from_str::(filters) { + // Other topics: parse and emit as raw JSON, e.g. ["btcusdt","ethusdt"] + map.serialize_entry("filters", &json_value)?; + } else { + // Fallback: emit as string if not valid JSON + map.serialize_entry("filters", filters)?; + } + } + + // SECURITY: Credentials are intentionally revealed here for the WebSocket auth protocol. + // This data is only sent over wss:// connections to the RTDS server. + if let Some(creds) = &self.clob_auth { + let auth = serde_json::json!({ + "key": creds.key.to_string(), + "secret": creds.secret.expose_secret(), + "passphrase": creds.passphrase.expose_secret(), + }); + map.serialize_entry("clob_auth", &auth)?; + } + + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_subscription_request() { + let sub = + Subscription::crypto_prices(Some(vec!["btcusdt".to_owned(), "ethusdt".to_owned()])); + let request = SubscriptionRequest::subscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"action\":\"subscribe\"")); + assert!(json.contains("\"topic\":\"crypto_prices\"")); + // Filters should be a JSON array, not a comma-separated string + assert!(json.contains("\"filters\":[\"btcusdt\",\"ethusdt\"]")); + } + + #[test] + fn serialize_chainlink_subscription() { + let sub = Subscription::chainlink_prices(Some("eth/usd".to_owned())); + let request = SubscriptionRequest::subscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"topic\":\"crypto_prices_chainlink\"")); + assert!(json.contains("\"type\":\"*\"")); + // Chainlink filters should be a JSON string (escaped), not a raw JSON object + // See: https://github.com/Polymarket/rs-clob-client/issues/136 + assert!( + json.contains(r#""filters":"{\"symbol\":\"eth/usd\"}""#), + "Chainlink filters should be serialized as escaped JSON string, got: {json}" + ); + } + + #[test] + fn serialize_comments_subscription() { + let sub = Subscription::comments(Some(CommentType::CommentCreated)); + let request = SubscriptionRequest::subscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"topic\":\"comments\"")); + assert!(json.contains("\"type\":\"comment_created\"")); + } + + #[test] + fn serialize_chainlink_without_filters() { + // When no symbol is provided, there should be no filters field + let sub = Subscription::chainlink_prices(None); + let request = SubscriptionRequest::subscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"topic\":\"crypto_prices_chainlink\"")); + assert!(!json.contains("\"filters\"")); + } + + #[test] + fn serialize_crypto_prices_without_filters() { + // When no symbols are provided, there should be no filters field + let sub = Subscription::crypto_prices(None); + let request = SubscriptionRequest::subscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"topic\":\"crypto_prices\"")); + assert!(!json.contains("\"filters\"")); + } + + #[test] + fn serialize_mixed_subscriptions() { + // Verify Chainlink and Binance subscriptions serialize differently in same request + let chainlink = Subscription::chainlink_prices(Some("btc/usd".to_owned())); + let binance = + Subscription::crypto_prices(Some(vec!["btcusdt".to_owned(), "ethusdt".to_owned()])); + let request = SubscriptionRequest::subscribe(vec![chainlink, binance]); + + let json = serde_json::to_string(&request).unwrap(); + + // Chainlink should have escaped string filters + assert!( + json.contains(r#""filters":"{\"symbol\":\"btc/usd\"}""#), + "Chainlink filters should be escaped string, got: {json}" + ); + // Binance should have raw JSON array filters + assert!( + json.contains("\"filters\":[\"btcusdt\",\"ethusdt\"]"), + "Binance filters should be raw JSON array, got: {json}" + ); + } + + #[test] + fn serialize_unsubscribe_request() { + let sub = Subscription::crypto_prices(Some(vec!["btcusdt".to_owned()])); + let request = SubscriptionRequest::unsubscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!( + json.contains("\"action\":\"unsubscribe\""), + "Action should be 'unsubscribe', got: {json}" + ); + assert!(json.contains("\"topic\":\"crypto_prices\"")); + assert!(json.contains("\"type\":\"update\"")); + } + + #[test] + fn serialize_unsubscribe_without_filters() { + let sub = Subscription::crypto_prices(None); + let request = SubscriptionRequest::unsubscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"action\":\"unsubscribe\"")); + assert!(json.contains("\"topic\":\"crypto_prices\"")); + assert!( + !json.contains("\"filters\""), + "Should have no filters field" + ); + } + + #[test] + fn serialize_unsubscribe_chainlink() { + let sub = Subscription::chainlink_prices(Some("btc/usd".to_owned())); + let request = SubscriptionRequest::unsubscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"action\":\"unsubscribe\"")); + assert!(json.contains("\"topic\":\"crypto_prices_chainlink\"")); + assert!(json.contains("\"type\":\"*\"")); + } + + #[test] + fn serialize_unsubscribe_comments() { + let sub = Subscription::comments(Some(CommentType::CommentCreated)); + let request = SubscriptionRequest::unsubscribe(vec![sub]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"action\":\"unsubscribe\"")); + assert!(json.contains("\"topic\":\"comments\"")); + assert!(json.contains("\"type\":\"comment_created\"")); + } + + #[test] + fn serialize_unsubscribe_multiple_topics() { + let crypto = Subscription::crypto_prices(None); + let chainlink = Subscription::chainlink_prices(None); + let comments = Subscription::comments(None); + let request = SubscriptionRequest::unsubscribe(vec![crypto, chainlink, comments]); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"action\":\"unsubscribe\"")); + assert!(json.contains("\"topic\":\"crypto_prices\"")); + assert!(json.contains("\"topic\":\"crypto_prices_chainlink\"")); + assert!(json.contains("\"topic\":\"comments\"")); + } +} diff --git a/polymarket-client-sdk/src/rtds/types/response.rs b/polymarket-client-sdk/src/rtds/types/response.rs new file mode 100644 index 0000000..d3ace88 --- /dev/null +++ b/polymarket-client-sdk/src/rtds/types/response.rs @@ -0,0 +1,303 @@ +use bon::Builder; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::types::{Address, Decimal}; + +/// Top-level RTDS message wrapper. +/// +/// All messages received from the RTDS WebSocket connection are deserialized into this struct. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Builder)] +pub struct RtdsMessage { + /// The subscription topic (e.g., `crypto_prices`, `comments`) + pub topic: String, + /// The message type/event (e.g., `update`, `comment_created`) + #[serde(rename = "type")] + pub msg_type: String, + /// Unix timestamp in milliseconds + pub timestamp: i64, + /// Event-specific data object + pub payload: Value, +} + +impl RtdsMessage { + /// Try to extract the payload as a crypto price update. + #[must_use] + pub fn as_crypto_price(&self) -> Option { + if self.topic == "crypto_prices" { + serde_json::from_value(self.payload.clone()).ok() + } else { + None + } + } + + /// Try to extract the payload as a Chainlink price update. + #[must_use] + pub fn as_chainlink_price(&self) -> Option { + if self.topic == "crypto_prices_chainlink" { + serde_json::from_value(self.payload.clone()).ok() + } else { + None + } + } + + /// Try to extract the payload as a comment event. + #[must_use] + pub fn as_comment(&self) -> Option { + if self.topic == "comments" { + serde_json::from_value(self.payload.clone()).ok() + } else { + None + } + } +} + +/// Binance crypto price update payload. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, Builder)] +pub struct CryptoPrice { + /// Trading pair symbol (lowercase concatenated, e.g., "solusdt", "btcusdt") + pub symbol: String, + /// Price timestamp in Unix milliseconds + pub timestamp: i64, + /// Current price value + pub value: Decimal, +} + +/// Chainlink price feed update payload. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, Builder)] +pub struct ChainlinkPrice { + /// Trading pair symbol (slash-separated, e.g., "eth/usd", "btc/usd") + pub symbol: String, + /// Price timestamp in Unix milliseconds + pub timestamp: i64, + /// Current price value + pub value: Decimal, +} + +/// Comment event payload. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, Builder)] +pub struct Comment { + /// Unique identifier for this comment + pub id: String, + /// The text content of the comment + pub body: String, + /// ISO 8601 timestamp when the comment was created + #[serde(rename = "createdAt")] + pub created_at: DateTime, + /// ID of the parent comment if this is a reply (null for top-level comments) + #[serde(rename = "parentCommentID", default)] + pub parent_comment_id: Option, + /// ID of the parent entity (event, market, etc.) + #[serde(rename = "parentEntityID")] + pub parent_entity_id: i64, + /// Type of parent entity (e.g., "Event", "Market") + #[serde(rename = "parentEntityType")] + pub parent_entity_type: String, + /// Profile information of the user who created the comment + pub profile: CommentProfile, + /// Current number of reactions on this comment + #[serde(rename = "reactionCount", default)] + pub reaction_count: i64, + /// Polygon address for replies + #[serde(rename = "replyAddress", default)] + pub reply_address: Option
, + /// Current number of reports on this comment + #[serde(rename = "reportCount", default)] + pub report_count: i64, + /// Polygon address of the user who created the comment + #[serde(rename = "userAddress")] + pub user_address: Address, +} + +/// Profile information for a comment author. +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, Builder)] +pub struct CommentProfile { + /// User profile address + #[serde(rename = "baseAddress")] + pub base_address: Address, + /// Whether the username should be displayed publicly + #[serde(rename = "displayUsernamePublic", default)] + pub display_username_public: bool, + /// User's display name + pub name: String, + /// Proxy wallet address used for transactions + #[serde(rename = "proxyWallet", default)] + pub proxy_wallet: Option
, + /// Generated pseudonym for the user + #[serde(default)] + pub pseudonym: Option, +} + +/// Comment message types. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CommentType { + /// New comment created + CommentCreated, + /// Comment was removed/deleted + CommentRemoved, + /// Reaction added to a comment + ReactionCreated, + /// Reaction removed from a comment + ReactionRemoved, + /// Unknown comment type from the API (captures the raw value for debugging). + #[serde(untagged)] + Unknown(String), +} + +/// Deserialize messages from the byte slice. +/// +/// Handles both single objects and arrays of messages. +/// Returns an empty vector for empty or whitespace-only input. +pub fn parse_messages(bytes: &[u8]) -> crate::Result> { + // Handle empty or whitespace-only input (server keepalive messages) + let trimmed = bytes + .iter() + .position(|b| !b.is_ascii_whitespace()) + .map_or(&[][..], |start| &bytes[start..]); + + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + // Try parsing as array first, fall back to single object + if trimmed.first() == Some(&b'[') { + Ok(serde_json::from_slice(trimmed)?) + } else { + let msg: RtdsMessage = serde_json::from_slice(trimmed)?; + Ok(vec![msg]) + } +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn parse_crypto_price_message() { + let json = r#"{ + "topic": "crypto_prices", + "type": "update", + "timestamp": 1753314064237, + "payload": { + "symbol": "solusdt", + "timestamp": 1753314064213, + "value": 189.55 + } + }"#; + + let msgs = parse_messages(json.as_bytes()).unwrap(); + assert_eq!(msgs.len(), 1); + + let msg = &msgs[0]; + assert_eq!(msg.topic, "crypto_prices"); + assert_eq!(msg.msg_type, "update"); + + let price = msg.as_crypto_price().unwrap(); + assert_eq!(price.symbol, "solusdt"); + assert_eq!(price.value, dec!(189.55)); + } + + #[test] + fn parse_chainlink_price_message() { + let json = r#"{ + "topic": "crypto_prices_chainlink", + "type": "update", + "timestamp": 1753314064237, + "payload": { + "symbol": "eth/usd", + "timestamp": 1753314064213, + "value": 3456.78 + } + }"#; + + let msgs = parse_messages(json.as_bytes()).unwrap(); + assert_eq!(msgs.len(), 1); + + let msg = &msgs[0]; + assert_eq!(msg.topic, "crypto_prices_chainlink"); + + let price = msg.as_chainlink_price().unwrap(); + assert_eq!(price.symbol, "eth/usd"); + assert_eq!(price.value, dec!(3456.78)); + } + + #[test] + fn parse_comment_message() { + let json = r#"{ + "topic": "comments", + "type": "comment_created", + "timestamp": 1753454975808, + "payload": { + "body": "Test comment", + "createdAt": "2025-07-25T14:49:35.801298Z", + "id": "1763355", + "parentCommentID": "1763325", + "parentEntityID": 18396, + "parentEntityType": "Event", + "profile": { + "baseAddress": "0xce533188d53a16ed580fd5121dedf166d3482677", + "displayUsernamePublic": true, + "name": "salted.caramel", + "proxyWallet": "0x4ca749dcfa93c87e5ee23e2d21ff4422c7a4c1ee", + "pseudonym": "Adored-Disparity" + }, + "reactionCount": 0, + "replyAddress": "0x0bda5d16f76cd1d3485bcc7a44bc6fa7db004cdd", + "reportCount": 0, + "userAddress": "0xce533188d53a16ed580fd5121dedf166d3482677" + } + }"#; + + let msgs = parse_messages(json.as_bytes()).unwrap(); + assert_eq!(msgs.len(), 1); + + let msg = &msgs[0]; + assert_eq!(msg.topic, "comments"); + assert_eq!(msg.msg_type, "comment_created"); + + let comment = msg.as_comment().unwrap(); + assert_eq!(comment.id, "1763355"); + assert_eq!(comment.body, "Test comment"); + assert_eq!(comment.profile.name, "salted.caramel"); + } + + #[test] + fn parse_message_array() { + let json = r#"[{ + "topic": "crypto_prices", + "type": "update", + "timestamp": 1753314064237, + "payload": { + "symbol": "btcusdt", + "timestamp": 1753314064213, + "value": 67234.50 + } + }]"#; + + let msgs = parse_messages(json.as_bytes()).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].topic, "crypto_prices"); + } + + #[test] + fn parse_empty_input() { + let msgs = parse_messages(b"").unwrap(); + assert!(msgs.is_empty()); + } + + #[test] + fn parse_whitespace_only_input() { + let msgs = parse_messages(b" \n\t ").unwrap(); + assert!(msgs.is_empty()); + } +} diff --git a/polymarket-client-sdk/src/serde_helpers.rs b/polymarket-client-sdk/src/serde_helpers.rs new file mode 100644 index 0000000..936035a --- /dev/null +++ b/polymarket-client-sdk/src/serde_helpers.rs @@ -0,0 +1,731 @@ +//! Serde helpers for flexible deserialization. +//! +//! When the `tracing` feature is enabled, this module also logs warnings for any +//! unknown fields encountered during deserialization, helping detect API changes. + +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma", +))] +use {serde::de::DeserializeOwned, serde_json::Value}; + +/// A `serde_as` type that deserializes strings or integers as `String`. +/// +/// Use with `#[serde_as(as = "StringFromAny")]` for `String` fields +/// or `#[serde_as(as = "Option")]` for `Option`. +#[cfg(any(feature = "clob", feature = "gamma"))] +pub struct StringFromAny; + +#[cfg(any(feature = "clob", feature = "gamma"))] +impl<'de> serde_with::DeserializeAs<'de, String> for StringFromAny { + fn deserialize_as(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use std::fmt; + + use serde::de::{self, Visitor}; + + struct StringOrNumberVisitor; + + impl Visitor<'_> for StringOrNumberVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or integer") + } + + fn visit_str(self, v: &str) -> std::result::Result + where + E: de::Error, + { + Ok(v.to_owned()) + } + + fn visit_string(self, v: String) -> std::result::Result + where + E: de::Error, + { + Ok(v) + } + + fn visit_i64(self, v: i64) -> std::result::Result + where + E: de::Error, + { + Ok(v.to_string()) + } + + fn visit_u64(self, v: u64) -> std::result::Result + where + E: de::Error, + { + Ok(v.to_string()) + } + } + + deserializer.deserialize_any(StringOrNumberVisitor) + } +} + +#[cfg(any(feature = "clob", feature = "gamma"))] +impl serde_with::SerializeAs for StringFromAny { + fn serialize_as(source: &String, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.serialize_str(source) + } +} + +/// Deserialize JSON with unknown field warnings. +/// +/// This function deserializes JSON to a target type while detecting and logging +/// any fields that are not captured by the type definition. +/// +/// # Arguments +/// +/// * `value` - The JSON value to deserialize +/// +/// # Returns +/// +/// The deserialized value, or an error if deserialization fails. +/// Unknown fields trigger warnings but do not cause deserialization to fail. +/// +/// # Example +/// +/// ```ignore +/// let json = serde_json::json!({ +/// "known_field": "value", +/// "unknown_field": "extra" +/// }); +/// let result: MyType = deserialize_with_warnings(json)?; +/// // Logs: WARN Unknown field "unknown_field" with value "extra" in MyType +/// ``` +#[cfg(all( + feature = "tracing", + any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" + ) +))] +pub fn deserialize_with_warnings(value: Value) -> crate::Result { + use std::any::type_name; + + tracing::trace!( + type_name = %type_name::(), + json = %value, + "deserializing JSON" + ); + + // Clone the value so we can look up unknown field values later + let original = value.clone(); + + // Collect unknown field paths during deserialization + let mut unknown_paths: Vec = Vec::new(); + + let result: T = serde_ignored::deserialize(value, |path| { + unknown_paths.push(path.to_string()); + }) + .inspect_err(|_| { + // Re-deserialize with serde_path_to_error to get the error path + let json_str = original.to_string(); + let jd = &mut serde_json::Deserializer::from_str(&json_str); + let path_result: Result = serde_path_to_error::deserialize(jd); + if let Err(path_err) = path_result { + let path = path_err.path().to_string(); + let inner_error = path_err.inner(); + let value_at_path = lookup_value(&original, &path); + let value_display = format_value(value_at_path); + + tracing::error!( + type_name = %type_name::(), + path = %path, + value = %value_display, + error = %inner_error, + "deserialization failed" + ); + } + })?; + + // Log warnings for unknown fields with their values + if !unknown_paths.is_empty() { + let type_name = type_name::(); + for path in unknown_paths { + let field_value = lookup_value(&original, &path); + let value_display = format_value(field_value); + + tracing::warn!( + type_name = %type_name, + field = %path, + value = %value_display, + "unknown field in API response" + ); + } + } + + Ok(result) +} + +/// Pass-through deserialization when tracing is disabled. +#[cfg(all( + not(feature = "tracing"), + any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" + ) +))] +pub fn deserialize_with_warnings(value: Value) -> crate::Result { + Ok(serde_json::from_value(value)?) +} + +/// Look up a value in a JSON structure by path. +/// +/// Handles paths from both `serde_ignored` and `serde_path_to_error`: +/// - `?` for Option wrappers (skipped, as JSON has no Option representation) +/// - Numeric indices for arrays: `items.0` or `items[0]` +/// - Field names for objects: `foo.bar` or `foo.bar[0].baz` +/// +/// Returns `None` if the path doesn't exist or traverses a non-container value. +#[cfg(feature = "tracing")] +fn lookup_value<'value>(value: &'value Value, path: &str) -> Option<&'value Value> { + if path.is_empty() { + return Some(value); + } + + let mut current = value; + + // Parse path segments, handling both dot notation and bracket notation + // e.g., "data[15].condition_id" -> ["data", "15", "condition_id"] + let segments = parse_path_segments(path); + + for segment in segments { + if segment.is_empty() || segment == "?" { + continue; + } + + match current { + Value::Object(map) => { + current = map.get(&segment)?; + } + Value::Array(arr) => { + let index: usize = segment.parse().ok()?; + current = arr.get(index)?; + } + _ => return None, + } + } + + Some(current) +} + +/// Parse a path string into segments, handling both dot and bracket notation. +/// +/// Examples: +/// - `"foo.bar"` -> `["foo", "bar"]` +/// - `"data[15].condition_id"` -> `["data", "15", "condition_id"]` +/// - `"items[0][1].value"` -> `["items", "0", "1", "value"]` +#[cfg(feature = "tracing")] +fn parse_path_segments(path: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + + let mut chars = path.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '.' => { + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + } + '[' => { + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + // Collect until closing bracket + for inner in chars.by_ref() { + if inner == ']' { + break; + } + current.push(inner); + } + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + } + ']' => { + // Shouldn't happen if well-formed, but handle gracefully + } + _ => { + current.push(ch); + } + } + } + + if !current.is_empty() { + segments.push(current); + } + + segments +} + +/// Format a JSON value for logging. +#[cfg(feature = "tracing")] +fn format_value(value: Option<&Value>) -> String { + match value { + Some(v) => v.to_string(), + None => "".to_owned(), + } +} + +#[cfg(test)] +mod tests { + // Imports for tracing-gated tests in the outer module + #[cfg(feature = "tracing")] + use serde_json::Value; + + #[cfg(feature = "tracing")] + use super::{format_value, lookup_value}; + + // ========== deserialize_with_warnings tests ========== + #[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" + ))] + mod deserialize_with_warnings_tests { + use serde::Deserialize; + + use super::super::deserialize_with_warnings; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStruct { + known_field: String, + #[serde(default)] + optional_field: Option, + } + + #[test] + fn deserialize_known_fields_only() { + let json = serde_json::json!({ + "known_field": "value", + "optional_field": 42 + }); + + let result: TestStruct = + deserialize_with_warnings(json).expect("deserialization failed"); + assert_eq!(result.known_field, "value"); + assert_eq!(result.optional_field, Some(42)); + } + + #[test] + fn deserialize_with_unknown_fields() { + let json = serde_json::json!({ + "known_field": "value", + "unknown_field": "extra", + "another_unknown": 123 + }); + + // Should succeed - extra fields are logged but not an error + let result: TestStruct = + deserialize_with_warnings(json).expect("deserialization failed"); + assert_eq!(result.known_field, "value"); + assert_eq!(result.optional_field, None); + } + + #[test] + fn deserialize_missing_required_field_fails() { + let json = serde_json::json!({ + "optional_field": 42 + }); + + let result: crate::Result = deserialize_with_warnings(json); + result.unwrap_err(); + } + + #[test] + fn deserialize_array() { + let json = serde_json::json!([1, 2, 3]); + + let result: Vec = deserialize_with_warnings(json).expect("deserialization failed"); + assert_eq!(result, vec![1, 2, 3]); + } + + #[derive(Debug, Deserialize, PartialEq)] + struct NestedStruct { + outer: String, + inner: InnerStruct, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct InnerStruct { + value: i32, + } + + #[test] + fn deserialize_nested_unknown_fields() { + let json = serde_json::json!({ + "outer": "test", + "inner": { + "value": 42, + "nested_unknown": "surprise" + } + }); + + let result: NestedStruct = + deserialize_with_warnings(json).expect("deserialization failed"); + assert_eq!(result.outer, "test"); + assert_eq!(result.inner.value, 42); + } + + /// Test that verifies warnings are actually emitted for unknown fields. + /// This test captures tracing output to prove the feature works. + #[cfg(feature = "tracing")] + #[test] + fn warning_is_emitted_for_unknown_fields() { + use std::sync::{Arc, Mutex}; + + use tracing_subscriber::layer::SubscriberExt as _; + + // Capture warnings in a buffer + let warnings: Arc>> = Arc::new(Mutex::new(Vec::new())); + let warnings_clone = Arc::clone(&warnings); + + // Custom layer that captures warn events + let layer = tracing_subscriber::fmt::layer() + .with_writer(move || { + struct CaptureWriter(Arc>>); + impl std::io::Write for CaptureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if let Ok(s) = std::str::from_utf8(buf) { + self.0.lock().expect("lock").push(s.to_owned()); + } + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + CaptureWriter(Arc::clone(&warnings_clone)) + }) + .with_ansi(false); + + let subscriber = tracing_subscriber::registry().with(layer); + + // Run the deserialization with our subscriber + tracing::subscriber::with_default(subscriber, || { + let json = serde_json::json!({ + "known_field": "value", + "secret_new_field": "surprise!", + "another_unknown": 42 + }); + + let result: TestStruct = + deserialize_with_warnings(json).expect("deserialization should succeed"); + assert_eq!(result.known_field, "value"); + }); + + // Check that warnings were captured + let captured = warnings.lock().expect("lock"); + let all_output = captured.join(""); + + assert!( + all_output.contains("unknown field"), + "Expected 'unknown field' in output, got: {all_output}" + ); + assert!( + all_output.contains("secret_new_field"), + "Expected 'secret_new_field' in output, got: {all_output}" + ); + } + } + + // ========== StringFromAny tests ========== + #[cfg(any(feature = "clob", feature = "gamma"))] + mod string_from_any_tests { + use serde::Deserialize; + + use super::super::StringFromAny; + + #[derive(Debug, Deserialize, PartialEq, serde::Serialize)] + struct StringFromAnyStruct { + #[serde(with = "serde_with::As::")] + id: String, + } + + #[derive(Debug, Deserialize, PartialEq, serde::Serialize)] + struct OptionalStringFromAny { + #[serde(with = "serde_with::As::>")] + id: Option, + } + + #[test] + fn string_from_any_deserialize_string() { + let json = serde_json::json!({ "id": "hello" }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, "hello"); + } + + #[test] + fn string_from_any_deserialize_positive_integer() { + let json = serde_json::json!({ "id": 12345 }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, "12345"); + } + + #[test] + fn string_from_any_deserialize_negative_integer() { + let json = serde_json::json!({ "id": -42 }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, "-42"); + } + + #[test] + fn string_from_any_deserialize_zero() { + let json = serde_json::json!({ "id": 0 }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, "0"); + } + + #[test] + fn string_from_any_deserialize_large_u64() { + // Test u64 max value + let json = serde_json::json!({ "id": u64::MAX }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, u64::MAX.to_string()); + } + + #[test] + fn string_from_any_deserialize_large_negative_i64() { + // Test i64 min value + let json = serde_json::json!({ "id": i64::MIN }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, i64::MIN.to_string()); + } + + #[test] + fn string_from_any_serialize_back_to_string() { + let obj = StringFromAnyStruct { + id: "12345".to_owned(), + }; + let json = serde_json::to_value(&obj).expect("serialization failed"); + assert_eq!(json, serde_json::json!({ "id": "12345" })); + } + + #[test] + fn string_from_any_roundtrip_from_string() { + let json = serde_json::json!({ "id": "hello" }); + let obj: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + let back = serde_json::to_value(&obj).expect("serialization failed"); + assert_eq!(back, serde_json::json!({ "id": "hello" })); + } + + #[test] + fn string_from_any_roundtrip_from_integer() { + let json = serde_json::json!({ "id": 42 }); + let obj: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + // After roundtrip, integer becomes string + let back = serde_json::to_value(&obj).expect("serialization failed"); + assert_eq!(back, serde_json::json!({ "id": "42" })); + } + + #[test] + fn string_from_any_option_some_string() { + let json = serde_json::json!({ "id": "hello" }); + let result: OptionalStringFromAny = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, Some("hello".to_owned())); + } + + #[test] + fn string_from_any_option_some_integer() { + let json = serde_json::json!({ "id": 123 }); + let result: OptionalStringFromAny = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, Some("123".to_owned())); + } + + #[test] + fn string_from_any_option_none() { + let json = serde_json::json!({ "id": null }); + let result: OptionalStringFromAny = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, None); + } + + #[test] + fn string_from_any_option_serialize_some() { + let obj = OptionalStringFromAny { + id: Some("test".to_owned()), + }; + let json = serde_json::to_value(&obj).expect("serialization failed"); + assert_eq!(json, serde_json::json!({ "id": "test" })); + } + + #[test] + fn string_from_any_option_serialize_none() { + let obj = OptionalStringFromAny { id: None }; + let json = serde_json::to_value(&obj).expect("serialization failed"); + assert_eq!(json, serde_json::json!({ "id": null })); + } + + #[test] + fn string_from_any_empty_string() { + let json = serde_json::json!({ "id": "" }); + let result: StringFromAnyStruct = + serde_json::from_value(json).expect("deserialization failed"); + assert_eq!(result.id, ""); + } + } + + // ========== lookup_value tests ========== + + #[cfg(feature = "tracing")] + #[test] + fn lookup_simple_path() { + let json = serde_json::json!({ + "foo": "bar" + }); + + let result = lookup_value(&json, "foo"); + assert_eq!(result, Some(&Value::String("bar".to_owned()))); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_nested_path() { + let json = serde_json::json!({ + "outer": { + "inner": "value" + } + }); + + let result = lookup_value(&json, "outer.inner"); + assert_eq!(result, Some(&Value::String("value".to_owned()))); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_array_index() { + let json = serde_json::json!({ + "items": ["a", "b", "c"] + }); + + let result = lookup_value(&json, "items.1"); + assert_eq!(result, Some(&Value::String("b".to_owned()))); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_empty_path_returns_root() { + let json = serde_json::json!({"foo": "bar"}); + let result = lookup_value(&json, ""); + assert_eq!(result, Some(&json)); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_consecutive_dots_handled() { + let json = serde_json::json!({"foo": {"bar": "value"}}); + // Path "foo..bar" should skip the empty segment and find "foo.bar" + let result = lookup_value(&json, "foo..bar"); + assert_eq!(result, Some(&Value::String("value".to_owned()))); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_leading_dot_handled() { + let json = serde_json::json!({"foo": "bar"}); + // Path ".foo" should skip the leading empty segment + let result = lookup_value(&json, ".foo"); + assert_eq!(result, Some(&Value::String("bar".to_owned()))); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_invalid_array_index_returns_none() { + let json = serde_json::json!({"items": [1, 2, 3]}); + let result = lookup_value(&json, "items.abc"); + assert_eq!(result, None); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_array_out_of_bounds_returns_none() { + let json = serde_json::json!({"items": [1, 2, 3]}); + let result = lookup_value(&json, "items.100"); + assert_eq!(result, None); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_through_primitive_returns_none() { + let json = serde_json::json!({"foo": "bar"}); + // Can't traverse through a string + let result = lookup_value(&json, "foo.baz"); + assert_eq!(result, None); + } + + #[cfg(feature = "tracing")] + #[test] + fn format_shows_full_string() { + let long_string = "a".repeat(300); + let value = Value::String(long_string.clone()); + + let formatted = format_value(Some(&value)); + // Full JSON string with quotes + assert_eq!(formatted, format!("\"{long_string}\"")); + } + + #[cfg(feature = "tracing")] + #[test] + fn format_array_shows_full_json() { + let value = serde_json::json!([1, 2, 3, 4, 5]); + + let formatted = format_value(Some(&value)); + assert_eq!(formatted, "[1,2,3,4,5]"); + } + + #[cfg(feature = "tracing")] + #[test] + fn format_object_shows_full_json() { + let value = serde_json::json!({"a": 1, "b": 2}); + + let formatted = format_value(Some(&value)); + // JSON object serialization order may vary, check both keys present + assert!(formatted.contains("\"a\":1")); + assert!(formatted.contains("\"b\":2")); + } + + #[cfg(feature = "tracing")] + #[test] + fn format_none_shows_placeholder() { + let formatted = format_value(None); + assert_eq!(formatted, ""); + } + + #[cfg(feature = "tracing")] + #[test] + fn lookup_option_marker_skipped() { + // serde_ignored uses '?' for Option wrappers + let json = serde_json::json!({"outer": {"inner": "value"}}); + // Path "?.outer.?.inner" should skip ? markers + let result = lookup_value(&json, "?.outer.?.inner"); + assert_eq!(result, Some(&Value::String("value".to_owned()))); + } +} diff --git a/polymarket-client-sdk/src/types.rs b/polymarket-client-sdk/src/types.rs new file mode 100644 index 0000000..e445073 --- /dev/null +++ b/polymarket-client-sdk/src/types.rs @@ -0,0 +1,23 @@ +//! Re-exported types from external crates for convenience. +//! +//! These types are commonly used in this SDK and are re-exported here +//! so users don't need to add these dependencies to their `Cargo.toml`. + +/// Ethereum address type and the [`address!`] macro for compile-time address literals. +/// [`ChainId`] is a type alias for `u64` representing EVM chain IDs. +/// [`Signature`] represents cryptographic signatures for signed orders. +/// [`B256`] is a 256-bit fixed-size byte array type used for condition IDs and hashes. +/// [`U256`] is a 256-bit integer +pub use alloy::primitives::{Address, B256, ChainId, Signature, U256, address, b256}; +/// Date and time types for timestamps in API responses and order expiration. +pub use chrono::{DateTime, NaiveDate, Utc}; +/// Arbitrary precision decimal type for prices, sizes, and amounts. +pub use rust_decimal::Decimal; +/// Macro for creating [`Decimal`] literals at compile time. +/// +/// # Example +/// ``` +/// use polymarket_client_sdk::types::dec; +/// let price = dec!(0.55); +/// ``` +pub use rust_decimal_macros::dec; diff --git a/polymarket-client-sdk/src/ws/config.rs b/polymarket-client-sdk/src/ws/config.rs new file mode 100644 index 0000000..a6f7a7e --- /dev/null +++ b/polymarket-client-sdk/src/ws/config.rs @@ -0,0 +1,116 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Configuration types intentionally mirror the module name for clarity" +)] + +use std::time::Duration; + +use backoff::{ExponentialBackoff, ExponentialBackoffBuilder}; + +const DEFAULT_HEARTBEAT_INTERVAL_DURATION: Duration = Duration::from_secs(5); +const DEFAULT_HEARTBEAT_TIMEOUT_DURATION: Duration = Duration::from_secs(15); +const DEFAULT_INITIAL_BACKOFF_DURATION: Duration = Duration::from_secs(1); +const DEFAULT_MAX_BACKOFF_DURATION: Duration = Duration::from_secs(60); +const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0; + +/// Configuration for WebSocket client behavior. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct Config { + /// Interval for sending PING messages to keep connection alive + pub heartbeat_interval: Duration, + /// Maximum time to wait for PONG response before considering connection dead + pub heartbeat_timeout: Duration, + /// Reconnection strategy configuration + pub reconnect: ReconnectConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL_DURATION, + heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT_DURATION, + reconnect: ReconnectConfig::default(), + } + } +} + +/// Configuration for automatic reconnection behavior. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct ReconnectConfig { + /// Maximum number of reconnection attempts before giving up. + /// `None` means infinite retries. + pub max_attempts: Option, + /// Initial backoff duration for first reconnection attempt + pub initial_backoff: Duration, + /// Maximum backoff duration + pub max_backoff: Duration, + /// Multiplier for exponential backoff + pub backoff_multiplier: f64, +} + +impl Default for ReconnectConfig { + fn default() -> Self { + Self { + max_attempts: None, // Infinite reconnection by default + initial_backoff: DEFAULT_INITIAL_BACKOFF_DURATION, + max_backoff: DEFAULT_MAX_BACKOFF_DURATION, + backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER, + } + } +} + +impl From for ExponentialBackoff { + fn from(config: ReconnectConfig) -> Self { + ExponentialBackoffBuilder::default() + .with_initial_interval(config.initial_backoff) + .with_max_interval(config.max_backoff) + .with_multiplier(config.backoff_multiplier) + .with_max_elapsed_time(None) // We handle max attempts separately + .build() + } +} + +#[cfg(test)] +mod tests { + use backoff::backoff::Backoff as _; + + use super::*; + + #[test] + fn backoff_sequence() { + let config = ReconnectConfig::default(); + let mut backoff: ExponentialBackoff = config.into(); + + // First backoff should be around initial_backoff (with some jitter) + let first = backoff.next_backoff().unwrap(); + assert!(first >= Duration::from_millis(500) && first <= Duration::from_millis(1500)); + } + + #[test] + fn backoff_respects_max() { + let config = ReconnectConfig { + initial_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(2), + backoff_multiplier: 3.0, + max_attempts: None, + }; + let mut backoff: ExponentialBackoff = config.into(); + + // Exhaust several iterations + for _ in 0..10 { + let _next = backoff.next_backoff(); + } + + // Should still return values capped at max + let duration = backoff.next_backoff().unwrap(); + assert!(duration <= Duration::from_secs(3)); + } + + #[test] + fn default_heartbeat_is_five_seconds() { + let config = Config::default(); + assert_eq!(config.heartbeat_interval, Duration::from_secs(5)); + } +} diff --git a/polymarket-client-sdk/src/ws/connection.rs b/polymarket-client-sdk/src/ws/connection.rs new file mode 100644 index 0000000..56b8c69 --- /dev/null +++ b/polymarket-client-sdk/src/ws/connection.rs @@ -0,0 +1,426 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Connection types expose their domain in the name for clarity" +)] + +use std::fmt::Debug; +use std::marker::PhantomData; +use std::time::Instant; + +use backoff::backoff::Backoff as _; +use futures::{SinkExt as _, StreamExt as _}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::net::TcpStream; +use tokio::sync::{broadcast, mpsc, watch}; +use tokio::time::{interval, sleep, timeout}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; + +use super::config::Config; +use super::error::WsError; +use super::traits::MessageParser; +use crate::auth::Credentials; +use crate::error::Kind; +use crate::ws::WithCredentials; +use crate::{Result, error::Error}; + +type WsStream = WebSocketStream>; + +/// Broadcast channel capacity for incoming messages. +const BROADCAST_CAPACITY: usize = 1024; + +/// Connection state tracking. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + /// Not connected + Disconnected, + /// Attempting to connect + Connecting, + /// Successfully connected + Connected { + /// When the connection was established + since: Instant, + }, + /// Reconnecting after failure + Reconnecting { + /// Current reconnection attempt number + attempt: u32, + }, +} + +impl ConnectionState { + /// Check if the connection is currently active. + #[must_use] + pub const fn is_connected(self) -> bool { + matches!(self, Self::Connected { .. }) + } +} + +/// Manages WebSocket connection lifecycle, reconnection, and heartbeat. +/// +/// This generic connection manager handles all WebSocket connection concerns: +/// - Establishing and maintaining connections +/// - Automatic reconnection with exponential backoff +/// - Heartbeat monitoring via PING/PONG +/// - Broadcasting messages to multiple subscribers +/// +/// # Type Parameters +/// +/// - `M`: Message type that implements [`DeserializeOwned`] among other "helper" types +/// - `P`: Parser type that implements [`MessageParser`] +/// +/// # Example +/// +/// ```ignore +/// let parser = SimpleParser; +/// let connection = ConnectionManager::new( +/// "wss://example.com".to_owned(), +/// config, +/// parser, +/// )?; +/// +/// // Subscribe to messages +/// let mut rx = connection.subscribe(); +/// while let Ok(msg) = rx.recv().await { +/// println!("Received: {:?}", msg); +/// } +/// ``` +#[derive(Clone)] +pub struct ConnectionManager +where + M: DeserializeOwned + Debug + Clone + Send + 'static, + P: MessageParser, +{ + /// Watch channel sender for state changes (enables reconnection detection) + state_tx: watch::Sender, + /// Watch channel receiver for state changes (for use in checking the current state) + state_rx: watch::Receiver, + /// Sender channel for outgoing messages + sender_tx: mpsc::UnboundedSender, + /// Broadcast sender for incoming messages + broadcast_tx: broadcast::Sender, + /// Phantom data for unused type parameters + _phantom: PhantomData

, +} + +impl ConnectionManager +where + M: DeserializeOwned + Debug + Clone + Send + 'static, + P: MessageParser, +{ + /// Create a new connection manager and start the connection loop. + /// + /// The `parser` is used to deserialize incoming WebSocket messages. + /// The connection loop runs in a background task and automatically + /// handles reconnection according to the config's `ReconnectConfig`. + pub fn new(endpoint: String, config: Config, parser: P) -> Result { + let (sender_tx, sender_rx) = mpsc::unbounded_channel(); + let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY); + let (state_tx, state_rx) = watch::channel(ConnectionState::Disconnected); + + // Spawn connection task + let connection_config = config; + let connection_endpoint = endpoint; + let broadcast_tx_clone = broadcast_tx.clone(); + let state_tx_clone = state_tx.clone(); + + tokio::spawn(async move { + Self::connection_loop( + connection_endpoint, + connection_config, + sender_rx, + broadcast_tx_clone, + parser, + state_tx_clone, + ) + .await; + }); + + Ok(Self { + state_tx, + state_rx, + sender_tx, + broadcast_tx, + _phantom: PhantomData, + }) + } + + /// Main connection loop with automatic reconnection. + async fn connection_loop( + endpoint: String, + config: Config, + mut sender_rx: mpsc::UnboundedReceiver, + broadcast_tx: broadcast::Sender, + parser: P, + state_tx: watch::Sender, + ) { + let mut attempt = 0_u32; + let mut backoff: backoff::ExponentialBackoff = config.reconnect.clone().into(); + + loop { + // Check if ConnectionManager was dropped (all sender_tx instances gone) + if sender_rx.is_closed() { + #[cfg(feature = "tracing")] + tracing::debug!("Sender channel closed, stopping connection loop"); + _ = state_tx.send(ConnectionState::Disconnected); + break; + } + + let state_rx = state_tx.subscribe(); + + _ = state_tx.send(ConnectionState::Connecting); + + // Attempt connection + match connect_async(&endpoint).await { + Ok((ws_stream, _)) => { + attempt = 0; + backoff.reset(); + _ = state_tx.send(ConnectionState::Connected { + since: Instant::now(), + }); + + // Handle connection + if let Err(e) = Self::handle_connection( + ws_stream, + &mut sender_rx, + &broadcast_tx, + state_rx, + config.clone(), + &parser, + ) + .await + { + #[cfg(feature = "tracing")] + tracing::error!("Error handling connection: {e:?}"); + #[cfg(not(feature = "tracing"))] + let _: &_ = &e; + } + } + Err(e) => { + let error = Error::with_source(Kind::WebSocket, WsError::Connection(e)); + #[cfg(feature = "tracing")] + tracing::warn!("Unable to connect: {error:?}"); + #[cfg(not(feature = "tracing"))] + let _: &_ = &error; + attempt = attempt.saturating_add(1); + } + } + + // Check if we should stop reconnecting + if let Some(max) = config.reconnect.max_attempts + && attempt >= max + { + _ = state_tx.send(ConnectionState::Disconnected); + break; + } + + // Update state and wait with exponential backoff + _ = state_tx.send(ConnectionState::Reconnecting { attempt }); + + if let Some(duration) = backoff.next_backoff() { + sleep(duration).await; + } + } + } + + /// Handle an active WebSocket connection. + async fn handle_connection( + ws_stream: WsStream, + sender_rx: &mut mpsc::UnboundedReceiver, + broadcast_tx: &broadcast::Sender, + state_rx: watch::Receiver, + config: Config, + parser: &P, + ) -> Result<()> { + let (mut write, mut read) = ws_stream.split(); + + // Channel to notify heartbeat loop when PONG is received + let (pong_tx, pong_rx) = watch::channel(Instant::now()); + let (ping_tx, mut ping_rx) = mpsc::unbounded_channel(); + + let heartbeat_handle = tokio::spawn(async move { + Self::heartbeat_loop(ping_tx, state_rx, &config, pong_rx).await; + }); + + loop { + tokio::select! { + // Handle incoming messages + Some(msg) = read.next() => { + match msg { + Ok(Message::Text(text)) if text == "PONG" => { + _ = pong_tx.send(Instant::now()); + } + Ok(Message::Text(text)) => { + #[cfg(feature = "tracing")] + tracing::trace!(%text, "Received WebSocket text message"); + + // Parse messages using the provided parser + match parser.parse(text.as_bytes()) { + Ok(messages) => { + for message in messages { + #[cfg(feature = "tracing")] + tracing::trace!(?message, "Parsed WebSocket message"); + _ = broadcast_tx.send(message); + } + } + Err(e) => { + #[cfg(feature = "tracing")] + tracing::warn!(%text, error = %e, "Failed to parse WebSocket message"); + #[cfg(not(feature = "tracing"))] + let _: (&_, &_) = (&text, &e); + } + } + } + Ok(Message::Close(_)) => { + heartbeat_handle.abort(); + return Err(Error::with_source( + Kind::WebSocket, + WsError::ConnectionClosed, + )) + } + Err(e) => { + heartbeat_handle.abort(); + return Err(Error::with_source( + Kind::WebSocket, + WsError::Connection(e), + )); + } + _ => { + // Ignore binary frames and unsolicited PONG replies. + } + } + } + + // Handle outgoing messages from subscriptions + Some(text) = sender_rx.recv() => { + if write.send(Message::Text(text.into())).await.is_err() { + break; + } + } + + // Handle PING requests from heartbeat loop + Some(()) = ping_rx.recv() => { + if write.send(Message::Text("PING".into())).await.is_err() { + break; + } + } + + // Check if connection is still active + else => { + break; + } + } + } + + // Cleanup + heartbeat_handle.abort(); + + Ok(()) + } + + /// Heartbeat loop that sends PING messages and monitors PONG responses. + async fn heartbeat_loop( + ping_tx: mpsc::UnboundedSender<()>, + state_rx: watch::Receiver, + config: &Config, + mut pong_rx: watch::Receiver, + ) { + let mut ping_interval = interval(config.heartbeat_interval); + + loop { + ping_interval.tick().await; + + // Check if still connected + if !state_rx.borrow().is_connected() { + break; + } + + // Mark current PONG state as seen before sending PING + // This prevents changed() from returning immediately due to a stale PONG + drop(pong_rx.borrow_and_update()); + + // Send PING request to message loop + let ping_sent = Instant::now(); + if ping_tx.send(()).is_err() { + // Message loop has terminated + break; + } + + // Wait for PONG within timeout + let pong_result = timeout(config.heartbeat_timeout, pong_rx.changed()).await; + + match pong_result { + Ok(Ok(())) => { + let last_pong = *pong_rx.borrow_and_update(); + if last_pong < ping_sent { + #[cfg(feature = "tracing")] + tracing::debug!( + "PONG received but older than last PING, connection may be stale" + ); + break; + } + } + Ok(Err(_)) => { + // Channel closed, connection is terminating + break; + } + Err(_) => { + // Timeout waiting for PONG + #[cfg(feature = "tracing")] + tracing::warn!( + "Heartbeat timeout: no PONG received within {:?}", + config.heartbeat_timeout + ); + break; + } + } + } + } + + /// Send a subscription request to the WebSocket server. + pub fn send(&self, request: &R) -> Result<()> { + let json = serde_json::to_string(request)?; + self.sender_tx + .send(json) + .map_err(|_e| WsError::ConnectionClosed)?; + Ok(()) + } + + /// Send a subscription request to the WebSocket server. + pub fn send_authenticated( + &self, + request: &R, + credentials: &Credentials, + ) -> Result<()> { + let json = request.as_authenticated(credentials)?; + self.sender_tx + .send(json) + .map_err(|_e| WsError::ConnectionClosed)?; + Ok(()) + } + + /// Get the current connection state. + #[must_use] + pub fn state(&self) -> ConnectionState { + *self.state_rx.borrow() + } + + /// Subscribe to incoming messages. + /// + /// Each call returns a new independent receiver. Multiple subscribers can + /// receive messages concurrently without blocking each other. + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { + self.broadcast_tx.subscribe() + } + + /// Subscribe to connection state changes. + /// + /// Returns a receiver that notifies when the connection state changes. + /// This is useful for detecting reconnections and re-establishing subscriptions. + #[must_use] + pub fn state_receiver(&self) -> watch::Receiver { + self.state_tx.subscribe() + } +} diff --git a/polymarket-client-sdk/src/ws/error.rs b/polymarket-client-sdk/src/ws/error.rs new file mode 100644 index 0000000..6d899ab --- /dev/null +++ b/polymarket-client-sdk/src/ws/error.rs @@ -0,0 +1,70 @@ +#![expect( + clippy::module_name_repetitions, + reason = "Error types include the module name to indicate their scope" +)] + +use std::error::Error as StdError; +use std::fmt; + +/// WebSocket error variants. +#[non_exhaustive] +#[derive(Debug)] +pub enum WsError { + /// Error connecting to or communicating with the WebSocket server + Connection(tokio_tungstenite::tungstenite::Error), + /// Error parsing a WebSocket message + MessageParse(serde_json::Error), + /// Subscription request failed + SubscriptionFailed(String), + /// Authentication failed for authenticated channel + AuthenticationFailed, + /// WebSocket connection was closed + ConnectionClosed, + /// Operation timed out + Timeout, + /// Received an invalid or unexpected message + InvalidMessage(String), + /// Subscription stream lagged and missed messages + Lagged { + /// Number of messages that were missed + count: u64, + }, +} + +impl fmt::Display for WsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connection(e) => write!(f, "WebSocket connection error: {e}"), + Self::MessageParse(e) => write!(f, "Failed to parse WebSocket message: {e}"), + Self::SubscriptionFailed(reason) => write!(f, "Subscription failed: {reason}"), + Self::AuthenticationFailed => write!(f, "WebSocket authentication failed"), + Self::ConnectionClosed => write!(f, "WebSocket connection closed"), + Self::Timeout => write!(f, "WebSocket operation timed out"), + Self::InvalidMessage(msg) => write!(f, "Invalid WebSocket message: {msg}"), + Self::Lagged { count } => write!(f, "Subscription lagged, missed {count} messages"), + } + } +} + +impl StdError for WsError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::Connection(e) => Some(e), + Self::MessageParse(e) => Some(e), + _ => None, + } + } +} + +// Integration with main Error type +impl From for crate::error::Error { + fn from(e: WsError) -> Self { + crate::error::Error::with_source(crate::error::Kind::WebSocket, e) + } +} + +impl From for crate::error::Error { + fn from(e: tokio_tungstenite::tungstenite::Error) -> Self { + crate::error::Error::with_source(crate::error::Kind::WebSocket, WsError::Connection(e)) + } +} diff --git a/polymarket-client-sdk/src/ws/mod.rs b/polymarket-client-sdk/src/ws/mod.rs new file mode 100644 index 0000000..5ab261c --- /dev/null +++ b/polymarket-client-sdk/src/ws/mod.rs @@ -0,0 +1,33 @@ +//! Core WebSocket infrastructure. +//! +//! This module provides generic connection management that can be +//! specialized for different WebSocket services using traits and the strategy pattern. +//! +//! # Architecture +//! +//! - [`ConnectionManager`]: Generic WebSocket connection handler with heartbeat and reconnection +//! - [`MessageParser`]: Trait for parsing incoming WebSocket messages +//! +//! # Example +//! +//! ```ignore +//! // Define your message type +//! #[derive(Clone, Debug, Deserialize)] +//! enum MyMessage { /* ... */ } +//! +//! let connection = ConnectionManager::new(endpoint, config, SimpleParser)?; +//! let subscriptions = SubscriptionManager::new(connection); +//! ``` + +pub mod config; +pub mod connection; +pub mod error; +pub mod traits; + +pub use connection::ConnectionManager; +#[expect( + clippy::module_name_repetitions, + reason = "WsError includes module name for clarity when used outside this module" +)] +pub use error::WsError; +pub use traits::*; diff --git a/polymarket-client-sdk/src/ws/traits.rs b/polymarket-client-sdk/src/ws/traits.rs new file mode 100644 index 0000000..71c17fd --- /dev/null +++ b/polymarket-client-sdk/src/ws/traits.rs @@ -0,0 +1,51 @@ +//! Core traits for generic WebSocket infrastructure. + +use secrecy::ExposeSecret as _; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +use crate::auth::Credentials; + +/// Message parser trait for converting raw bytes to messages. +/// +/// This abstracts the different parsing strategies: +/// - CLOB/WS: Interest-based filtering, peeking at `event_type` before full deserialization +/// - RTDS: Simple parse, no filtering +/// +/// # Example +/// +/// ```ignore +/// pub struct SimpleParser; +/// +/// impl MessageParser for SimpleParser { +/// fn parse(&self, bytes: &[u8]) -> crate::Result> { +/// let msg: MyMessage = serde_json::from_slice(bytes)?; +/// Ok(vec![msg]) +/// } +/// } +/// ``` +pub trait MessageParser: Send + Sync + 'static { + /// Parse incoming bytes into messages. + /// + /// May return empty vec if messages are filtered out based on interest or other criteria. + /// Handles both single objects and arrays of messages. + fn parse(&self, bytes: &[u8]) -> crate::Result>; +} + +pub trait WithCredentials: Serialize + Sized { + fn as_authenticated(&self, credentials: &Credentials) -> Result { + let mut payload_json = serde_json::to_value(self)?; + let auth = json!({ + "apiKey": credentials.key.to_string(), + "secret": credentials.secret.expose_secret(), + "passphrase": credentials.passphrase.expose_secret(), + }); + + if let Value::Object(ref mut obj) = payload_json { + obj.insert("auth".to_owned(), auth); + } + + serde_json::to_string(&payload_json) + } +} diff --git a/polymarket-client-sdk/tests/auth.rs b/polymarket-client-sdk/tests/auth.rs new file mode 100644 index 0000000..4c0b13a --- /dev/null +++ b/polymarket-client-sdk/tests/auth.rs @@ -0,0 +1,265 @@ +#![cfg(feature = "clob")] + +mod common; + +use std::str::FromStr as _; + +use alloy::signers::Signer as _; +use alloy::signers::local::LocalSigner; +use httpmock::MockServer; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::auth::{Credentials, ExposeSecret as _}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::error::{Kind, Synchronization, Validation}; +use reqwest::StatusCode; +use serde_json::json; + +use crate::common::{API_KEY, PASSPHRASE, POLY_ADDRESS, PRIVATE_KEY, SECRET, create_authenticated}; + +#[tokio::test] +async fn authenticate_with_explicit_credentials_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .credentials(Credentials::default()) + .authenticate() + .await?; + + assert_eq!(signer.address(), client.address()); + + Ok(()) +} + +#[tokio::test] +async fn authenticate_with_nonce_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key"); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY, + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .nonce(123) + .authenticate() + .await?; + + assert_eq!(signer.address(), client.address()); + + mock.assert(); + + Ok(()) +} + +#[tokio::test] +async fn authenticate_with_explicit_credentials_and_nonce_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let err = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .nonce(123) + .credentials(Credentials::default()) + .authenticate() + .await + .unwrap_err(); + + let validation_err = err.downcast_ref::().unwrap(); + + assert_eq!( + validation_err.reason, + "Credentials and nonce are both set. If nonce is set, then you must not supply credentials" + ); + + Ok(()) +} + +#[tokio::test] +async fn authenticated_to_unauthenticated_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + assert_eq!(client.host().as_str(), format!("{}/", server.base_url())); + client.deauthenticate().await?; + + Ok(()) +} + +#[tokio::test] +async fn authenticate_with_multiple_strong_references_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key"); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY, + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let _client_clone = client.clone(); + + let err = client + .authentication_builder(&signer) + .authenticate() + .await + .unwrap_err(); + + err.downcast_ref::().unwrap(); + + Ok(()) +} + +#[tokio::test] +async fn deauthenticated_with_multiple_strong_references_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let _client_clone = client.clone(); + + let err = client.deauthenticate().await.unwrap_err(); + let sync_error = err.downcast_ref::().unwrap(); + assert_eq!( + sync_error.to_string(), + "synchronization error: multiple threads are attempting to log in or log out" + ); + + Ok(()) +} + +#[tokio::test] +async fn create_api_key_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/auth/api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let credentials = client.create_api_key(&signer, None).await?; + + assert_eq!(credentials.key(), API_KEY); + mock.assert(); + + Ok(()) +} + +#[tokio::test] +async fn derive_api_key_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let credentials = client.derive_api_key(&signer, None).await?; + + assert_eq!(credentials.key(), API_KEY); + mock.assert(); + + Ok(()) +} + +#[tokio::test] +async fn create_or_derive_api_key_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/auth/api-key"); + then.status(StatusCode::NOT_FOUND); + }); + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let credentials = client.create_or_derive_api_key(&signer, None).await?; + + assert_eq!(credentials.key(), API_KEY); + mock.assert(); + mock2.assert(); + + Ok(()) +} + +#[tokio::test] +async fn create_or_derive_api_key_should_propagate_network_errors() -> anyhow::Result<()> { + // Use an invalid host to simulate a network error (connection refused) + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new("http://127.0.0.1:1", Config::default())?; + + let err = client + .create_or_derive_api_key(&signer, None) + .await + .expect_err("should fail with network error"); + + // Network errors should be propagated as Internal errors, not swallowed + assert_eq!(err.kind(), Kind::Internal); + + Ok(()) +} + +#[test] +fn credentials_secret_accessor_should_return_secret() { + let credentials = Credentials::new(API_KEY, SECRET.to_owned(), PASSPHRASE.to_owned()); + assert_eq!(credentials.secret().expose_secret(), SECRET); +} + +#[test] +fn credentials_passphrase_accessor_should_return_passphrase() { + let credentials = Credentials::new(API_KEY, SECRET.to_owned(), PASSPHRASE.to_owned()); + assert_eq!(credentials.passphrase().expose_secret(), PASSPHRASE); +} + +#[tokio::test] +async fn authenticated_client_should_expose_credentials() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let credentials = client.credentials(); + + assert_eq!(credentials.key(), API_KEY); + assert_eq!(credentials.secret().expose_secret(), SECRET); + assert_eq!(credentials.passphrase().expose_secret(), PASSPHRASE); + + Ok(()) +} diff --git a/polymarket-client-sdk/tests/bridge.rs b/polymarket-client-sdk/tests/bridge.rs new file mode 100644 index 0000000..77036ef --- /dev/null +++ b/polymarket-client-sdk/tests/bridge.rs @@ -0,0 +1,341 @@ +#![cfg(feature = "bridge")] +#![allow(clippy::unwrap_used, reason = "tests can panic on unwrap")] + +mod deposit { + use httpmock::{Method::POST, MockServer}; + use polymarket_client_sdk::bridge::{ + Client, + types::{DepositAddresses, DepositRequest, DepositResponse}, + }; + use polymarket_client_sdk::types::address; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn deposit_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/deposit") + .header("Content-Type", "application/json") + .json_body(json!({ + "address": "0x56687bf447db6ffa42ffe2204a05edaa20f55839" + })); + then.status(StatusCode::CREATED).json_body(json!({ + "address": { + "evm": "0x23566f8b2E82aDfCf01846E54899d110e97AC053", + "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", + "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g" + }, + "note": "Only certain chains and tokens are supported. See /supported-assets for details." + })); + }); + + let request = DepositRequest::builder() + .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + + let response = client.deposit(&request).await?; + + let expected = DepositResponse::builder() + .address( + DepositAddresses::builder() + .evm(address!("23566f8b2E82aDfCf01846E54899d110e97AC053")) + .svm("CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb") + .btc("bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g") + .build(), + ) + .note( + "Only certain chains and tokens are supported. See /supported-assets for details." + .to_owned(), + ) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn deposit_without_note_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(POST).path("/deposit"); + then.status(StatusCode::CREATED).json_body(json!({ + "address": { + "evm": "0x23566f8b2E82aDfCf01846E54899d110e97AC053", + "svm": "CrvTBvzryYxBHbWu2TiQpcqD5M7Le7iBKzVmEj3f36Jb", + "btc": "bc1q8eau83qffxcj8ht4hsjdza3lha9r3egfqysj3g" + } + })); + }); + + let request = DepositRequest::builder() + .address(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + + let response = client.deposit(&request).await?; + + assert!(response.note.is_none()); + assert_eq!( + response.address.evm, + address!("23566f8b2E82aDfCf01846E54899d110e97AC053") + ); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn deposit_bad_request_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(POST).path("/deposit"); + then.status(StatusCode::BAD_REQUEST) + .json_body(json!({"error": "Invalid address"})); + }); + + let request = DepositRequest::builder() + .address(address!("0000000000000000000000000000000000000000")) + .build(); + + let result = client.deposit(&request).await; + + result.unwrap_err(); + mock.assert(); + + Ok(()) + } +} + +mod supported_assets { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::bridge::{ + Client, + types::{SupportedAsset, SupportedAssetsResponse, Token}, + }; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + #[tokio::test] + async fn supported_assets_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/supported-assets"); + then.status(StatusCode::OK).json_body(json!({ + "supportedAssets": [ + { + "chainId": "1", + "chainName": "Ethereum", + "token": { + "name": "USD Coin", + "symbol": "USDC", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "decimals": 6 + }, + "minCheckoutUsd": 45.0 + }, + { + "chainId": "137", + "chainName": "Polygon", + "token": { + "name": "Bridged USDC", + "symbol": "USDC.e", + "address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "decimals": 6 + }, + "minCheckoutUsd": 10.0 + } + ] + })); + }); + + let response = client.supported_assets().await?; + + let expected = SupportedAssetsResponse::builder() + .supported_assets(vec![ + SupportedAsset::builder() + .chain_id(1_u64) + .chain_name("Ethereum") + .token( + Token::builder() + .name("USD Coin") + .symbol("USDC") + .address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + .decimals(6_u8) + .build(), + ) + .min_checkout_usd(dec!(45)) + .build(), + SupportedAsset::builder() + .chain_id(137_u64) + .chain_name("Polygon") + .token( + Token::builder() + .name("Bridged USDC") + .symbol("USDC.e") + .address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174") + .decimals(6_u8) + .build(), + ) + .min_checkout_usd(dec!(10)) + .build(), + ]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn supported_assets_empty_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/supported-assets"); + then.status(StatusCode::OK) + .json_body(json!({"supportedAssets": []})); + }); + + let response = client.supported_assets().await?; + + assert!(response.supported_assets.is_empty()); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn supported_assets_server_error_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/supported-assets"); + then.status(StatusCode::INTERNAL_SERVER_ERROR) + .json_body(json!({"error": "Internal server error"})); + }); + + let result = client.supported_assets().await; + + result.unwrap_err(); + mock.assert(); + + Ok(()) + } +} + +mod deposit_status { + use alloy::primitives::{U256, address}; + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::bridge::{ + Client, + types::{DepositTransaction, DepositTransactionStatus, StatusRequest, StatusResponse}, + }; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn deposit_status_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/status/0x9cb12Ec30568ab763ae5891ce4b8c5C96CeD72C9"); + then.status(StatusCode::OK).json_body(json!({ + "transactions": [ + { + "fromChainId": "1", + "fromTokenAddress": "11111111111111111111111111111111", + "fromAmountBaseUnit": "13566635", + "toChainId": "137", + "toTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "status": "COMPLETED", + "txHash": "3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFwUB8fk37hFkxz35P5UEnnmWz21rb2t5wJ8pq3EE2XnxU", + "createdTimeMs": 1_757_646_914_535_u64, + + }, + { + "fromChainId": "2", + "fromTokenAddress": "11111111111111111111111111111111", + "fromAmountBaseUnit": "13_566_635", + "toChainId": "137", + "toTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "status": "DEPOSIT_DETECTED", + + } + ] + })); + }); + + let request = StatusRequest::builder() + .address("0x9cb12Ec30568ab763ae5891ce4b8c5C96CeD72C9") + .build(); + let response = client.status(&request).await?; + + let expected = StatusResponse::builder() + .transactions(vec![ + DepositTransaction::builder() + .from_chain_id(1) + .from_token_address("11111111111111111111111111111111") + .from_amount_base_unit(U256::from(13_566_635)) + .to_chain_id(137) + .to_token_address(address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")) + .status(DepositTransactionStatus::Completed) + .tx_hash("3atr19NAiNCYt24RHM1WnzZp47RXskpTDzspJoCBBaMFwUB8fk37hFkxz35P5UEnnmWz21rb2t5wJ8pq3EE2XnxU") + .created_time_ms(1_757_646_914_535) + .build(), + DepositTransaction::builder() + .from_chain_id(2) + .from_token_address("11111111111111111111111111111111") + .from_amount_base_unit(U256::from(13_566_635)) + .to_chain_id(137) + .to_token_address(address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")) + .status(DepositTransactionStatus::DepositDetected) + .build(), + ]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } +} + +mod client { + use polymarket_client_sdk::bridge::Client; + + #[test] + fn default_client_should_have_correct_host() { + let client = Client::default(); + assert_eq!(client.host().as_str(), "https://bridge.polymarket.com/"); + } + + #[test] + fn custom_host_should_succeed() -> anyhow::Result<()> { + let client = Client::new("https://custom.bridge.api")?; + assert_eq!(client.host().as_str(), "https://custom.bridge.api/"); + Ok(()) + } + + #[test] + fn invalid_host_should_fail() { + let result = Client::new("not a valid url"); + result.unwrap_err(); + } +} diff --git a/polymarket-client-sdk/tests/clob.rs b/polymarket-client-sdk/tests/clob.rs new file mode 100644 index 0000000..f4bacad --- /dev/null +++ b/polymarket-client-sdk/tests/clob.rs @@ -0,0 +1,3242 @@ +#![cfg(feature = "clob")] +#![allow( + clippy::unwrap_used, + reason = "Do not need additional syntax for setting up tests, and https://github.com/rust-lang/rust-clippy/issues/13981" +)] + +mod common; + +use std::collections::HashMap; +use std::str::FromStr as _; + +use alloy::primitives::U256; +use chrono::{DateTime, Utc}; +use httpmock::MockServer; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::clob::types::SignatureType; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::{Decimal, b256}; +use reqwest::StatusCode; +use rust_decimal_macros::dec; +use serde_json::json; +use uuid::Uuid; + +use crate::common::{ + POLY_ADDRESS, POLY_API_KEY, POLY_PASSPHRASE, PRIVATE_KEY, create_authenticated, + ensure_requirements, token_1, token_2, +}; + +mod unauthenticated { + + use chrono::{TimeDelta, TimeZone as _}; + use futures_util::future; + use futures_util::stream::StreamExt as _; + use polymarket_client_sdk::clob::types::request::{ + LastTradePriceRequest, MidpointRequest, OrderBookSummaryRequest, PriceHistoryRequest, + PriceRequest, SpreadRequest, + }; + use polymarket_client_sdk::clob::types::response::{ + FeeRateResponse, GeoblockResponse, LastTradePriceResponse, LastTradesPricesResponse, + MarketResponse, MidpointResponse, MidpointsResponse, NegRiskResponse, + OrderBookSummaryResponse, OrderSummary, Page, PriceHistoryResponse, PricePoint, + PriceResponse, PricesResponse, Rewards, SimplifiedMarketResponse, SpreadResponse, + SpreadsResponse, TickSizeResponse, Token, + }; + use polymarket_client_sdk::clob::types::{Interval, Side, TickSize, TimeRange}; + use polymarket_client_sdk::error::Status; + use polymarket_client_sdk::types::address; + use reqwest::Method; + + use super::*; + + #[tokio::test] + async fn ok_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(StatusCode::OK).body("\"OK\""); + }); + + let response = client.ok().await?; + + assert_eq!(response, "OK"); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn server_time_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/time"); + then.status(StatusCode::OK).body("1764612536"); + }); + + let response = client.server_time().await?; + + assert_eq!(response, 1_764_612_536); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn midpoint_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/midpoint") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "mid": "0.5" })); + }); + + let request = MidpointRequest::builder().token_id(token_1()).build(); + let response = client.midpoint(&request).await?; + + let expected = MidpointResponse::builder().mid(dec!(0.5)).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn midpoints_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/midpoints") + .json_body(json!([{ "token_id": token_1().to_string() }])); + then.status(StatusCode::OK).json_body(json!( + { token_1().to_string(): 0.5 } + )); + }); + + let request = MidpointRequest::builder().token_id(token_1()).build(); + let response = client.midpoints(&[request]).await?; + + let expected = MidpointsResponse::builder() + .midpoints(HashMap::from_iter([(token_1(), dec!(0.5))])) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn price_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/price") + .query_param("token_id", token_1().to_string()) + .query_param("side", "BUY"); + then.status(StatusCode::OK) + .json_body(json!({ "price": "0.5" })); + }); + + let request = PriceRequest::builder() + .token_id(token_1()) + .side(Side::Buy) + .build(); + let response = client.price(&request).await?; + + let expected = PriceResponse::builder().price(dec!(0.5)).build(); + + assert_eq!(response, expected); + mock.assert(); + + let request = PriceRequest::builder() + .token_id(token_1()) + .side(Side::Sell) + .build(); + let err = client.price(&request).await.unwrap_err(); + let status_err = err.downcast_ref::().unwrap(); + + assert_eq!( + status_err.to_string(), + r#"error(404 Not Found) making GET call to /price with {"message":"Request did not match any route or mock"}"# + ); + assert_eq!(status_err.status_code, StatusCode::NOT_FOUND); + assert_eq!(status_err.method, Method::GET); + assert_eq!(status_err.path, "/price"); + + Ok(()) + } + + #[tokio::test] + async fn prices_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/prices") + .json_body(json!([{ "token_id": token_1().to_string(), "side": "BUY" }])); + then.status(StatusCode::OK) + .json_body(json!({ token_1().to_string(): { "BUY": 0.5 } })); + }); + + let mut price_map = HashMap::new(); + let mut side_map = HashMap::new(); + side_map.insert(Side::Buy, dec!(0.5)); + price_map.insert(token_1(), side_map); + + let request = PriceRequest::builder() + .token_id(token_1()) + .side(Side::Buy) + .build(); + let response = client.prices(&[request]).await?; + + let expected = PricesResponse::builder().prices(price_map).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn all_prices_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/prices"); + then.status(StatusCode::OK) + .json_body(json!({ token_1().to_string(): { "BUY": 0.5, "SELL": 0.6 } })); + }); + + let response = client.all_prices().await?; + + let mut price_map = HashMap::new(); + let mut side_map = HashMap::new(); + side_map.insert(Side::Buy, dec!(0.5)); + side_map.insert(Side::Sell, dec!(0.6)); + price_map.insert(token_1(), side_map); + + let expected = PricesResponse::builder().prices(price_map).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn price_history_with_interval_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let test_market = U256::from(0x123); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/prices-history") + .query_param("market", "291") + .query_param("interval", "1h") + .query_param("fidelity", "10"); + then.status(StatusCode::OK).json_body(json!({ + "history": [ + { "t": 1000, "p": "0.5" }, + { "t": 1500, "p": "0.55" }, + { "t": 2000, "p": "0.6" } + ] + })); + }); + + let request = PriceHistoryRequest::builder() + .market(test_market) + .time_range(Interval::OneHour) + .fidelity(10) + .build(); + let response = client.price_history(&request).await?; + + let expected = PriceHistoryResponse::builder() + .history(vec![ + PricePoint::builder().t(1000).p(dec!(0.5)).build(), + PricePoint::builder().t(1500).p(dec!(0.55)).build(), + PricePoint::builder().t(2000).p(dec!(0.6)).build(), + ]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn price_history_with_range_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let test_market = U256::from(0x123); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/prices-history") + .query_param("market", "291") + .query_param("startTs", "1000") + .query_param("endTs", "2000"); + then.status(StatusCode::OK).json_body(json!({ + "history": [ + { "t": 1000, "p": "0.5" }, + { "t": 2000, "p": "0.6" } + ] + })); + }); + + let request = PriceHistoryRequest::builder() + .market(test_market) + .time_range(TimeRange::from_range(1000, 2000)) + .build(); + let response = client.price_history(&request).await?; + + let expected = PriceHistoryResponse::builder() + .history(vec![ + PricePoint::builder().t(1000).p(dec!(0.5)).build(), + PricePoint::builder().t(2000).p(dec!(0.6)).build(), + ]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn spread_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/spread") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "spread": "0.5" })); + }); + + let request = SpreadRequest::builder().token_id(token_1()).build(); + let response = client.spread(&request).await?; + + let expected = SpreadResponse::builder().spread(dec!(0.5)).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn spreads_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/spreads") + .json_body(json!([{ "token_id": token_1().to_string() }])); + then.status(StatusCode::OK) + .json_body(json!({ "spreads": { token_1().to_string(): 2 } })); + }); + + let mut spread_map = HashMap::new(); + spread_map.insert(token_1(), Decimal::TWO); + + let request = SpreadRequest::builder().token_id(token_1()).build(); + let response = client.spreads(&[request]).await?; + + let expected = SpreadsResponse::builder().spreads(spread_map).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn tick_size_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/tick-size") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "minimum_tick_size": "0.1" })); + }); + + let response = client.tick_size(token_1()).await?; + + let expected = TickSizeResponse::builder() + .minimum_tick_size(TickSize::Tenth) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn neg_risk_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/neg-risk") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "neg_risk": true })); + }); + + let response = client.neg_risk(token_1()).await?; + + let expected = NegRiskResponse::builder().neg_risk(true).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn fee_rate_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/fee-rate") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "base_fee": 0 })); + }); + + let response = client.fee_rate_bps(token_1()).await?; + + let expected = FeeRateResponse::builder().base_fee(0).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn set_tick_size_should_prepopulate_cache() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + // Pre-populate the cache - no HTTP call should be made + client.set_tick_size(token_1(), TickSize::Hundredth); + + // This should return the cached value without making an HTTP request + let response = client.tick_size(token_1()).await?; + + let expected = TickSizeResponse::builder() + .minimum_tick_size(TickSize::Hundredth) + .build(); + + assert_eq!(response, expected); + // No mock was set up, so if an HTTP call was made, this test would fail + + Ok(()) + } + + #[tokio::test] + async fn set_neg_risk_should_prepopulate_cache() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + // Pre-populate the cache + client.set_neg_risk(token_2(), true); + + // This should return the cached value without making an HTTP request + let response = client.neg_risk(token_2()).await?; + + let expected = NegRiskResponse::builder().neg_risk(true).build(); + + assert_eq!(response, expected); + + Ok(()) + } + + #[tokio::test] + async fn set_fee_rate_bps_should_prepopulate_cache() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + // Pre-populate the cache with 50 basis points (0.50%) + client.set_fee_rate_bps(token_1(), 50); + + // This should return the cached value without making an HTTP request + let response = client.fee_rate_bps(token_1()).await?; + + let expected = FeeRateResponse::builder().base_fee(50).build(); + + assert_eq!(response, expected); + + Ok(()) + } + + #[tokio::test] + async fn invalidate_caches_should_clear_prepopulated_values() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + // Pre-populate the cache + client.set_tick_size(token_1(), TickSize::Tenth); + + // Verify the cache works + let response = client.tick_size(token_1()).await?; + assert_eq!(response.minimum_tick_size, TickSize::Tenth); + + // Invalidate the cache + client.invalidate_internal_caches(); + + // Now set up a mock for the HTTP call that will be made after cache invalidation + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/tick-size") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "minimum_tick_size": "0.001" })); + }); + + // After invalidation, this should make an HTTP call + let response = client.tick_size(token_1()).await?; + + assert_eq!(response.minimum_tick_size, TickSize::Thousandth); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn order_book_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/book") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK).json_body(json!({ + "market": "0x00000000000000000000000000000000000000000000000000000000aabbcc00", + "asset_id": token_1(), + "tick_size": TickSize::Hundredth.as_decimal(), + "min_order_size": "100", + "neg_risk": false, + "timestamp": "123456789", + "bids": [ + { + "price": "0.3", + "size": "100" + }, + { + "price": "0.4", + "size": "100" + } + ], + "asks": [ + { + "price": "0.6", + "size": "100" + }, + { + "price": "0.7", + "size": "100" + } + ] + })); + }); + + let request = OrderBookSummaryRequest::builder() + .token_id(token_1()) + .build(); + let response = client.order_book(&request).await?; + + let expected = OrderBookSummaryResponse::builder() + .market(b256!( + "00000000000000000000000000000000000000000000000000000000aabbcc00" + )) + .neg_risk(false) + .timestamp(Utc.timestamp_millis_opt(123_456_789).unwrap()) + .min_order_size(Decimal::ONE_HUNDRED) + .tick_size(TickSize::Hundredth) + .asset_id(token_1()) + .bids(vec![ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + ]) + .asks(vec![ + OrderSummary::builder() + .price(dec!(0.6)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.7)) + .size(Decimal::ONE_HUNDRED) + .build(), + ]) + .build(); + + assert_eq!(response, expected); + assert_eq!( + expected.hash()?, + "03196cc4f520d81c0748b4f042f2096441d160e8ef5eac4f0378cb5bd80fd183" + ); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn order_books_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/books") + .json_body(json!([{ "token_id": token_1().to_string() }])); + then.status(StatusCode::OK).json_body(json!([{ + "market": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_id": token_1(), + "tick_size": TickSize::Hundredth.as_decimal(), + "min_order_size": "5", + "neg_risk": false, + "timestamp": "1", + "asks": [{ + "price": "2", + "size": "1" + }] + }])); + }); + + let request = OrderBookSummaryRequest::builder() + .token_id(token_1()) + .build(); + let response = client.order_books(&[request]).await?; + + let expected = vec![ + OrderBookSummaryResponse::builder() + .market(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .neg_risk(false) + .timestamp(DateTime::::UNIX_EPOCH + TimeDelta::milliseconds(1)) + .min_order_size(dec!(5)) + .tick_size(TickSize::Hundredth) + .asset_id(token_1()) + .asks(vec![ + OrderSummary::builder() + .price(Decimal::TWO) + .size(Decimal::ONE) + .build(), + ]) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn last_trade_price_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/last-trade-price") + .query_param("token_id", token_1().to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "price": 0.12, "side": "BUY" })); + }); + + let request = LastTradePriceRequest::builder().token_id(token_1()).build(); + let response = client.last_trade_price(&request).await?; + + let expected = LastTradePriceResponse::builder() + .price(dec!(0.12)) + .side(Side::Buy) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn last_trades_prices_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/last-trades-prices") + .json_body(json!([{ "token_id": token_1().to_string() }])); + then.status(StatusCode::OK).json_body( + json!([{ "token_id": token_1().to_string(), "price": 0.12, "side": "BUY" }]), + ); + }); + + let request = LastTradePriceRequest::builder().token_id(token_1()).build(); + let response = client.last_trades_prices(&[request]).await?; + + let expected = vec![ + LastTradesPricesResponse::builder() + .token_id(token_1()) + .price(dec!(0.12)) + .side(Side::Buy) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn market_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/markets/1"); + then.status(StatusCode::OK).json_body(json!({ + "enable_order_book": true, + "active": true, + "closed": false, + "archived": false, + "accepting_orders": true, + "accepting_order_timestamp": "2024-01-15T12:34:56Z", + "minimum_order_size": "1", + "minimum_tick_size": "0.01", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "question_id": "0x0000000000000000000000000000000000000000000000000000000067890abc", + "question": "Will BTC close above $50k today?", + "description": "A market about BTC daily close price", + "market_slug": "btc-close-above-50k", + "end_date_iso": "2024-02-01T00:00:00Z", + "game_start_time": null, + "seconds_delay": 5, + "fpmm": "0x0000000000000000000000000000000000abc123", + "maker_base_fee": "0", + "taker_base_fee": 0.1, + "notifications_enabled": true, + "neg_risk": false, + "neg_risk_market_id": "", + "neg_risk_request_id": "", + "icon": "https://example.com/icon.png", + "image": "https://example.com/image.png", + "rewards": { + "rates": null, + "min_size": "10.0", + "max_spread": "0.05" + }, + "is_50_50_outcome": false, + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": false + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "tags": [ + "crypto", + "btc", + "price" + ] + })); + }); + + let response = client.market("1").await?; + + let expected = MarketResponse::builder() + .enable_order_book(true) + .active(true) + .closed(false) + .archived(false) + .accepting_orders(true) + .accepting_order_timestamp("2024-01-15T12:34:56Z".parse::>().unwrap()) + .minimum_order_size(Decimal::ONE) + .minimum_tick_size(TickSize::Hundredth.as_decimal()) + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .question_id(b256!( + "0000000000000000000000000000000000000000000000000000000067890abc" + )) + .question("Will BTC close above $50k today?") + .description("A market about BTC daily close price") + .market_slug("btc-close-above-50k") + .end_date_iso("2024-02-01T00:00:00Z".parse::>().unwrap()) + .seconds_delay(5) + .fpmm(address!("0000000000000000000000000000000000abc123")) + .maker_base_fee(Decimal::ZERO) + .taker_base_fee(dec!(0.1)) + .notifications_enabled(true) + .neg_risk(false) + .icon("https://example.com/icon.png") + .image("https://example.com/image.png") + .rewards( + Rewards::builder() + .min_size(dec!(10.0)) + .max_spread(dec!(0.05)) + .build(), + ) + .is_50_50_outcome(false) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(false) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .tags(vec!["crypto".into(), "btc".into(), "price".into()]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn sampling_markets_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/sampling-markets"); + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "enable_order_book": true, + "active": true, + "closed": false, + "archived": false, + "accepting_orders": true, + "accepting_order_timestamp": "2024-01-15T12:34:56Z", + "minimum_order_size": "1", + "minimum_tick_size": "0.01", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "question_id": "0x0000000000000000000000000000000000000000000000000000000067890abc", + "question": "Will BTC close above $50k today?", + "description": "A market about BTC daily close price", + "market_slug": "btc-close-above-50k", + "end_date_iso": "2024-02-01T00:00:00Z", + "game_start_time": null, + "seconds_delay": 5, + "fpmm": "0x0000000000000000000000000000000000abc123", + "maker_base_fee": "0", + "taker_base_fee": "0", + "notifications_enabled": true, + "neg_risk": false, + "neg_risk_market_id": "", + "neg_risk_request_id": "", + "icon": "https://example.com/icon.png", + "image": "https://example.com/image.png", + "rewards": { + "rates": null, + "min_size": "10.0", + "max_spread": "0.05" + }, + "is_50_50_outcome": false, + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": false + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "tags": [ + "crypto", + "btc", + "price" + ] + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let response = client.sampling_markets(None).await?; + + let market = MarketResponse::builder() + .enable_order_book(true) + .active(true) + .closed(false) + .archived(false) + .accepting_orders(true) + .accepting_order_timestamp("2024-01-15T12:34:56Z".parse::>().unwrap()) + .minimum_order_size(Decimal::ONE) + .minimum_tick_size(TickSize::Hundredth.as_decimal()) + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .question_id(b256!( + "0000000000000000000000000000000000000000000000000000000067890abc" + )) + .question("Will BTC close above $50k today?") + .description("A market about BTC daily close price") + .market_slug("btc-close-above-50k") + .end_date_iso("2024-02-01T00:00:00Z".parse::>().unwrap()) + .seconds_delay(5) + .fpmm(address!("0000000000000000000000000000000000abc123")) + .maker_base_fee(Decimal::ZERO) + .taker_base_fee(Decimal::ZERO) + .notifications_enabled(true) + .neg_risk(false) + .icon("https://example.com/icon.png") + .image("https://example.com/image.png") + .rewards( + Rewards::builder() + .min_size(dec!(10.0)) + .max_spread(dec!(0.05)) + .build(), + ) + .is_50_50_outcome(false) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(false) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .tags(vec!["crypto".into(), "btc".into(), "price".into()]) + .build(); + let expected = Page::builder() + .data(vec![market]) + .next_cursor("next") + .limit(1) + .count(1) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn simplified_markets_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/simplified-markets"); + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "condition_id": "0x00000000000000000000000000000000000000000000000000000000c0012345", + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": false + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "rewards": { + "rates": null, + "min_size": "10.0", + "max_spread": "0.05" + }, + "archived": false, + "accepting_orders": true, + "active": true, + "closed": false + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let response = client.simplified_markets(None).await?; + + let simplified = SimplifiedMarketResponse::builder() + .condition_id(b256!( + "00000000000000000000000000000000000000000000000000000000c0012345" + )) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(false) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .rewards( + Rewards::builder() + .min_size(dec!(10.0)) + .max_spread(dec!(0.05)) + .build(), + ) + .archived(false) + .accepting_orders(true) + .active(true) + .closed(false) + .build(); + let expected = Page::builder() + .data(vec![simplified]) + .next_cursor("next") + .limit(1) + .count(1) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn sampling_simplified_markets_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/sampling-simplified-markets"); + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "condition_id": "0x00000000000000000000000000000000000000000000000000000000c0012345", + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": false + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "rewards": { + "rates": null, + "min_size": "10.0", + "max_spread": "0.05" + }, + "archived": false, + "accepting_orders": true, + "active": true, + "closed": false + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let response = client.sampling_simplified_markets(None).await?; + + let simplified = SimplifiedMarketResponse::builder() + .condition_id(b256!( + "00000000000000000000000000000000000000000000000000000000c0012345" + )) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(false) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .rewards( + Rewards::builder() + .min_size(dec!(10.0)) + .max_spread(dec!(0.05)) + .build(), + ) + .archived(false) + .accepting_orders(true) + .active(true) + .closed(false) + .build(); + let expected = Page::builder() + .data(vec![simplified]) + .next_cursor("next") + .limit(1) + .count(1) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn stream_markets_should_succeed() -> anyhow::Result<()> { + const TERMINAL_CURSOR: &str = "LTE="; // base64("-1") + + let server = MockServer::start(); + let client = Client::new(&server.base_url(), Config::default())?; + + let json = json!({ + "data": [ + { + "enable_order_book": true, + "active": true, + "closed": false, + "archived": false, + "accepting_orders": true, + "accepting_order_timestamp": "2024-01-15T12:34:56Z", + "minimum_order_size": "1", + "minimum_tick_size": "0.01", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "question_id": "0x0000000000000000000000000000000000000000000000000000000067890abc", + "question": "Will BTC close above $50k today?", + "description": "A market about BTC daily close price", + "market_slug": "btc-close-above-50k", + "end_date_iso": "2024-02-01T00:00:00Z", + "game_start_time": null, + "seconds_delay": 5, + "fpmm": "0x0000000000000000000000000000000000abc123", + "maker_base_fee": "0", + "taker_base_fee": "0", + "notifications_enabled": true, + "neg_risk": false, + "neg_risk_market_id": "", + "neg_risk_request_id": "", + "icon": "https://example.com/icon.png", + "image": "https://example.com/image.png", + "rewards": { + "rates": null, + "min_size": "10.0", + "max_spread": "0.05" + }, + "is_50_50_outcome": false, + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": false + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "tags": [ + "crypto", + "btc", + "price" + ] + } + ], + "limit": 1, + "count": 1 + }); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/markets") + .is_true(|req| req.query_params().is_empty()); + + let mut json_with_cursor = json.clone(); + if let Some(obj) = json_with_cursor.as_object_mut() { + obj.insert( + "next_cursor".to_owned(), + serde_json::Value::String("1".to_owned()), + ); + } + + then.status(StatusCode::OK).json_body(json_with_cursor); + }); + + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/markets") + .query_param("next_cursor", "1"); + + let mut json_with_cursor = json.clone(); + if let Some(obj) = json_with_cursor.as_object_mut() { + obj.insert( + "next_cursor".to_owned(), + serde_json::Value::String(TERMINAL_CURSOR.to_owned()), + ); + } + + then.status(StatusCode::OK).json_body(json_with_cursor); + }); + + let response: Vec = client + .stream_data(Client::markets) + .filter_map(|d| future::ready(d.ok())) + .collect() + .await; + + let market = MarketResponse::builder() + .enable_order_book(true) + .active(true) + .closed(false) + .archived(false) + .accepting_orders(true) + .accepting_order_timestamp("2024-01-15T12:34:56Z".parse::>().unwrap()) + .minimum_order_size(Decimal::ONE) + .minimum_tick_size(TickSize::Hundredth.as_decimal()) + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .question_id(b256!( + "0000000000000000000000000000000000000000000000000000000067890abc" + )) + .question("Will BTC close above $50k today?") + .description("A market about BTC daily close price") + .market_slug("btc-close-above-50k") + .end_date_iso("2024-02-01T00:00:00Z".parse::>().unwrap()) + .seconds_delay(5) + .fpmm(address!("0000000000000000000000000000000000abc123")) + .maker_base_fee(Decimal::ZERO) + .taker_base_fee(Decimal::ZERO) + .notifications_enabled(true) + .neg_risk(false) + .icon("https://example.com/icon.png") + .image("https://example.com/image.png") + .rewards( + Rewards::builder() + .min_size(dec!(10.0)) + .max_spread(dec!(0.05)) + .build(), + ) + .is_50_50_outcome(false) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(false) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .tags(vec!["crypto".into(), "btc".into(), "price".into()]) + .build(); + let expected = vec![market.clone(), market]; + + assert_eq!(response, expected); + mock.assert(); + mock2.assert(); + + Ok(()) + } + + #[tokio::test] + async fn check_geoblock_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let config = Config::builder().geoblock_host(server.base_url()).build(); + let client = Client::new(&server.base_url(), config)?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/api/geoblock"); + then.status(StatusCode::OK).json_body(json!({ + "blocked": false, + "ip": "192.168.1.1", + "country": "US", + "region": "NY" + })); + }); + + let response = client.check_geoblock().await?; + + let expected = GeoblockResponse::builder() + .blocked(false) + .ip("192.168.1.1".to_owned()) + .country("US".to_owned()) + .region("NY".to_owned()) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn check_geoblock_blocked_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let config = Config::builder().geoblock_host(server.base_url()).build(); + let client = Client::new(&server.base_url(), config)?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/api/geoblock"); + then.status(StatusCode::OK).json_body(json!({ + "blocked": true, + "ip": "10.0.0.1", + "country": "CU", + "region": "HAV" + })); + }); + + let response = client.check_geoblock().await?; + + assert!(response.blocked); + assert_eq!(response.country, "CU"); + mock.assert(); + + Ok(()) + } +} + +mod authenticated { + #[cfg(feature = "heartbeats")] + use std::time::Duration; + + use alloy::primitives::Signature; + use alloy::signers::Signer as _; + use alloy::signers::local::LocalSigner; + use chrono::NaiveDate; + use httpmock::Method::{DELETE, GET, POST}; + use polymarket_client_sdk::clob::types::request::{ + BalanceAllowanceRequest, CancelMarketOrderRequest, DeleteNotificationsRequest, + OrdersRequest, TradesRequest, UserRewardsEarningRequest, + }; + use polymarket_client_sdk::clob::types::response::{ + ApiKeysResponse, BalanceAllowanceResponse, BanStatusResponse, CancelOrdersResponse, + CurrentRewardResponse, Earning, HeartbeatResponse, MakerOrder, MarketRewardResponse, + MarketRewardsConfig, NotificationPayload, NotificationResponse, OpenOrderResponse, + OrderScoringResponse, Page, PostOrderResponse, RewardsConfig, Token, + TotalUserEarningResponse, TradeResponse, UserEarningResponse, UserRewardsEarningResponse, + }; + use polymarket_client_sdk::clob::types::{ + AssetType, OrderStatusType, OrderType, Side, SignableOrder, SignedOrder, TickSize, + TradeStatusType, TraderSide, + }; + #[cfg(feature = "heartbeats")] + use polymarket_client_sdk::error::Synchronization; + use polymarket_client_sdk::types::{Address, address, b256}; + + use super::*; + use crate::common::{ + API_KEY, PASSPHRASE, POLY_NONCE, POLY_SIGNATURE, POLY_TIMESTAMP, SECRET, SIGNATURE, + TIMESTAMP, + }; + + #[tokio::test] + async fn api_keys_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/auth/api-keys") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK) + .json_body(json!({"apiKeys": [API_KEY]})); + }); + + let response = client.api_keys().await?; + + let expected = ApiKeysResponse::builder().keys(vec![API_KEY]).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn delete_api_keys_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/auth/api-key") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK).body("\"\""); + }); + + client.delete_api_key().await?; + + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn closed_only_mode_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/auth/ban-status/closed-only") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK) + .json_body(json!({"closed_only": true})); + }); + + let response = client.closed_only_mode().await?; + + let expected = BanStatusResponse::builder().closed_only(true).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + // Also fills in some other, less often used fields like nonce, and salt generator + #[tokio::test] + async fn sign_order_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()) + .header(POLY_NONCE, "0") + .header(POLY_SIGNATURE, SIGNATURE) + .header(POLY_TIMESTAMP, TIMESTAMP); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + let mock2 = server.mock(|when, then| { + when.method(GET).path("/time"); + then.status(StatusCode::OK) + .json_body(TIMESTAMP.parse::().unwrap()); + }); + + let funder = address!("0x995c9b1f779c04e65AF8ea3360F96c43b5e62316"); + let config = Config::builder().use_server_time(true).build(); + let client = Client::new(&server.base_url(), config)? + .authentication_builder(&signer) + .funder(funder) + .signature_type(SignatureType::Proxy) + .salt_generator(|| 1) // To ensure determinism + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + + assert_eq!( + client.tick_size(token_1()).await?.minimum_tick_size, + TickSize::Thousandth + ); + + let taker = address!("0xf7fB45986800e2D259BAa25B56466bd02dA37a44"); + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.512)) + .size(Decimal::ONE_HUNDRED) + .side(Side::Buy) + .taker(taker) + .nonce(2) + .build() + .await?; + + let signed_order = client.sign(&signer, signable_order.clone()).await?; + + let expected = SignedOrder::builder() + .owner(API_KEY) + .order(signable_order.order) + .order_type(OrderType::GTC) + .post_only(false) + .signature(Signature::new( + U256::from_str( + "67938079796141091828598175285011746318151402208362009718761031231176791189384", + )?, + U256::from_str( + "31661255856293674232712511615893783899761903915420680037924826147367342033568", + )?, + true, + )) + .build(); + + assert_eq!(signed_order.order.taker, taker); + assert_eq!(signed_order.order.maker, funder); + assert_ne!(signed_order.order.maker, client.address()); + assert_eq!(signed_order.order.signatureType, SignatureType::Proxy as u8); + assert_eq!(signed_order.order.nonce, U256::from(2)); + assert_eq!(signed_order.order.salt, U256::from(1)); + assert_eq!( + client.address(), + address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266") + ); + + assert_eq!(signed_order, expected); + mock.assert(); + mock2.assert_calls(2); + + Ok(()) + } + + #[tokio::test] + async fn post_order_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/order") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!({ + "order": { + "expiration": "0", + "feeRateBps": "0", + "maker": Address::ZERO, + "makerAmount": "0", + "nonce": "0", + "salt": 0, + "side": Side::Buy, + "signature": "0x0d18c04a653d89bf7375636adb7db69cffe362755960dc6ce8a0d46b04355b767958fae51c48e0e4b0908347442cb461e811d2f5a751303f7a8c1f75e17b3e701b", + "signatureType": 0, + "signer": Address::ZERO, + "taker": Address::ZERO, + "takerAmount": "0", + "tokenId": "0" + }, + "orderType": "FOK", + "owner": "00000000-0000-0000-0000-000000000000" + })); + then.status(StatusCode::OK).json_body(json!({ + "error_msg": "", + "makingAmount": "", + "orderID": "0x23b457271bce9fa09b4f79125c9ec09e968235a462de82e318ef4eb6fe0ffeb0", + "status": "live", + "success": true, + "takingAmount": "" + })); + }); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let signed_order = client.sign(&signer, SignableOrder::default()).await?; + let response = client.post_order(signed_order).await?; + + let expected = PostOrderResponse::builder() + .making_amount(Decimal::ZERO) + .taking_amount(Decimal::ZERO) + .order_id("0x23b457271bce9fa09b4f79125c9ec09e968235a462de82e318ef4eb6fe0ffeb0") + .status(OrderStatusType::Live) + .success(true) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn post_order_should_accept_transactions_hashes_alias() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/order") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK).json_body(json!({ + "error_msg": "", + "makingAmount": "100", + "orderID": "0x23b457271bce9fa09b4f79125c9ec09e968235a462de82e318ef4eb6fe0ffeb0", + "status": "matched", + "success": true, + "takingAmount": "50", + "transactionsHashes": ["0x2369f69af45a559ad6e769d3d209d2379af9d412315e27b9283594a6392557b6"] + })); + }); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let signed_order = client.sign(&signer, SignableOrder::default()).await?; + let response = client.post_order(signed_order).await?; + + let expected = PostOrderResponse::builder() + .making_amount(Decimal::from(100)) + .taking_amount(Decimal::from(50)) + .order_id("0x23b457271bce9fa09b4f79125c9ec09e968235a462de82e318ef4eb6fe0ffeb0") + .status(OrderStatusType::Matched) + .success(true) + .transaction_hashes(vec![b256!( + "2369f69af45a559ad6e769d3d209d2379af9d412315e27b9283594a6392557b6" + )]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn order_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let json = json!({ + "id": "1", + "status": "LIVE", + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker_address": "0x2222222222222222222222222222222222222222", + "market": "0x000000000000000000000000000000000000000000000000006d61726b657461", + "asset_id": token_1(), + "side": "buy", + "original_size": "10.0", + "size_matched": "2.5", + "price": "0.45", + "associate_trades": [ + "0xtradehash1", + "0xtradehash2" + ], + "outcome": "YES", + "created_at": 1_705_322_096, + "expiration": "1705708800", + "order_type": "gtd" + }); + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/data/order/1") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK).json_body(json); + }); + + let response = client.order("1").await?; + + let expected = OpenOrderResponse::builder() + .id("1") + .status(OrderStatusType::Live) + .owner(Uuid::max()) + .maker_address(address!("0x2222222222222222222222222222222222222222")) + .market(b256!( + "000000000000000000000000000000000000000000000000006d61726b657461" + )) + .asset_id(token_1()) + .side(Side::Buy) + .original_size(dec!(10.0)) + .size_matched(dec!(2.5)) + .price(dec!(0.45)) + .associate_trades(vec!["0xtradehash1".into(), "0xtradehash2".into()]) + .outcome("YES") + .created_at("2024-01-15T12:34:56Z".parse().unwrap()) + .expiration("2024-01-20T00:00:00Z".parse().unwrap()) + .order_type(OrderType::GTD) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn orders_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let json = json!({ + "data": [ + { + "id": "1", + "status": "LIVE", + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker_address": "0x2222222222222222222222222222222222222222", + "market": "0x000000000000000000000000000000000000000000000000006d61726b657461", + "asset_id": token_1(), + "side": "buy", + "original_size": "10.0", + "size_matched": "2.5", + "price": "0.45", + "associate_trades": [ + "0xtradehash1", + "0xtradehash2" + ], + "outcome": "YES", + "created_at": 1_705_322_096, + "expiration": "1705708800", + "order_type": "GTC" + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + }); + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/data/orders") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("id", "1"); + then.status(StatusCode::OK).json_body(json); + }); + + let request = OrdersRequest::builder().order_id("1").build(); + let response = client.orders(&request, None).await?; + + let order = OpenOrderResponse::builder() + .id("1") + .status(OrderStatusType::Live) + .owner(Uuid::max()) + .maker_address(address!("0x2222222222222222222222222222222222222222")) + .market(b256!( + "000000000000000000000000000000000000000000000000006d61726b657461" + )) + .asset_id(token_1()) + .side(Side::Buy) + .original_size(dec!(10.0)) + .size_matched(dec!(2.5)) + .price(dec!(0.45)) + .associate_trades(vec!["0xtradehash1".into(), "0xtradehash2".into()]) + .outcome("YES") + .created_at("2024-01-15T12:34:56Z".parse().unwrap()) + .expiration("2024-01-20T00:00:00Z".parse().unwrap()) + .order_type(OrderType::GTC) + .build(); + let expected = Page::builder() + .data(vec![order]) + .limit(1) + .count(1) + .next_cursor("next") + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn cancel_order_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/order") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!({ "orderId": "1" })); + then.status(StatusCode::OK).json_body(json!({ + "canceled": [], + "notCanceled": { + "1": "the order is already canceled" + } + } + )); + }); + + let response = client.cancel_order("1").await?; + + let expected = CancelOrdersResponse::builder() + .not_canceled(HashMap::from_iter([( + "1".to_owned(), + "the order is already canceled".to_owned(), + )])) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn cancel_order_should_accept_snake_case_not_canceled() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/order") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!({ "orderId": "1" })); + then.status(StatusCode::OK).json_body(json!({ + "canceled": [], + "not_canceled": { + "1": "the order is already canceled" + } + } + )); + }); + + let response = client.cancel_order("1").await?; + + let expected = CancelOrdersResponse::builder() + .not_canceled(HashMap::from_iter([( + "1".to_owned(), + "the order is already canceled".to_owned(), + )])) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn cancel_orders_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/orders") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!(["1"])); + then.status(StatusCode::OK).json_body(json!({ + "canceled": ["1"] + } + )); + }); + + let response = client.cancel_orders(&["1"]).await?; + + let expected = CancelOrdersResponse::builder() + .canceled(vec!["1".to_owned()]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn cancel_all_orders_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .path("/cancel-all"); + then.status(StatusCode::OK).json_body(json!({ + "canceled": ["2"], + "notCanceled": { + "1": "the order is already canceled" + } + } + )); + }); + + let response = client.cancel_all_orders().await?; + + let expected = CancelOrdersResponse::builder() + .canceled(vec!["2".to_owned()]) + .not_canceled(HashMap::from_iter([( + "1".to_owned(), + "the order is already canceled".to_owned(), + )])) + .build(); + + assert_eq!(response, expected); + + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn cancel_market_orders_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .path("/cancel-market-orders"); + then.status(StatusCode::OK).json_body(json!({ + "market": "m", + "asset_id": token_1(), + })); + }); + + let request = CancelMarketOrderRequest::builder() + .market(b256!( + "000000000000000000000000000000000000000000000000000000000000006d" + )) + .asset_id(token_1()) + .build(); + + client.cancel_market_orders(&request).await?; + + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn trades_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/data/trades") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("id", "1") + .query_param("market", "0x000000000000000000000000000000000000000000000000000000006d61726b"); + + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "id": "1", + "taker_order_id": "taker_123", + "market": "0x000000000000000000000000000000000000000000000000000000006d61726b", + "asset_id": token_1(), + "side": "BUY", + "size": "12.5", + "fee_rate_bps": "5", + "price": "0.42", + "status": "MATCHED", + "match_time": "1705322096", + "last_update": "1705322130", + "outcome": "YES", + "bucket_index": 2, + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker_address": "0x2222222222222222222222222222222222222222", + "maker_orders": [ + { + "order_id": "maker_001", + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker_address": "0x4444444444444444444444444444444444444444", + "matched_amount": "5.0", + "price": "0.42", + "fee_rate_bps": "5", + "asset_id": token_1(), + "outcome": "YES", + "side": "SELL" + }, + { + "order_id": "maker_002", + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker_address": "0x6666666666666666666666666666666666666666", + "matched_amount": "7.5", + "price": "0.42", + "fee_rate_bps": "5", + "asset_id": token_1(), + "outcome": "YES", + "side": "SELL" + } + ], + "transaction_hash": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "trader_side": "TAKER" + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let request = TradesRequest::builder() + .id("1") + .market(b256!( + "000000000000000000000000000000000000000000000000000000006d61726b" + )) + .build(); + let response = client.trades(&request, None).await?; + + let trade = TradeResponse::builder() + .id("1") + .taker_order_id("taker_123") + .market(b256!( + "000000000000000000000000000000000000000000000000000000006d61726b" + )) + .asset_id(token_1()) + .side(Side::Buy) + .size(dec!(12.5)) + .fee_rate_bps(dec!(5)) + .price(dec!(0.42)) + .status(TradeStatusType::Matched) + .match_time("2024-01-15T12:34:56Z".parse().unwrap()) + .last_update("2024-01-15T12:35:30Z".parse().unwrap()) + .outcome("YES") + .bucket_index(2) + .owner(Uuid::max()) + .maker_address(address!("0x2222222222222222222222222222222222222222")) + .maker_orders(vec![ + MakerOrder::builder() + .order_id("maker_001") + .owner(Uuid::max()) + .maker_address(address!("0x4444444444444444444444444444444444444444")) + .matched_amount(dec!(5.0)) + .price(dec!(0.42)) + .fee_rate_bps(dec!(5)) + .asset_id(token_1()) + .outcome("YES") + .side(Side::Sell) + .build(), + MakerOrder::builder() + .order_id("maker_002") + .owner(Uuid::max()) + .maker_address(address!("0x6666666666666666666666666666666666666666")) + .matched_amount(dec!(7.5)) + .price(dec!(0.42)) + .fee_rate_bps(dec!(5)) + .asset_id(token_1()) + .outcome("YES") + .side(Side::Sell) + .build(), + ]) + .transaction_hash(b256!( + "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + )) + .trader_side(TraderSide::Taker) + .build(); + let expected = Page::builder() + .limit(1) + .count(1) + .data(vec![trade]) + .next_cursor("next") + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn notifications_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/notifications") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("signature_type", (SignatureType::Eoa as u8).to_string()); + then.status(StatusCode::OK).json_body(json!([ + { + "type": 1, + "owner": API_KEY, + "payload": { + "asset_id": "71321045679252212594626385532706912750332728571942532289631379312455583992563", + "condition_id": "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1", + "eventSlug": "will-trump-win-the-2024-iowa-caucus", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/trump1+copy.png", + "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/trump1+copy.png", + "market": "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1", + "market_slug": "will-trump-win-the-2024-iowa-caucus", + "matched_size": "20", + "order_id": "0x2ae21876d2702d8b71308d0999062db9625a691ce4593c5f10230eeeff945e70", + "original_size": "2.4", + "outcome": "YES", + "outcome_index": 0, + "owner": "b349bff6-7af8-0470-ed25-22a2a5e1c154", + "price": "0.12", + "question": "Will Trump win the 2024 Iowa Caucus?", + "remaining_size": "0", + "seriesSlug": "", + "side": "buy", + "trade_id": "565a5035-d70e-4493-9215-8cae52d26efe", + "transaction_hash": "0x3bc57dcae83a930df64fce8fdc46a8fca9b98af92a7b83a8a2f2c657446c2a71", + "type": "" + } + } + ])); + }); + + let response = client.notifications().await?; + + let expected = vec![ + NotificationResponse::builder() + .r#type(1) + .owner(API_KEY) + .payload(NotificationPayload::builder() + .asset_id(U256::from_str("71321045679252212594626385532706912750332728571942532289631379312455583992563").unwrap()) + .condition_id(b256!( + "5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1" + )) + .event_slug("will-trump-win-the-2024-iowa-caucus") + .icon("https://polymarket-upload.s3.us-east-2.amazonaws.com/trump1+copy.png") + .image("https://polymarket-upload.s3.us-east-2.amazonaws.com/trump1+copy.png") + .market(b256!( + "5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1" + )) + .market_slug("will-trump-win-the-2024-iowa-caucus") + .matched_size(dec!(20)) + .order_id("0x2ae21876d2702d8b71308d0999062db9625a691ce4593c5f10230eeeff945e70") + .original_size(dec!(2.4)) + .outcome("YES") + .outcome_index(0) + .owner(Uuid::from_str("b349bff6-7af8-0470-ed25-22a2a5e1c154").unwrap()) + .price(dec!(0.12)) + .question("Will Trump win the 2024 Iowa Caucus?") + .remaining_size(Decimal::ZERO) + .series_slug("") + .side(Side::Buy) + .trade_id("565a5035-d70e-4493-9215-8cae52d26efe") + .transaction_hash(b256!( + "3bc57dcae83a930df64fce8fdc46a8fca9b98af92a7b83a8a2f2c657446c2a71" + )) + .order_type(OrderType::Unknown(String::new())) + .build() + ) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn delete_notifications_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/notifications") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("ids", "1,2"); + then.status(StatusCode::OK).json_body(json!(null)); + }); + + let request = DeleteNotificationsRequest::builder() + .notification_ids(vec!["1".to_owned(), "2".to_owned()]) + .build(); + client.delete_notifications(&request).await?; + + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn balance_allowance_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/balance-allowance") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("asset_type", "COLLATERAL") + .query_param("token_id", token_1().to_string()) + .query_param("signature_type", "0"); + // Trying different Decimal deserialization routes + then.status(StatusCode::OK).json_body(json!({ + "balance": 0, + "allowances": { Address::ZERO.to_string(): "1" } + })); + }); + + let request = BalanceAllowanceRequest::builder() + .asset_type(AssetType::Collateral) + .token_id(token_1()) + .build(); + let response = client.balance_allowance(request).await?; + + let expected = BalanceAllowanceResponse::builder() + .balance(Decimal::ZERO) + .allowances(HashMap::from_iter([(Address::ZERO, "1".to_owned())])) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn update_balance_allowance_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/balance-allowance/update") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("asset_type", "COLLATERAL") + .query_param("token_id", token_1().to_string()) + .query_param("signature_type", "0"); + then.status(StatusCode::OK).json_body(json!(null)); + }); + + let request = BalanceAllowanceRequest::builder() + .asset_type(AssetType::Collateral) + .token_id(token_1()) + .build(); + client.update_balance_allowance(request).await?; + + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn is_order_scoring_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/order-scoring") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("order_id", "1"); + then.status(StatusCode::OK).json_body(json!({ + "scoring": true, + })); + }); + + let response = client.is_order_scoring("1").await?; + + let expected = OrderScoringResponse::builder().scoring(true).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn are_orders_scoring_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/orders-scoring") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!(["1"])); + then.status(StatusCode::OK).json_body(json!( + { "1": true } + )); + }); + + let response = client.are_orders_scoring(&["1"]).await?; + + let expected = HashMap::from_iter(vec![("1".to_owned(), true)]); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn earnings_for_user_for_day_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let date = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/user") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("date", date.to_string()) + .query_param("signature_type", (SignatureType::Eoa as u8).to_string()); + then.status(StatusCode::OK).json_body(json!({ + "data": [{ + "date": "2025-12-08", + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "asset_address": "0x0000000000000000000000000000000000000001", + "maker_address": "0x0000000000000000000000000000000000000002", + "earnings": 1, + "asset_rate": "0.1" + }], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let expected = Page::builder() + .limit(1) + .count(1) + .next_cursor("next") + .data(vec![ + UserEarningResponse::builder() + .date(date) + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .maker_address(address!("0x0000000000000000000000000000000000000002")) + .earnings(Decimal::ONE) + .asset_rate(dec!(0.1)) + .build(), + ]) + .build(); + + let response = client.earnings_for_user_for_day(date, None).await?; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn total_earnings_for_user_for_day_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let date = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/user/total") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("date", date.to_string()) + .query_param("signature_type", (SignatureType::Eoa as u8).to_string()); + then.status(StatusCode::OK).json_body(json!([{ + "date": "2025-12-08", + "asset_address": "0x0000000000000000000000000000000000000001", + "maker_address": "0x0000000000000000000000000000000000000002", + "earnings": 1, + "asset_rate": "0.1" + }])); + }); + + let response = client.total_earnings_for_user_for_day(date).await?; + + let expected = vec![ + TotalUserEarningResponse::builder() + .date(date) + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .maker_address(address!("0x0000000000000000000000000000000000000002")) + .earnings(Decimal::ONE) + .asset_rate(dec!(0.1)) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn user_earnings_and_markets_config_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let today = Utc::now(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/user/total") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("date", today.date_naive().to_string()) + .query_param("order_by", "") + .query_param("position", "") + .query_param("no_competition", "false") + .query_param("signature_type", (SignatureType::Eoa as u8).to_string()); + then.status(StatusCode::OK).json_body(json!( + [ + { + "condition_id": "0x0000000000000000000000000000000000000000000000000000000c00d00123", + "question": "Will BTC be above $50k on December 31, 2025?", + "market_slug": "btc-above-50k-2025-12-31", + "event_slug": "btc-above-50k-2025", + "image": "https://example.com/markets/btc.png", + "rewards_max_spread": "0.05", + "rewards_min_size": "10.0", + "market_competitiveness": "0.80", + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.55", + "winner": true + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.45", + "winner": false + } + ], + "rewards_config": [ + { + "asset_address": "0x0000000000000000000000000000000000000001", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "rate_per_day": "1.5", + "total_rewards": "500.0" + }, + { + "asset_address": "0x0000000000000000000000000000000000000002", + "start_date": "2024-06-01", + "end_date": "2024-12-31", + "rate_per_day": "0.75", + "total_rewards": "250.0" + } + ], + "maker_address": "0x1111111111111111111111111111111111111111", + "earning_percentage": "0.25", + "earnings": [ + { + "asset_address": "0x0000000000000000000000000000000000000001", + "earnings": "125.0", + "asset_rate": "1.5" + }, + { + "asset_address": "0x0000000000000000000000000000000000000002", + "earnings": "62.5", + "asset_rate": "0.75" + } + ] + } + ] + )); + }); + + let request = UserRewardsEarningRequest::builder() + .date(today.date_naive()) + .build(); + let response = client + .user_earnings_and_markets_config(&request, None) + .await?; + + let expected = vec![ + UserRewardsEarningResponse::builder() + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000c00d00123" + )) + .question("Will BTC be above $50k on December 31, 2025?") + .market_slug("btc-above-50k-2025-12-31") + .event_slug("btc-above-50k-2025") + .image("https://example.com/markets/btc.png") + .rewards_max_spread(dec!(0.05)) + .rewards_min_size(dec!(10.0)) + .market_competitiveness(dec!(0.80)) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.55)) + .winner(true) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.45)) + .winner(false) + .build(), + ]) + .rewards_config(vec![ + RewardsConfig::builder() + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .start_date("2024-01-01".parse().unwrap()) + .end_date("2024-12-31".parse().unwrap()) + .rate_per_day(dec!(1.5)) + .total_rewards(dec!(500.0)) + .build(), + RewardsConfig::builder() + .asset_address(address!("0x0000000000000000000000000000000000000002")) + .start_date("2024-06-01".parse().unwrap()) + .end_date("2024-12-31".parse().unwrap()) + .rate_per_day(dec!(0.75)) + .total_rewards(dec!(250.0)) + .build(), + ]) + .maker_address(address!("0x1111111111111111111111111111111111111111")) + .earning_percentage(dec!(0.25)) + .earnings(vec![ + Earning::builder() + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .earnings(dec!(125.0)) + .asset_rate(dec!(1.5)) + .build(), + Earning::builder() + .asset_address(address!("0x0000000000000000000000000000000000000002")) + .earnings(dec!(62.5)) + .asset_rate(dec!(0.75)) + .build(), + ]) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn reward_percentages_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/user/percentages") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("signature_type", "0"); + then.status(StatusCode::OK).json_body(json!({ "1": 2 })); + }); + + let response = client.reward_percentages().await?; + + let expected = HashMap::from_iter(vec![("1".to_owned(), Decimal::TWO)]); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn current_rewards_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/markets/current") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE); + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "condition_id": "0x000000000000000000000000000000000000000000000000000000c0dabc0123", + "rewards_max_spread": "0.05", + "rewards_min_size": "20.0", + "rewards_config": [ + { + "asset_address": "0x0000000000000000000000000000000000000001", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "rate_per_day": "2.0", + "total_rewards": "750.0" + }, + { + "asset_address": "0x0000000000000000000000000000000000000002", + "start_date": "2024-06-01", + "end_date": "2024-12-31", + "rate_per_day": "1.0", + "total_rewards": "300.0" + } + ] + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let response = client.current_rewards(None).await?; + + let market_reward = CurrentRewardResponse::builder() + .condition_id(b256!( + "000000000000000000000000000000000000000000000000000000c0dabc0123" + )) + .rewards_max_spread(dec!(0.05)) + .rewards_min_size(dec!(20.0)) + .rewards_config(vec![ + RewardsConfig::builder() + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .start_date("2024-01-01".parse().unwrap()) + .end_date("2024-12-31".parse().unwrap()) + .rate_per_day(dec!(2.0)) + .total_rewards(dec!(750.0)) + .build(), + RewardsConfig::builder() + .asset_address(address!("0x0000000000000000000000000000000000000002")) + .start_date("2024-06-01".parse().unwrap()) + .end_date("2024-12-31".parse().unwrap()) + .rate_per_day(dec!(1.0)) + .total_rewards(dec!(300.0)) + .build(), + ]) + .build(); + let expected = Page::builder() + .limit(1) + .count(1) + .next_cursor("next") + .data(vec![market_reward]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn raw_rewards_for_market_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/rewards/markets/1") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .query_param("next_cursor", "1"); + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "condition_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "question": "Will BTC reach $100k in 2025?", + "market_slug": "btc-100k-2025", + "event_slug": "btc-2025", + "image": "https://example.com/markets/btc.png", + "rewards_max_spread": "0.05", + "rewards_min_size": "15.0", + "market_competitiveness": 0.05, + "tokens": [ + { + "token_id": token_1(), + "outcome": "YES", + "price": "0.58", + "winner": true + }, + { + "token_id": token_2(), + "outcome": "NO", + "price": "0.42", + "winner": false + } + ], + "rewards_config": [ + { + "id": "1", + "asset_address": "0x0000000000000000000000000000000000000001", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "rate_per_day": "1.25", + "total_rewards": "400.0", + "total_days": 10 + }, + { + "id": "2", + "asset_address": "0x0000000000000000000000000000000000000002", + "start_date": "2024-06-01", + "end_date": "2024-12-31", + "rate_per_day": "0.80", + "total_rewards": "200.0", + "total_days": 10 + } + ] + } + ], + "limit": 1, + "count": 1, + "next_cursor": "2" + })); + }); + + let response = client + .raw_rewards_for_market("1", Some("1".to_owned())) + .await?; + + let market_reward = MarketRewardResponse::builder() + .condition_id(b256!( + "0000000000000000000000000000000000000000000000000000000000000001" + )) + .question("Will BTC reach $100k in 2025?") + .market_slug("btc-100k-2025") + .event_slug("btc-2025") + .image("https://example.com/markets/btc.png") + .rewards_max_spread(dec!(0.05)) + .rewards_min_size(dec!(15.0)) + .market_competitiveness(dec!(0.05)) + .tokens(vec![ + Token::builder() + .token_id(token_1()) + .outcome("YES") + .price(dec!(0.58)) + .winner(true) + .build(), + Token::builder() + .token_id(token_2()) + .outcome("NO") + .price(dec!(0.42)) + .winner(false) + .build(), + ]) + .rewards_config(vec![ + MarketRewardsConfig::builder() + .id("1") + .asset_address(address!("0x0000000000000000000000000000000000000001")) + .start_date("2024-01-01".parse()?) + .end_date("2024-12-31".parse()?) + .rate_per_day(dec!(1.25)) + .total_rewards(dec!(400.0)) + .total_days(Decimal::TEN) + .build(), + MarketRewardsConfig::builder() + .id("2") + .asset_address(address!("0x0000000000000000000000000000000000000002")) + .start_date("2024-06-01".parse()?) + .end_date("2024-12-31".parse()?) + .rate_per_day(dec!(0.80)) + .total_rewards(dec!(200.0)) + .total_days(Decimal::TEN) + .build(), + ]) + .build(); + let expected = Page::builder() + .limit(1) + .count(1) + .next_cursor("2") + .data(vec![market_reward]) + .build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn post_heartbeats_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let id = Uuid::new_v4(); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/v1/heartbeats") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!({ + "heartbeat_id": null + })); + then.status(StatusCode::OK).json_body(json!({ + "heartbeat_id": id, + "error": null + })); + }); + + let response = client.post_heartbeat(None).await?; + + let expected = HeartbeatResponse::builder().heartbeat_id(id).build(); + + assert_eq!(response, expected); + mock.assert(); + + Ok(()) + } + + #[cfg(feature = "heartbeats")] + #[tokio::test] + async fn stop_heartbeats_from_two_clones_should_fail_and_then_succeed_on_drop() + -> anyhow::Result<()> { + let server = MockServer::start(); + + let id = Uuid::new_v4(); + + // Before `create_authenticated` to have a heartbeat mock immediately available + server.mock(|when, then| { + when.method(POST) + .path("/v1/heartbeats") + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .json_body(json!({ + "heartbeat_id": null + })); + then.status(StatusCode::OK).json_body(json!({ + "heartbeat_id": id, + "error": null + })); + }); + + let mut client = create_authenticated(&server).await?; + assert!(client.heartbeats_active()); + + // Give the first client time to get set up + tokio::time::sleep(Duration::from_millis(100)).await; + + let client_clone = client.clone(); + assert!(client_clone.heartbeats_active()); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let err = client.stop_heartbeats().await.unwrap_err(); + err.downcast_ref::().unwrap(); + + // Retain the heartbeat cancel token and channel on initial error + assert!(client.heartbeats_active()); + assert!(client_clone.heartbeats_active()); + + drop(client_clone); + + assert!(client.heartbeats_active()); + + // After dropping the offending client, we should be able to stop heartbeats successfully + client.stop_heartbeats().await?; + + assert!(!client.heartbeats_active()); + + Ok(()) + } +} + +mod builder_authenticated { + use alloy::signers::Signer as _; + use alloy::signers::local::LocalSigner; + use httpmock::Method::DELETE; + use polymarket_client_sdk::auth::builder::Config as BuilderConfig; + use polymarket_client_sdk::clob::types::request::TradesRequest; + use polymarket_client_sdk::clob::types::response::{ + BuilderApiKeyResponse, BuilderTradeResponse, Page, + }; + use polymarket_client_sdk::clob::types::{Side, TradeStatusType}; + use polymarket_client_sdk::types::{address, b256}; + + use super::*; + use crate::common::{ + API_KEY, BUILDER_API_KEY, BUILDER_PASSPHRASE, PASSPHRASE, POLY_BUILDER_API_KEY, + POLY_BUILDER_PASSPHRASE, POLY_BUILDER_SIGNATURE, POLY_BUILDER_TIMESTAMP, POLY_NONCE, + POLY_SIGNATURE, POLY_TIMESTAMP, SECRET, SIGNATURE, TIMESTAMP, + }; + + #[tokio::test] + async fn builder_api_keys_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()) + .header(POLY_NONCE, "0") + .header(POLY_SIGNATURE, SIGNATURE) + .header(POLY_TIMESTAMP, TIMESTAMP); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY, + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/time"); + then.status(StatusCode::OK) + .json_body(TIMESTAMP.parse::().unwrap()); + }); + + let config = Config::builder().use_server_time(true).build(); + let builder_config = BuilderConfig::remote(&server.base_url(), Some("token".to_owned()))?; + let client = Client::new(&server.base_url(), config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + let client = client.promote_to_builder(builder_config).await?; + + let mock3 = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/") + .header("authorization", "Bearer token"); + + then.status(StatusCode::OK).json_body(json!({ + POLY_BUILDER_API_KEY: BUILDER_API_KEY, + POLY_BUILDER_PASSPHRASE: BUILDER_PASSPHRASE, + POLY_BUILDER_SIGNATURE: "signature", + POLY_BUILDER_TIMESTAMP: "1", + })); + }); + + let time = Utc::now(); + let mock4 = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/builder-api-key") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .header(POLY_BUILDER_API_KEY, BUILDER_API_KEY) + .header(POLY_BUILDER_PASSPHRASE, BUILDER_PASSPHRASE) + .header(POLY_BUILDER_SIGNATURE, "signature") + .header(POLY_BUILDER_TIMESTAMP, "1"); + + then.status(StatusCode::OK).json_body(json!( + [ + { + "key": Uuid::nil(), + "createdAt": time + } + ] + )); + }); + + let response = client.builder_api_keys().await?; + + let expected = vec![ + BuilderApiKeyResponse::builder() + .key(Uuid::nil()) + .created_at(time) + .build(), + ]; + + assert_eq!(response, expected); + mock.assert(); + mock2.assert_calls(3); + mock3.assert(); + mock4.assert(); + + Ok(()) + } + + #[tokio::test] + async fn revoke_builder_api_key_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()) + .header(POLY_NONCE, "0") + .header(POLY_SIGNATURE, SIGNATURE) + .header(POLY_TIMESTAMP, TIMESTAMP); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY, + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/time"); + then.status(StatusCode::OK) + .json_body(TIMESTAMP.parse::().unwrap()); + }); + + let config = Config::builder().use_server_time(true).build(); + let builder_config = BuilderConfig::remote(&server.base_url(), Some("token".to_owned()))?; + let client = Client::new(&server.base_url(), config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + let client = client.promote_to_builder(builder_config).await?; + + let mock3 = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/") + .header("authorization", "Bearer token"); + + then.status(StatusCode::OK).json_body(json!({ + POLY_BUILDER_API_KEY: BUILDER_API_KEY, + POLY_BUILDER_PASSPHRASE: BUILDER_PASSPHRASE, + POLY_BUILDER_SIGNATURE: "signature", + POLY_BUILDER_TIMESTAMP: "1", + })); + }); + + let mock4 = server.mock(|when, then| { + when.method(DELETE) + .path("/auth/builder-api-key") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .header(POLY_BUILDER_API_KEY, BUILDER_API_KEY) + .header(POLY_BUILDER_PASSPHRASE, BUILDER_PASSPHRASE) + .header(POLY_BUILDER_SIGNATURE, "signature") + .header(POLY_BUILDER_TIMESTAMP, "1"); + then.status(StatusCode::OK).json_body(json!(null)); + }); + + client.revoke_builder_api_key().await?; + + mock.assert(); + mock2.assert_calls(3); + mock3.assert(); + mock4.assert(); + + Ok(()) + } + + #[tokio::test] + async fn builder_trades_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()) + .header(POLY_NONCE, "0") + .header(POLY_SIGNATURE, SIGNATURE) + .header(POLY_TIMESTAMP, TIMESTAMP); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY, + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/time"); + then.status(StatusCode::OK) + .json_body(TIMESTAMP.parse::().unwrap()); + }); + + let config = Config::builder().use_server_time(true).build(); + let builder_config = BuilderConfig::remote(&server.base_url(), Some("token".to_owned()))?; + let client = Client::new(&server.base_url(), config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + let client = client.promote_to_builder(builder_config).await?; + + let mock3 = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/") + .header("authorization", "Bearer token"); + + then.status(StatusCode::OK).json_body(json!({ + POLY_BUILDER_API_KEY: BUILDER_API_KEY, + POLY_BUILDER_PASSPHRASE: BUILDER_PASSPHRASE, + POLY_BUILDER_SIGNATURE: "signature", + POLY_BUILDER_TIMESTAMP: "1", + })); + }); + + let mock4 = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/builder/trades") + .header(POLY_ADDRESS, client.address().to_string().to_lowercase()) + .header(POLY_API_KEY, API_KEY) + .header(POLY_PASSPHRASE, PASSPHRASE) + .header(POLY_BUILDER_API_KEY, BUILDER_API_KEY) + .header(POLY_BUILDER_PASSPHRASE, BUILDER_PASSPHRASE) + .header(POLY_BUILDER_SIGNATURE, "signature") + .header(POLY_BUILDER_TIMESTAMP, "1") + .query_param("id", "1") + .query_param("market", "0x000000000000000000000000000000000000000000000000000000006d61726b"); + + then.status(StatusCode::OK).json_body(json!({ + "data": [ + { + "id": "1", + "tradeType": "limit", + "takerOrderHash": "0x0000000000000000000000000000000000000000000000000074616b65726f72", + "builder": "0x00000000000000000000000000006275696c6431", + "market": "0x000000000000000000000000000000000000000000000000000000006d61726b", + "assetId": token_1(), + "side": "buy", + "size": "10.0", + "sizeUsdc": "100.0", + "price": "0.45", + "status": "MATCHED", + "outcome": "YES", + "outcomeIndex": 0, + "owner": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "maker": "0x2222222222222222222222222222222222222222", + "transactionHash": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "matchTime": "1758579597", + "bucketIndex": 3, + "fee": "0.1", + "feeUsdc": "1.0", + "err_msg": "partial fill due to liquidity", + "createdAt": "2024-01-15T12:30:00Z", + "updatedAt": "2024-01-15T12:35:00Z" + } + ], + "limit": 1, + "count": 1, + "next_cursor": "next" + })); + }); + + let request = TradesRequest::builder() + .id("1") + .market(b256!( + "000000000000000000000000000000000000000000000000000000006d61726b" + )) + .build(); + let response = client.builder_trades(&request, None).await?; + + let trade = BuilderTradeResponse::builder() + .id("1") + .trade_type("limit") + .taker_order_hash(b256!( + "0000000000000000000000000000000000000000000000000074616b65726f72" + )) + .builder(address!("00000000000000000000000000006275696c6431")) + .market(b256!( + "000000000000000000000000000000000000000000000000000000006d61726b" + )) + .asset_id(token_1()) + .side(Side::Buy) + .size(dec!(10.0)) + .size_usdc(dec!(100.0)) + .price(dec!(0.45)) + .status(TradeStatusType::Matched) + .outcome("YES") + .outcome_index(0) + .owner(Uuid::max()) + .maker(address!("2222222222222222222222222222222222222222")) + .transaction_hash(b256!( + "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + )) + .match_time("2025-09-22T22:19:57Z".parse()?) + .bucket_index(3) + .fee(dec!(0.1)) + .fee_usdc(dec!(1.0)) + .err_msg("partial fill due to liquidity") + .created_at("2024-01-15T12:30:00Z".parse()?) + .updated_at("2024-01-15T12:35:00Z".parse()?) + .build(); + let expected = Page::builder() + .limit(1) + .count(1) + .data(vec![trade]) + .next_cursor("next") + .build(); + + assert_eq!(response, expected); + mock.assert(); + mock2.assert_calls(3); + mock3.assert(); + mock4.assert(); + + Ok(()) + } +} diff --git a/polymarket-client-sdk/tests/common/mod.rs b/polymarket-client-sdk/tests/common/mod.rs new file mode 100644 index 0000000..57c2e03 --- /dev/null +++ b/polymarket-client-sdk/tests/common/mod.rs @@ -0,0 +1,130 @@ +#![cfg(feature = "clob")] +#![allow( + clippy::unwrap_used, + clippy::missing_panics_doc, + reason = "Do not need additional syntax for setting up tests, and https://github.com/rust-lang/rust-clippy/issues/13981" +)] +#![allow( + unused, + reason = "Deeply nested uses in sub-modules are falsely flagged as being unused" +)] + +use std::str::FromStr as _; + +use alloy::primitives::U256; +use alloy::signers::Signer as _; +use alloy::signers::k256::ecdsa::SigningKey; +use alloy::signers::local::LocalSigner; +use httpmock::MockServer; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::auth::Normal; +use polymarket_client_sdk::auth::state::Authenticated; +use polymarket_client_sdk::clob::types::{SignatureType, TickSize}; +use polymarket_client_sdk::clob::{Client, Config}; +use polymarket_client_sdk::types::Decimal; +use reqwest::StatusCode; +use serde_json::json; +use uuid::Uuid; + +// publicly known private key +pub const PRIVATE_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +pub const PASSPHRASE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +pub const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +pub const SIGNATURE: &str = "0xfdfb5abf512e439ea61c8595c18e527e718bf16010acf57cef51d09e15893098275d3c6f73038f36ec0cd0ce55436fca14dc64b11611f4dce896e354207508cc1b"; +pub const TIMESTAMP: &str = "100000"; + +pub const BUILDER_PASSPHRASE: &str = "passphrase"; + +pub const POLY_ADDRESS: &str = "POLY_ADDRESS"; +pub const POLY_API_KEY: &str = "POLY_API_KEY"; +pub const POLY_NONCE: &str = "POLY_NONCE"; +pub const POLY_PASSPHRASE: &str = "POLY_PASSPHRASE"; +pub const POLY_SIGNATURE: &str = "POLY_SIGNATURE"; +pub const POLY_TIMESTAMP: &str = "POLY_TIMESTAMP"; + +pub const POLY_BUILDER_API_KEY: &str = "POLY_BUILDER_API_KEY"; +pub const POLY_BUILDER_PASSPHRASE: &str = "POLY_BUILDER_PASSPHRASE"; +pub const POLY_BUILDER_SIGNATURE: &str = "POLY_BUILDER_SIGNATURE"; +pub const POLY_BUILDER_TIMESTAMP: &str = "POLY_BUILDER_TIMESTAMP"; + +pub const API_KEY: Uuid = Uuid::nil(); +pub const BUILDER_API_KEY: Uuid = Uuid::max(); + +pub const USDC_DECIMALS: u32 = 6; + +pub type TestClient = Client>; + +#[must_use] +pub fn token_1() -> U256 { + U256::from_str("15871154585880608648532107628464183779895785213830018178010423617714102767076") + .unwrap() +} + +#[must_use] +pub fn token_2() -> U256 { + U256::from_str("99920934651435586775038877380223724073374199451810545861447160390199026872860") + .unwrap() +} + +pub async fn create_authenticated(server: &MockServer) -> anyhow::Result { + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()) + .header(POLY_NONCE, "0") + .header(POLY_SIGNATURE, SIGNATURE) + .header(POLY_TIMESTAMP, TIMESTAMP); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + let mock2 = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/time"); + then.status(StatusCode::OK) + .json_body(TIMESTAMP.parse::().unwrap()); + }); + + let config = Config::builder().use_server_time(true).build(); + let client = Client::new(&server.base_url(), config)? + .authentication_builder(&signer) + .authenticate() + .await?; + + mock.assert(); + mock2.assert_calls(2); + + Ok(client) +} + +pub fn ensure_requirements(server: &MockServer, token_id: U256, tick_size: TickSize) { + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/neg-risk"); + then.status(StatusCode::OK) + .json_body(json!({ "neg_risk": false })); + }); + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/fee-rate"); + then.status(StatusCode::OK) + .json_body(json!({ "base_fee": 0 })); + }); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/tick-size") + .query_param("token_id", token_id.to_string()); + then.status(StatusCode::OK).json_body(json!({ + "minimum_tick_size": tick_size.as_decimal(), + })); + }); +} + +#[must_use] +pub fn to_decimal(value: U256) -> Decimal { + Decimal::from_str_exact(&value.to_string()).unwrap() +} diff --git a/polymarket-client-sdk/tests/ctf.rs b/polymarket-client-sdk/tests/ctf.rs new file mode 100644 index 0000000..cccce4b --- /dev/null +++ b/polymarket-client-sdk/tests/ctf.rs @@ -0,0 +1,300 @@ +#![cfg(feature = "ctf")] +#![allow(clippy::unwrap_used, reason = "Fine for tests")] + +use alloy::primitives::{B256, U256}; +use alloy::providers::ProviderBuilder; +use httpmock::{Method::POST, MockServer}; +use polymarket_client_sdk::POLYGON; +use polymarket_client_sdk::ctf::Client; +use polymarket_client_sdk::types::address; +use serde_json::json; + +mod contract_calls { + use alloy::primitives::b256; + use polymarket_client_sdk::ctf::types::{ + CollectionIdRequest, ConditionIdRequest, PositionIdRequest, + }; + + use super::*; + + #[tokio::test] + async fn get_condition_id() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + let client = Client::new(provider, POLYGON)?; + + // Mock the eth_call JSON-RPC response + let mock = server.mock(|when, then| { + when.method(POST).path("/"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + })); + }); + + let request = ConditionIdRequest::builder() + .oracle(address!("0x0000000000000000000000000000000000000001")) + .question_id(B256::ZERO) + .outcome_slot_count(U256::from(2)) + .build(); + + let response = client.condition_id(&request).await?; + + // Verify we got the mocked response + assert_eq!( + response.condition_id, + b256!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + ); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn get_collection_id() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + let client = Client::new(provider, POLYGON)?; + + let mock = server.mock(|when, then| { + when.method(POST).path("/"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + })); + }); + + let request = CollectionIdRequest::builder() + .parent_collection_id(B256::ZERO) + .condition_id(B256::ZERO) + .index_set(U256::from(1)) + .build(); + + let response = client.collection_id(&request).await?; + + // Verify we got the mocked response + assert_eq!( + response.collection_id, + b256!("abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd") + ); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn get_position_id() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + let client = Client::new(provider, POLYGON)?; + + let mock = server.mock(|when, then| { + when.method(POST).path("/"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x00000000000000000000000000000000000000000000000000000000000000ff" + })); + }); + + let usdc = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + + let request = PositionIdRequest::builder() + .collateral_token(usdc) + .collection_id(B256::ZERO) + .build(); + + let response = client.position_id(&request).await?; + + // Verify we got the mocked response + assert_eq!(response.position_id, U256::from(0xff)); + mock.assert(); + + Ok(()) + } +} + +mod client_creation { + use super::*; + + #[tokio::test] + async fn polygon_mainnet_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + + let client = Client::new(provider, POLYGON); + client.unwrap(); + + Ok(()) + } + + #[tokio::test] + async fn amoy_testnet_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + + let client = Client::new(provider, polymarket_client_sdk::AMOY); + client.unwrap(); + + Ok(()) + } + + #[tokio::test] + async fn invalid_chain_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + + let client = Client::new(provider, 999); + client.unwrap_err(); + + Ok(()) + } +} + +mod request_builders { + use polymarket_client_sdk::ctf::types::{ + ConditionIdRequest, MergePositionsRequest, RedeemPositionsRequest, SplitPositionRequest, + }; + + use super::*; + + #[test] + fn condition_id_request_builder() { + let request = ConditionIdRequest::builder() + .oracle(address!("0x0000000000000000000000000000000000000001")) + .question_id(B256::ZERO) + .outcome_slot_count(U256::from(2)) + .build(); + + assert_eq!( + request.oracle, + address!("0x0000000000000000000000000000000000000001") + ); + assert_eq!(request.question_id, B256::ZERO); + assert_eq!(request.outcome_slot_count, U256::from(2)); + } + + #[test] + fn split_position_request_builder() { + let request = SplitPositionRequest::builder() + .collateral_token(address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")) + .condition_id(B256::ZERO) + .partition(vec![U256::from(1), U256::from(2)]) + .amount(U256::from(1_000_000)) + .build(); + + assert_eq!( + request.collateral_token, + address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174") + ); + assert_eq!(request.parent_collection_id, B256::ZERO); + assert_eq!(request.amount, U256::from(1_000_000)); + } + + #[test] + fn merge_positions_request_builder() { + let request = MergePositionsRequest::builder() + .collateral_token(address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")) + .condition_id(B256::ZERO) + .partition(vec![U256::from(1), U256::from(2)]) + .amount(U256::from(1_000_000)) + .build(); + + assert_eq!(request.parent_collection_id, B256::ZERO); + assert_eq!(request.amount, U256::from(1_000_000)); + } + + #[test] + fn redeem_positions_request_builder() { + let request = RedeemPositionsRequest::builder() + .collateral_token(address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")) + .condition_id(B256::ZERO) + .index_sets(vec![U256::from(1)]) + .build(); + + assert_eq!(request.parent_collection_id, B256::ZERO); + assert_eq!(request.index_sets, vec![U256::from(1)]); + } +} + +mod binary_market_convenience_methods { + use polymarket_client_sdk::ctf::types::{ + MergePositionsRequest, RedeemPositionsRequest, SplitPositionRequest, + }; + + use super::*; + + #[test] + fn split_position_for_binary_market() { + let usdc = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + let condition_id = B256::ZERO; + + let request = + SplitPositionRequest::for_binary_market(usdc, condition_id, U256::from(1_000_000)); + + assert_eq!(request.collateral_token, usdc); + assert_eq!(request.condition_id, condition_id); + assert_eq!(request.partition, vec![U256::from(1), U256::from(2)]); + assert_eq!(request.amount, U256::from(1_000_000)); + assert_eq!(request.parent_collection_id, B256::ZERO); + } + + #[test] + fn merge_positions_for_binary_market() { + let usdc = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + let condition_id = B256::ZERO; + + let request = + MergePositionsRequest::for_binary_market(usdc, condition_id, U256::from(1_000_000)); + + assert_eq!(request.collateral_token, usdc); + assert_eq!(request.condition_id, condition_id); + assert_eq!(request.partition, vec![U256::from(1), U256::from(2)]); + assert_eq!(request.amount, U256::from(1_000_000)); + } + + #[test] + fn redeem_positions_for_binary_market() { + let usdc = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); + let condition_id = B256::ZERO; + + let request = RedeemPositionsRequest::for_binary_market(usdc, condition_id); + + assert_eq!(request.collateral_token, usdc); + assert_eq!(request.condition_id, condition_id); + assert_eq!(request.index_sets, vec![U256::from(1), U256::from(2)]); + } +} + +mod neg_risk { + use polymarket_client_sdk::ctf::types::RedeemNegRiskRequest; + + use super::*; + + #[test] + fn redeem_neg_risk_request_builder() { + let condition_id = B256::ZERO; + let amounts = vec![U256::from(500_000), U256::from(500_000)]; + + let request = RedeemNegRiskRequest::builder() + .condition_id(condition_id) + .amounts(amounts.clone()) + .build(); + + assert_eq!(request.condition_id, condition_id); + assert_eq!(request.amounts, amounts); + } + + #[tokio::test] + async fn client_with_neg_risk_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let provider = ProviderBuilder::new().connect(&server.base_url()).await?; + + let client = Client::with_neg_risk(provider, POLYGON); + client.unwrap(); + + Ok(()) + } +} diff --git a/polymarket-client-sdk/tests/data.rs b/polymarket-client-sdk/tests/data.rs new file mode 100644 index 0000000..3cacdba --- /dev/null +++ b/polymarket-client-sdk/tests/data.rs @@ -0,0 +1,1540 @@ +#![cfg(feature = "data")] + +use polymarket_client_sdk::types::{Address, B256, U256, address, b256}; + +const TEST_USER: Address = address!("1234567890abcdef1234567890abcdef12345678"); +const TEST_CONDITION_ID: B256 = + b256!("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); + +fn test_user() -> Address { + TEST_USER +} + +fn test_condition_id() -> B256 { + TEST_CONDITION_ID +} + +mod health { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::Client; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn health_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/"); + then.status(StatusCode::OK).json_body(json!({ + "data": "OK" + })); + }); + + let response = client.health().await?; + + assert_eq!(response.data, "OK"); + mock.assert(); + + Ok(()) + } +} + +mod positions { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::PositionsRequest}; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::{test_condition_id, test_user}; + + #[tokio::test] + async fn positions_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/positions") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678"); + then.status(StatusCode::OK).json_body(json!([ + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "conditionId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "size": 100.5, + "avgPrice": 0.65, + "initialValue": 65.325, + "currentValue": 70.35, + "cashPnl": 5.025, + "percentPnl": 7.69, + "totalBought": 100.5, + "realizedPnl": 0.0, + "percentRealizedPnl": 0.0, + "curPrice": 0.70, + "redeemable": false, + "mergeable": false, + "title": "Will BTC hit $100k?", + "slug": "btc-100k", + "icon": "https://example.com/btc.png", + "eventSlug": "crypto-prices", + "outcome": "Yes", + "outcomeIndex": 0, + "oppositeOutcome": "No", + "oppositeAsset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "endDate": "2025-12-31", + "negativeRisk": false + } + ])); + }); + + let request = PositionsRequest::builder().user(test_user()).build(); + + let response = client.positions(&request).await?; + + assert_eq!(response.len(), 1); + let pos = &response[0]; + assert_eq!(pos.proxy_wallet, test_user()); + assert_eq!(pos.condition_id, test_condition_id()); + assert_eq!(pos.size, dec!(100.5)); + assert_eq!(pos.title, "Will BTC hit $100k?"); + assert!(!pos.redeemable); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn positions_with_filters_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/positions") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678") + .query_param("limit", "10") + .query_param("offset", "5") + .query_param("redeemable", "true"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = PositionsRequest::builder() + .user(test_user()) + .limit(10)? + .offset(5)? + .redeemable(true) + .build(); + + let response = client.positions(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } +} + +mod trades { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::Side, types::request::TradesRequest}; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::{test_condition_id, test_user}; + + #[tokio::test] + async fn trades_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/trades"); + then.status(StatusCode::OK).json_body(json!([ + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "side": "BUY", + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "conditionId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "size": 50.0, + "price": 0.55, + "timestamp": 1_703_980_800, + "title": "Market Title", + "slug": "market-slug", + "icon": "https://example.com/icon.png", + "eventSlug": "event-slug", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "Trader Name", + "pseudonym": "TraderX", + "bio": "A trader", + "profileImage": "https://example.com/avatar.png", + "profileImageOptimized": "https://example.com/avatar-opt.png", + "transactionHash": "0x2222222222222222222222222222222222222222222222222222222222222222" + } + ])); + }); + + let response = client.trades(&TradesRequest::default()).await?; + + assert_eq!(response.len(), 1); + let trade = &response[0]; + assert_eq!(trade.proxy_wallet, test_user()); + assert_eq!(trade.condition_id, test_condition_id()); + assert_eq!(trade.side, Side::Buy); + assert_eq!(trade.size, dec!(50.0)); + assert_eq!(trade.price, dec!(0.55)); + assert_eq!(trade.timestamp, 1_703_980_800); + mock.assert(); + + Ok(()) + } +} + +mod activity { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{ + Client, + types::request::ActivityRequest, + types::{ActivityType, Side}, + }; + use reqwest::StatusCode; + use serde_json::json; + + use super::{test_condition_id, test_user}; + + #[tokio::test] + async fn activity_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/activity") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678"); + then.status(StatusCode::OK).json_body(json!([ + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "timestamp": 1_703_980_800, + "conditionId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "type": "TRADE", + "size": 100.0, + "usdcSize": 55.0, + "transactionHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "price": 0.55, + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "side": "BUY", + "outcomeIndex": 0, + "title": "Market", + "slug": "market-slug", + "outcome": "Yes" + }, + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "timestamp": 1_703_980_900, + "conditionId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "type": "REDEEM", + "size": 100.0, + "usdcSize": 100.0, + "transactionHash": "0x2222222222222222222222222222222222222222222222222222222222222222" + } + ])); + }); + + let request = ActivityRequest::builder().user(test_user()).build(); + + let response = client.activity(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].proxy_wallet, test_user()); + assert_eq!(response[0].condition_id, Some(test_condition_id())); + assert_eq!(response[0].activity_type, ActivityType::Trade); + assert_eq!(response[0].side, Some(Side::Buy)); + assert_eq!(response[1].activity_type, ActivityType::Redeem); + mock.assert(); + + Ok(()) + } +} + +mod holders { + use std::str::FromStr as _; + + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::HoldersRequest}; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::{U256, address, test_condition_id, test_user}; + + #[tokio::test] + async fn holders_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let holder2 = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/holders") + .query_param( + "market", + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ); + then.status(StatusCode::OK).json_body(json!([ + { + "token": "0x1111111111111111111111111111111111111111111111111111111111111111", + "holders": [ + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "bio": "Whale trader", + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "pseudonym": "WhaleX", + "amount": 10000.0, + "displayUsernamePublic": true, + "outcomeIndex": 0, + "name": "Holder One", + "profileImage": "https://example.com/h1.png" + }, + { + "proxyWallet": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "amount": 5000.0, + "outcomeIndex": 0 + } + ] + } + ])); + }); + + let request = HoldersRequest::builder() + .markets(vec![test_condition_id()]) + .build(); + + let response = client.holders(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!( + response[0].token, + U256::from_str("0x1111111111111111111111111111111111111111111111111111111111111111")? + ); + let holders = &response[0].holders; + assert_eq!(holders.len(), 2); + assert_eq!(holders[0].proxy_wallet, test_user()); + assert_eq!(holders[0].amount, dec!(10000.0)); + assert_eq!(holders[1].proxy_wallet, holder2); + assert_eq!(holders[1].amount, dec!(5000.0)); + mock.assert(); + + Ok(()) + } +} + +mod value { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::ValueRequest}; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::test_user; + + #[tokio::test] + async fn value_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/value") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678"); + then.status(StatusCode::OK).json_body(json!([ + { + "user": "0x1234567890abcdef1234567890abcdef12345678", + "value": 12345.67 + } + ])); + }); + + let request = ValueRequest::builder().user(test_user()).build(); + + let response = client.value(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].user, test_user()); + assert_eq!(response[0].value, dec!(12345.67)); + mock.assert(); + + Ok(()) + } +} + +mod closed_positions { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::ClosedPositionsRequest}; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::{test_condition_id, test_user}; + + #[tokio::test] + async fn closed_positions_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/closed-positions") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678"); + then.status(StatusCode::OK).json_body(json!([ + { + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "asset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "conditionId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "avgPrice": 0.45, + "totalBought": 100.0, + "realizedPnl": 55.0, + "curPrice": 1.0, + "timestamp": 1_703_980_800, + "title": "Resolved Market", + "slug": "resolved-market", + "icon": "https://example.com/icon.png", + "eventSlug": "event-slug", + "outcome": "Yes", + "outcomeIndex": 0, + "oppositeOutcome": "No", + "oppositeAsset": "0x1111111111111111111111111111111111111111111111111111111111111111", + "endDate": "2025-12-31T00:00:00Z", + } + ])); + }); + + let request = ClosedPositionsRequest::builder().user(test_user()).build(); + + let response = client.closed_positions(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].proxy_wallet, test_user()); + assert_eq!(response[0].condition_id, test_condition_id()); + assert_eq!(response[0].realized_pnl, dec!(55.0)); + assert_eq!(response[0].cur_price, dec!(1.0)); + assert_eq!(response[0].timestamp, 1_703_980_800); + mock.assert(); + + Ok(()) + } +} + +mod leaderboard { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{ + Client, + types::request::TraderLeaderboardRequest, + types::{LeaderboardCategory, LeaderboardOrderBy, TimePeriod}, + }; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::{address, test_user}; + + #[tokio::test] + async fn leaderboard_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let second_user = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/leaderboard"); + then.status(StatusCode::OK).json_body(json!([ + { + "rank": "1", + "proxyWallet": "0x1234567890abcdef1234567890abcdef12345678", + "userName": "TopTrader", + "vol": 1_000_000.0, + "pnl": 150_000.0, + "profileImage": "https://example.com/top.png", + "xUsername": "toptrader", + "verifiedBadge": true + }, + { + "rank": "2", + "proxyWallet": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "userName": "SecondPlace", + "vol": 500_000.0, + "pnl": 75_000.0, + "verifiedBadge": false + } + ])); + }); + + let request = TraderLeaderboardRequest::builder().build(); + + let response = client.leaderboard(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].rank, 1); + assert_eq!(response[0].proxy_wallet, test_user()); + assert_eq!(response[0].pnl, dec!(150_000.0)); + assert_eq!(response[0].verified_badge, Some(true)); + assert_eq!(response[1].rank, 2); + assert_eq!(response[1].proxy_wallet, second_user); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn leaderboard_with_filters_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/leaderboard") + .query_param("category", "POLITICS") + .query_param("timePeriod", "WEEK") + .query_param("orderBy", "VOL") + .query_param("limit", "10"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = TraderLeaderboardRequest::builder() + .category(LeaderboardCategory::Politics) + .time_period(TimePeriod::Week) + .order_by(LeaderboardOrderBy::Vol) + .limit(10)? + .build(); + + let response = client.leaderboard(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } +} + +mod traded { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::TradedRequest}; + use reqwest::StatusCode; + use serde_json::json; + + use super::test_user; + + #[tokio::test] + async fn traded_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/traded") + .query_param("user", "0x1234567890abcdef1234567890abcdef12345678"); + then.status(StatusCode::OK).json_body(json!({ + "user": "0x1234567890abcdef1234567890abcdef12345678", + "traded": 42 + })); + }); + + let request = TradedRequest::builder().user(test_user()).build(); + + let response = client.traded(&request).await?; + + assert_eq!(response.user, test_user()); + assert_eq!(response.traded, 42); + mock.assert(); + + Ok(()) + } +} + +mod open_interest { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::types::response::Market; + use polymarket_client_sdk::data::{Client, types::request::OpenInterestRequest}; + use polymarket_client_sdk::types::b256; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + use super::test_condition_id; + + #[tokio::test] + async fn open_interest_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/oi"); + then.status(StatusCode::OK).json_body(json!([ + { + "market": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "value": 1_500_000.0 + }, + { + "market": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "value": 750_000.0 + }, + { + "market": "GLOBAL", + "value": 2_250_000.0 + } + ])); + }); + + let response = client + .open_interest(&OpenInterestRequest::default()) + .await?; + + assert_eq!(response.len(), 3); + assert_eq!( + response[0].market, + Market::Market(b256!( + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )) + ); + assert_eq!(response[0].value, dec!(1_500_000.0)); + assert_eq!( + response[1].market, + Market::Market(b256!( + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + )) + ); + assert_eq!(response[2].market, Market::Global); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn open_interest_with_market_filter_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/oi").query_param( + "market", + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ); + then.status(StatusCode::OK).json_body(json!([ + { + "market": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "value": 500_000.0 + } + ])); + }); + + let request = OpenInterestRequest::builder() + .markets(vec![test_condition_id()]) + .build(); + + let response = client.open_interest(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!( + response[0].market, + Market::Market(b256!( + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )) + ); + mock.assert(); + + Ok(()) + } +} + +mod live_volume { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::types::response::Market; + use polymarket_client_sdk::data::{Client, types::request::LiveVolumeRequest}; + use polymarket_client_sdk::types::b256; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + #[tokio::test] + async fn live_volume_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/live-volume") + .query_param("id", "123"); + then.status(StatusCode::OK).json_body(json!([ + { + "total": 250_000.0, + "markets": [ + { + "market": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "value": 150_000.0 + }, + { + "market": "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "value": 100_000.0 + } + ] + } + ])); + }); + + let request = LiveVolumeRequest::builder().id(123).build(); + + let response = client.live_volume(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].total, dec!(250_000.0)); + let markets = &response[0].markets; + assert_eq!(markets.len(), 2); + assert_eq!( + markets[0].market, + Market::Market(b256!( + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + )) + ); + assert_eq!(markets[0].value, dec!(150_000.0)); + assert_eq!( + markets[1].market, + Market::Market(b256!( + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + )) + ); + mock.assert(); + + Ok(()) + } +} + +mod builder_leaderboard { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{ + Client, types::TimePeriod, types::request::BuilderLeaderboardRequest, + }; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + #[tokio::test] + async fn builder_leaderboard_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/builders/leaderboard"); + then.status(StatusCode::OK).json_body(json!([ + { + "rank": "1", + "builder": "TopBuilder", + "volume": 5_000_000.0, + "activeUsers": 1500, + "verified": true, + "builderLogo": "https://example.com/builder1.png" + }, + { + "rank": "2", + "builder": "SecondBuilder", + "volume": 2_500_000.0, + "activeUsers": 800, + "verified": false + } + ])); + }); + + let request = BuilderLeaderboardRequest::builder().build(); + + let response = client.builder_leaderboard(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].rank, 1); + assert_eq!(response[0].builder, "TopBuilder"); + assert_eq!(response[0].volume, dec!(5_000_000.0)); + assert_eq!(response[0].active_users, 1500); + assert!(response[0].verified); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn builder_leaderboard_with_time_period_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/builders/leaderboard") + .query_param("timePeriod", "MONTH") + .query_param("limit", "5"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = BuilderLeaderboardRequest::builder() + .time_period(TimePeriod::Month) + .limit(5)? + .build(); + + let response = client.builder_leaderboard(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } +} + +mod builder_volume { + use std::str::FromStr as _; + + use chrono::{DateTime, Utc}; + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{ + Client, types::TimePeriod, types::request::BuilderVolumeRequest, + }; + use reqwest::StatusCode; + use rust_decimal_macros::dec; + use serde_json::json; + + #[tokio::test] + async fn builder_volume_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/builders/volume"); + then.status(StatusCode::OK).json_body(json!([ + { + "dt": "2025-01-15T00:00:00Z", + "builder": "Builder1", + "builderLogo": "https://example.com/b1.png", + "verified": true, + "volume": 100_000.0, + "activeUsers": 250, + "rank": "1" + }, + { + "dt": "2025-01-14T00:00:00Z", + "builder": "Builder1", + "builderLogo": "https://example.com/b1.png", + "verified": true, + "volume": 95_000.0, + "activeUsers": 230, + "rank": "1" + } + ])); + }); + + let request = BuilderVolumeRequest::builder().build(); + + let response = client.builder_volume(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!( + response[0].dt, + DateTime::::from_str("2025-01-15T00:00:00Z")? + ); + assert_eq!(response[0].builder, "Builder1"); + assert_eq!(response[0].volume, dec!(100_000.0)); + assert!(response[0].verified); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn builder_volume_with_time_period_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/builders/volume") + .query_param("timePeriod", "WEEK"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = BuilderVolumeRequest::builder() + .time_period(TimePeriod::Week) + .build(); + + let response = client.builder_volume(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } +} + +mod error_handling { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::data::{Client, types::request::PositionsRequest}; + use polymarket_client_sdk::error::Kind; + use reqwest::StatusCode; + use serde_json::json; + + use super::test_user; + + #[tokio::test] + async fn bad_request_should_return_error() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/positions"); + then.status(StatusCode::BAD_REQUEST).json_body(json!({ + "error": "Invalid user address" + })); + }); + + let request = PositionsRequest::builder().user(test_user()).build(); + + let result = client.positions(&request).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), Kind::Status); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn server_error_should_return_error() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/positions"); + then.status(StatusCode::INTERNAL_SERVER_ERROR) + .json_body(json!({ + "error": "Internal server error" + })); + }); + + let request = PositionsRequest::builder().user(test_user()).build(); + + let result = client.positions(&request).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), Kind::Status); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn null_response_should_return_error() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/positions"); + then.status(StatusCode::OK).body("null"); + }); + + let request = PositionsRequest::builder().user(test_user()).build(); + + let result = client.positions(&request).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), Kind::Status); + mock.assert(); + + Ok(()) + } +} + +mod client { + use polymarket_client_sdk::data::Client; + + #[test] + fn client_default_should_succeed() { + let client = Client::default(); + assert_eq!(client.host().as_str(), "https://data-api.polymarket.com/"); + } + + #[test] + fn client_new_with_custom_host_should_succeed() -> anyhow::Result<()> { + let client = Client::new("https://custom-api.example.com")?; + assert_eq!(client.host().as_str(), "https://custom-api.example.com/"); + Ok(()) + } + + #[test] + fn client_new_with_invalid_url_should_fail() { + Client::new("not-a-valid-url").unwrap_err(); + } +} + +mod types { + use polymarket_client_sdk::ToQueryParams as _; + use polymarket_client_sdk::data::{ + types::request::{ + ActivityRequest, BuilderLeaderboardRequest, HoldersRequest, LiveVolumeRequest, + PositionsRequest, TradedRequest, TraderLeaderboardRequest, TradesRequest, + }, + types::{ + ActivityType, BoundedIntError, LeaderboardCategory, LeaderboardOrderBy, MarketFilter, + PositionSortBy, Side, SortDirection, TimePeriod, TradeFilter, + }, + }; + use rust_decimal_macros::dec; + + use super::{address, b256}; + + #[test] + fn bounded_limits() { + // Test that the builder validates bounds correctly + // PositionsRequest limit: 0-500 + drop( + PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(0) + .unwrap() + .build(), + ); + drop( + PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(500) + .unwrap() + .build(), + ); + let err = PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(501); + assert!(matches!(err, Err(BoundedIntError { .. }))); + + // HoldersRequest limit: 0-20 + drop( + HoldersRequest::builder() + .markets(vec![]) + .limit(0) + .unwrap() + .build(), + ); + drop( + HoldersRequest::builder() + .markets(vec![]) + .limit(20) + .unwrap() + .build(), + ); + let err = HoldersRequest::builder().markets(vec![]).limit(21); + assert!(matches!(err, Err(BoundedIntError { .. }))); + + // TraderLeaderboardRequest limit: 1-50 + let err = TraderLeaderboardRequest::builder().limit(0); + assert!(matches!(err, Err(BoundedIntError { .. }))); + drop( + TraderLeaderboardRequest::builder() + .limit(1) + .unwrap() + .build(), + ); + drop( + TraderLeaderboardRequest::builder() + .limit(50) + .unwrap() + .build(), + ); + let err = TraderLeaderboardRequest::builder().limit(51); + assert!(matches!(err, Err(BoundedIntError { .. }))); + + // BuilderLeaderboardRequest limit: 0-50 + drop( + BuilderLeaderboardRequest::builder() + .limit(0) + .unwrap() + .build(), + ); + drop( + BuilderLeaderboardRequest::builder() + .limit(50) + .unwrap() + .build(), + ); + let err = BuilderLeaderboardRequest::builder().limit(51); + assert!(matches!(err, Err(BoundedIntError { .. }))); + } + + #[test] + fn positions_request_query_string() { + let req = PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(50) + .unwrap() + .sort_by(PositionSortBy::CashPnl) + .sort_direction(SortDirection::Desc) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("user=0x")); + assert!(qs.contains("limit=50")); + assert!(qs.contains("sortBy=CASHPNL")); + assert!(qs.contains("sortDirection=DESC")); + } + + #[test] + fn market_filter_query_string() { + let hash1 = b256!("dd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"); + let hash2 = b256!("aa22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"); + + let req = PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .filter(MarketFilter::markets([hash1, hash2])) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("market=")); + assert!(qs.contains("%2C")); // URL-encoded comma + assert!(!qs.contains("eventId=")); + } + + #[test] + fn event_id_filter_query_string() { + let req = PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .filter(MarketFilter::event_ids(["1".to_owned(), "2".to_owned()])) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("eventId=1%2C2")); // URL-encoded "1,2" + assert!(!qs.contains("market=")); + } + + #[test] + fn trade_filter() { + TradeFilter::cash(dec!(100.0)).unwrap(); + TradeFilter::tokens(dec!(0.0)).unwrap(); + TradeFilter::cash(dec!(-1.0)).unwrap_err(); + } + + #[test] + fn trades_request_with_filter() { + let req = TradesRequest::builder() + .trade_filter(TradeFilter::cash(dec!(100.0)).unwrap()) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("filterType=CASH")); + assert!(qs.contains("filterAmount=100")); + } + + #[test] + fn activity_types_query_string() { + let req = ActivityRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .activity_types(vec![ActivityType::Trade, ActivityType::Redeem]) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("type=TRADE%2CREDEEM")); // URL-encoded "TRADE,REDEEM" + } + + #[test] + fn live_volume_request() { + let req = LiveVolumeRequest::builder().id(123).build(); + + let qs = req.query_params(None); + assert!(qs.contains("id=123")); + } + + #[test] + fn traded_request() { + let req = TradedRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("user=0x")); + } + + #[test] + fn trader_leaderboard_request() { + let req = TraderLeaderboardRequest::builder() + .category(LeaderboardCategory::Politics) + .time_period(TimePeriod::Week) + .order_by(LeaderboardOrderBy::Pnl) + .limit(10) + .unwrap() + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("category=POLITICS")); + assert!(qs.contains("timePeriod=WEEK")); + assert!(qs.contains("orderBy=PNL")); + assert!(qs.contains("limit=10")); + } + + #[test] + fn enum_display() { + assert_eq!(Side::Buy.to_string(), "BUY"); + assert_eq!(Side::Sell.to_string(), "SELL"); + assert_eq!(ActivityType::Trade.to_string(), "TRADE"); + assert_eq!(PositionSortBy::CashPnl.to_string(), "CASHPNL"); + assert_eq!(PositionSortBy::PercentPnl.to_string(), "PERCENTPNL"); + assert_eq!(TimePeriod::All.to_string(), "ALL"); + assert_eq!(LeaderboardCategory::Overall.to_string(), "OVERALL"); + } + + #[test] + fn all_activity_types_display() { + use polymarket_client_sdk::data::types::ActivityType; + assert_eq!(ActivityType::Split.to_string(), "SPLIT"); + assert_eq!(ActivityType::Merge.to_string(), "MERGE"); + assert_eq!(ActivityType::Redeem.to_string(), "REDEEM"); + assert_eq!(ActivityType::Reward.to_string(), "REWARD"); + assert_eq!(ActivityType::Conversion.to_string(), "CONVERSION"); + } + + #[test] + fn all_position_sort_by_display() { + assert_eq!(PositionSortBy::Current.to_string(), "CURRENT"); + assert_eq!(PositionSortBy::Initial.to_string(), "INITIAL"); + assert_eq!(PositionSortBy::Tokens.to_string(), "TOKENS"); + assert_eq!(PositionSortBy::Title.to_string(), "TITLE"); + assert_eq!(PositionSortBy::Resolving.to_string(), "RESOLVING"); + assert_eq!(PositionSortBy::Price.to_string(), "PRICE"); + assert_eq!(PositionSortBy::AvgPrice.to_string(), "AVGPRICE"); + } + + #[test] + fn all_time_periods_display() { + assert_eq!(TimePeriod::Day.to_string(), "DAY"); + assert_eq!(TimePeriod::Week.to_string(), "WEEK"); + assert_eq!(TimePeriod::Month.to_string(), "MONTH"); + } + + #[test] + fn all_leaderboard_categories_display() { + assert_eq!(LeaderboardCategory::Politics.to_string(), "POLITICS"); + assert_eq!(LeaderboardCategory::Sports.to_string(), "SPORTS"); + assert_eq!(LeaderboardCategory::Crypto.to_string(), "CRYPTO"); + assert_eq!(LeaderboardCategory::Culture.to_string(), "CULTURE"); + assert_eq!(LeaderboardCategory::Mentions.to_string(), "MENTIONS"); + assert_eq!(LeaderboardCategory::Weather.to_string(), "WEATHER"); + assert_eq!(LeaderboardCategory::Economics.to_string(), "ECONOMICS"); + assert_eq!(LeaderboardCategory::Tech.to_string(), "TECH"); + assert_eq!(LeaderboardCategory::Finance.to_string(), "FINANCE"); + } + + #[test] + fn leaderboard_order_by_display() { + assert_eq!(LeaderboardOrderBy::Vol.to_string(), "VOL"); + } + + #[test] + fn sort_direction_display() { + assert_eq!(SortDirection::Asc.to_string(), "ASC"); + } +} + +mod error_display { + use polymarket_client_sdk::data::{types::TradeFilter, types::request::PositionsRequest}; + use rust_decimal_macros::dec; + + use super::address; + + #[test] + fn bounded_int_error_display() { + let err = PositionsRequest::builder() + .user(address!("56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(501); + let Err(err) = err else { + panic!("Expected an error") + }; + assert!(err.to_string().contains("500")); + assert!(err.to_string().contains("501")); + } + + #[test] + fn trade_filter_error_display() { + let err = TradeFilter::cash(dec!(-1.0)).unwrap_err(); + assert!(err.to_string().contains("-1")); + } +} + +mod request_query_string_extended { + use polymarket_client_sdk::ToQueryParams as _; + use polymarket_client_sdk::data::types::{ + ActivitySortBy, ClosedPositionSortBy, MarketFilter, PositionSortBy, Side, SortDirection, + TradeFilter, + request::{ + ActivityRequest, BuilderLeaderboardRequest, ClosedPositionsRequest, HoldersRequest, + OpenInterestRequest, PositionsRequest, TraderLeaderboardRequest, TradesRequest, + ValueRequest, + }, + }; + use rust_decimal_macros::dec; + + use super::{Address, B256, address, b256}; + + fn test_addr() -> Address { + address!("56687bf447db6ffa42ffe2204a05edaa20f55839") + } + + fn test_hash() -> B256 { + b256!("dd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917") + } + + #[test] + fn positions_request_full() { + let req = PositionsRequest::builder() + .user(test_addr()) + .size_threshold(dec!(100)) + .mergeable(true) + .sort_by(PositionSortBy::Current) + .title("test") + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("sizeThreshold=")); + assert!(qs.contains("mergeable=")); + assert!(qs.contains("sortBy=")); + assert!(qs.contains("title=")); + } + + #[test] + fn trades_request_full() { + let req = TradesRequest::builder() + .user(test_addr()) + .filter(MarketFilter::markets([test_hash()])) + .limit(50) + .unwrap() + .taker_only(true) + .side(Side::Buy) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("user=")); + assert!(qs.contains("market=")); + assert!(qs.contains("limit=")); + assert!(qs.contains("takerOnly=")); + assert!(qs.contains("side=")); + } + + #[test] + fn activity_request_full() { + let req = ActivityRequest::builder() + .user(test_addr()) + .filter(MarketFilter::event_ids(["1".to_owned()])) + .limit(50) + .unwrap() + .start(1000) + .end(2000) + .sort_by(ActivitySortBy::Timestamp) + .sort_direction(SortDirection::Asc) + .side(Side::Sell) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("eventId=")); + assert!(qs.contains("start=")); + assert!(qs.contains("end=")); + assert!(qs.contains("sortBy=")); + assert!(qs.contains("sortDirection=")); + assert!(qs.contains("side=")); + } + + #[test] + fn holders_request_full() { + let req = HoldersRequest::builder() + .markets(vec![test_hash()]) + .min_balance(10) + .unwrap() + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("minBalance=")); + } + + #[test] + fn value_request_with_markets() { + let req = ValueRequest::builder() + .user(test_addr()) + .markets(vec![test_hash()]) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("market=")); + } + + #[test] + fn closed_positions_request_full() { + let req = ClosedPositionsRequest::builder() + .user(test_addr()) + .filter(MarketFilter::markets([test_hash()])) + .title("test") + .limit(10) + .unwrap() + .sort_by(ClosedPositionSortBy::RealizedPnl) + .sort_direction(SortDirection::Desc) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("market=")); + assert!(qs.contains("title=")); + assert!(qs.contains("sortBy=")); + assert!(qs.contains("sortDirection=")); + } + + #[test] + fn builder_leaderboard_request_full() { + let req = BuilderLeaderboardRequest::builder() + .offset(10) + .unwrap() + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("offset=")); + } + + #[test] + fn trader_leaderboard_request_full() { + let req = TraderLeaderboardRequest::builder() + .user(test_addr()) + .user_name("testuser".to_owned()) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("user=")); + assert!(qs.contains("userName=")); + } + + #[test] + fn trade_filter_tokens() { + let req = TradesRequest::builder() + .trade_filter(TradeFilter::tokens(dec!(50.0)).unwrap()) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("filterType=TOKENS")); + } + + #[test] + fn empty_market_filter_not_added() { + let req = PositionsRequest::builder() + .user(test_addr()) + .filter(MarketFilter::markets([] as [B256; 0])) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("market=")); + } + + #[test] + fn empty_event_id_filter_not_added() { + let req = PositionsRequest::builder() + .user(test_addr()) + .filter(MarketFilter::event_ids([])) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("eventId=")); + } + + #[test] + fn empty_activity_types_not_added() { + let req = ActivityRequest::builder() + .user(test_addr()) + .activity_types(vec![]) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("type=")); + } + + #[test] + fn empty_holders_markets_not_added() { + let req = HoldersRequest::builder() + .markets(Vec::::new()) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("market=")); + } + + #[test] + fn empty_value_markets_not_added() { + let req = ValueRequest::builder() + .user(test_addr()) + .markets(Vec::::new()) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("market=")); + } + + #[test] + fn closed_position_sort_by_variants() { + use polymarket_client_sdk::data::types::ClosedPositionSortBy; + assert_eq!(ClosedPositionSortBy::Title.to_string(), "TITLE"); + assert_eq!(ClosedPositionSortBy::Price.to_string(), "PRICE"); + assert_eq!(ClosedPositionSortBy::AvgPrice.to_string(), "AVGPRICE"); + assert_eq!(ClosedPositionSortBy::Timestamp.to_string(), "TIMESTAMP"); + } + + #[test] + fn activity_sort_by_variants() { + assert_eq!(ActivitySortBy::Tokens.to_string(), "TOKENS"); + assert_eq!(ActivitySortBy::Cash.to_string(), "CASH"); + } + + #[test] + fn filter_type_display() { + use polymarket_client_sdk::data::types::FilterType; + assert_eq!(FilterType::Cash.to_string(), "CASH"); + assert_eq!(FilterType::Tokens.to_string(), "TOKENS"); + } + + #[test] + fn empty_request_query_string() { + let req = TradesRequest::default(); + let qs = req.query_params(None); + assert!(qs.is_empty()); + } + + #[test] + fn trades_request_with_offset() { + let req = TradesRequest::builder().offset(100).unwrap().build(); + + let qs = req.query_params(None); + assert!(qs.contains("offset=100")); + } + + #[test] + fn open_interest_request_with_markets() { + let req = OpenInterestRequest::builder() + .markets(vec![test_hash()]) + .build(); + + let qs = req.query_params(None); + assert!(qs.contains("market=")); + } + + #[test] + fn open_interest_request_empty_markets() { + let req = OpenInterestRequest::builder() + .markets(Vec::::new()) + .build(); + + let qs = req.query_params(None); + assert!(!qs.contains("market=")); + } + + #[test] + fn closed_position_sort_by_realized_pnl() { + assert_eq!(ClosedPositionSortBy::RealizedPnl.to_string(), "REALIZEDPNL"); + } +} diff --git a/polymarket-client-sdk/tests/gamma.rs b/polymarket-client-sdk/tests/gamma.rs new file mode 100644 index 0000000..1ad2f69 --- /dev/null +++ b/polymarket-client-sdk/tests/gamma.rs @@ -0,0 +1,1582 @@ +#![cfg(feature = "gamma")] +#![allow( + clippy::unwrap_used, + reason = "Do not need additional syntax for setting up tests, and https://github.com/rust-lang/rust-clippy/issues/13981" +)] + +//! Integration tests for the Gamma API client. +//! +//! These tests use `httpmock` to mock HTTP responses, ensuring deterministic +//! and fast test execution without requiring network access. +//! +//! # Running Tests +//! +//! ```bash +//! cargo test --features gamma +//! ``` +//! +//! # Test Coverage +//! +//! Tests are organized by API endpoint group: +//! - `sports`: Teams, sports metadata, and market types +//! - `tags`: Tag listing and lookup by ID/slug, related tags +//! - `events`: Event listing and lookup by ID/slug, event tags +//! - `markets`: Market listing and lookup by ID/slug, market tags +//! - `series`: Series listing and lookup by ID +//! - `comments`: Comment listing and lookup by ID/user address +//! - `profiles`: Public profile lookup +//! - `search`: Search across events, markets, and profiles +//! - `health`: API health check + +pub mod common; + +mod sports { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{Client, types::request::TeamsRequest}; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn teams_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/teams"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": 1, + "name": "Lakers", + "league": "NBA", + "record": "45-37", + "logo": "https://example.com/lakers.png", + "abbreviation": "LAL", + "alias": "Los Angeles Lakers", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-06-20T14:45:00Z" + }, + { + "id": 2, + "name": "Celtics", + "league": "NBA", + "record": "64-18", + "logo": "https://example.com/celtics.png", + "abbreviation": "BOS", + "alias": "Boston Celtics", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-06-20T14:45:00Z" + } + ])); + }); + + let response = client.teams(&TeamsRequest::default()).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].id, 1); + assert_eq!(response[0].name, Some("Lakers".to_owned())); + assert_eq!(response[0].league, Some("NBA".to_owned())); + assert_eq!(response[1].id, 2); + assert_eq!(response[1].name, Some("Celtics".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn sports_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/sports"); + then.status(StatusCode::OK).json_body(json!([ + { + "sport": "ncaab", + "image": "https://example.com/basketball.png", + "resolution": "https://example.com", + "ordering": "home", + "tags": "1,2,3", + "series": "39" + } + ])); + }); + + let response = client.sports().await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].sport, "ncaab"); + assert_eq!(response[0].image, "https://example.com/basketball.png"); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn sports_market_types_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/sports/market-types"); + then.status(StatusCode::OK).json_body(json!({ + "marketTypes": ["moneyline", "spreads", "totals"] + })); + }); + + let response = client.sports_market_types().await?; + + assert_eq!( + response.market_types, + vec!["moneyline", "spreads", "totals"] + ); + mock.assert(); + + Ok(()) + } +} + +mod tags { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{ + Client, + types::request::{ + RelatedTagsByIdRequest, RelatedTagsBySlugRequest, TagByIdRequest, TagBySlugRequest, + TagsRequest, + }, + }; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn tags_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "label": "Politics", + "slug": "politics", + "forceShow": true, + "publishedAt": "2024-01-15T10:30:00Z", + "createdBy": 1, + "updatedBy": 2, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-06-20T14:45:00Z", + "forceHide": false, + "isCarousel": true + } + ])); + }); + + let request = TagsRequest::builder().build(); + let response = client.tags(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].label, Some("Politics".to_owned())); + assert_eq!(response[0].slug, Some("politics".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn tag_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags/42"); + then.status(StatusCode::OK).json_body(json!({ + "id": "42", + "label": "Sports", + "slug": "sports", + "forceShow": false, + "forceHide": false, + "isCarousel": false + })); + }); + + let request = TagByIdRequest::builder().id("42").build(); + let response = client.tag_by_id(&request).await?; + + assert_eq!(response.id, "42"); + assert_eq!(response.label, Some("Sports".to_owned())); + assert_eq!(response.slug, Some("sports".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn tag_by_slug_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags/slug/crypto"); + then.status(StatusCode::OK).json_body(json!({ + "id": "99", + "label": "Crypto", + "slug": "crypto", + "forceShow": true, + "forceHide": false, + "isCarousel": true + })); + }); + + let request = TagBySlugRequest::builder().slug("crypto").build(); + let response = client.tag_by_slug(&request).await?; + + assert_eq!(response.id, "99"); + assert_eq!(response.label, Some("Crypto".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn related_tags_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags/42/related-tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "tagID": "42", + "relatedTagID": "99", + "rank": 1 + } + ])); + }); + + let request = RelatedTagsByIdRequest::builder().id("42").build(); + let response = client.related_tags_by_id(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].tag_id, Some("42".to_owned())); + assert_eq!(response[0].related_tag_id, Some("99".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn related_tags_by_slug_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags/slug/politics/related-tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "2", + "tagID": "10", + "relatedTagID": "20", + "rank": 5 + } + ])); + }); + + let request = RelatedTagsBySlugRequest::builder().slug("politics").build(); + let response = client.related_tags_by_slug(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "2"); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn tags_related_to_tag_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/tags/42/related-tags/tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "99", + "label": "Related Tag", + "slug": "related-tag", + "forceShow": false, + "forceHide": false, + "isCarousel": false + } + ])); + }); + + let request = RelatedTagsByIdRequest::builder().id("42").build(); + let response = client.tags_related_to_tag_by_id(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "99"); + assert_eq!(response[0].label, Some("Related Tag".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn tags_related_to_tag_by_slug_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/tags/slug/politics/related-tags/tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "50", + "label": "Elections", + "slug": "elections", + "forceShow": true, + "forceHide": false, + "isCarousel": true + } + ])); + }); + + let request = RelatedTagsBySlugRequest::builder().slug("politics").build(); + let response = client.tags_related_to_tag_by_slug(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "50"); + assert_eq!(response[0].label, Some("Elections".to_owned())); + mock.assert(); + + Ok(()) + } +} + +mod events { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{ + Client, + types::request::{EventByIdRequest, EventBySlugRequest, EventsRequest}, + }; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn events_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/events") + .query_param("active", "true"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "123", + "title": "Test Event", + "slug": "test-event", + "active": true + } + ])); + }); + + let request = EventsRequest::builder().active(true).build(); + let response = client.events(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "123"); + assert_eq!(response[0].title, Some("Test Event".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn event_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/events/456"); + then.status(StatusCode::OK).json_body(json!({ + "id": "456", + "title": "Specific Event", + "slug": "specific-event" + })); + }); + + let request = EventByIdRequest::builder().id("456").build(); + let response = client.event_by_id(&request).await?; + + assert_eq!(response.id, "456"); + assert_eq!(response.title, Some("Specific Event".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn event_by_slug_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/events/slug/my-event"); + then.status(StatusCode::OK).json_body(json!({ + "id": "789", + "title": "My Event", + "slug": "my-event" + })); + }); + + let request = EventBySlugRequest::builder().slug("my-event").build(); + let response = client.event_by_slug(&request).await?; + + assert_eq!(response.id, "789"); + assert_eq!(response.slug, Some("my-event".to_owned())); + mock.assert(); + + Ok(()) + } +} + +mod markets { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{ + Client, + types::request::{MarketByIdRequest, MarketBySlugRequest, MarketsRequest}, + }; + use reqwest::StatusCode; + use serde_json::json; + + use crate::common::{token_1, token_2}; + + #[tokio::test] + async fn markets_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/markets").query_param("limit", "10"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "question": "Test Market?", + "slug": "test-market" + } + ])); + }); + + let request = MarketsRequest::builder().limit(10).build(); + let response = client.markets(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].question, Some("Test Market?".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn market_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/markets/42"); + then.status(StatusCode::OK).json_body(json!({ + "id": "42", + "question": "Specific Market?", + "slug": "specific-market" + })); + }); + + let request = MarketByIdRequest::builder().id("42").build(); + let response = client.market_by_id(&request).await?; + + assert_eq!(response.id, "42"); + assert_eq!(response.question, Some("Specific Market?".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn market_by_slug_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/markets/slug/my-market"); + then.status(StatusCode::OK).json_body(json!({ + "id": "99", + "question": "My Market?", + "slug": "my-market" + })); + }); + + let request = MarketBySlugRequest::builder().slug("my-market").build(); + let response = client.market_by_slug(&request).await?; + + assert_eq!(response.id, "99"); + assert_eq!(response.slug, Some("my-market".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn markets_empty_request() -> anyhow::Result<()> { + // Tests (true, true): no base params, no clob_token_ids + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/markets"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = MarketsRequest::default(); + let response = client.markets(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn markets_only_clob_token_ids() -> anyhow::Result<()> { + // Tests (true, false): only clob_token_ids, no base params + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/markets") + .query_param("clob_token_ids", token_1().to_string()) + .query_param("clob_token_ids", token_2().to_string()); + then.status(StatusCode::OK).json_body(json!([ + {"id": "1", "question": "Market 1?", "slug": "market-1"} + ])); + }); + + let request = MarketsRequest::builder() + .clob_token_ids(vec![token_1(), token_2()]) + .build(); + let response = client.markets(&request).await?; + + assert_eq!(response.len(), 1); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn markets_with_base_and_clob_params() -> anyhow::Result<()> { + // Tests (false, false): both base params and clob_token_ids + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/markets") + .query_param("limit", "50") + .query_param("clob_token_ids", token_1().to_string()) + .query_param("clob_token_ids", token_2().to_string()); + then.status(StatusCode::OK).json_body(json!([ + {"id": "1", "question": "Market 1?", "slug": "market-1"}, + {"id": "2", "question": "Market 2?", "slug": "market-2"} + ])); + }); + + let request = MarketsRequest::builder() + .limit(50) + .clob_token_ids(vec![token_1(), token_2()]) + .build(); + let response = client.markets(&request).await?; + + assert_eq!(response.len(), 2); + mock.assert(); + + Ok(()) + } +} + +mod search { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{Client, types::request::SearchRequest}; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn search_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/public-search") + .query_param("q", "bitcoin"); + then.status(StatusCode::OK).json_body(json!({ + "events": [], + "tags": [], + "profiles": [] + })); + }); + + let request = SearchRequest::builder().q("bitcoin").build(); + let response = client.search(&request).await?; + + assert!( + response.events.is_none() + || response + .events + .as_ref() + .is_some_and(std::vec::Vec::is_empty) + ); + mock.assert(); + + Ok(()) + } +} + +mod health { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::Client; + use reqwest::StatusCode; + + #[tokio::test] + async fn status_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/status"); + then.status(StatusCode::OK).body("OK"); + }); + + let response = client.status().await?; + + assert_eq!(response, "OK"); + mock.assert(); + + Ok(()) + } +} + +mod series { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{ + Client, + types::request::{SeriesByIdRequest, SeriesListRequest}, + }; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn series_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/series"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "title": "Weekly Elections", + "slug": "weekly-elections", + "active": true, + "closed": false + } + ])); + }); + + let request = SeriesListRequest::builder().build(); + let response = client.series(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].title, Some("Weekly Elections".to_owned())); + assert_eq!(response[0].slug, Some("weekly-elections".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn series_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/series/42"); + then.status(StatusCode::OK).json_body(json!({ + "id": "42", + "title": "NFL Season 2024", + "slug": "nfl-season-2024", + "active": true, + "recurrence": "weekly" + })); + }); + + let request = SeriesByIdRequest::builder().id("42").build(); + let response = client.series_by_id(&request).await?; + + assert_eq!(response.id, "42"); + assert_eq!(response.title, Some("NFL Season 2024".to_owned())); + assert_eq!(response.recurrence, Some("weekly".to_owned())); + mock.assert(); + + Ok(()) + } +} + +mod comments { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::types::ParentEntityType; + use polymarket_client_sdk::gamma::{ + Client, + types::request::{CommentsByIdRequest, CommentsByUserAddressRequest, CommentsRequest}, + }; + use polymarket_client_sdk::types::address; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn comments_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/comments"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "body": "Great market!", + "parentEntityType": "Event", + "parentEntityID": 123, + "userAddress": "0x56687bf447db6ffa42ffe2204a05edaa20f55839", + "createdAt": "2024-01-15T10:30:00Z" + } + ])); + }); + + let request = CommentsRequest::builder() + .parent_entity_type(ParentEntityType::Event) + .parent_entity_id("123") + .build(); + let response = client.comments(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].body, Some("Great market!".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn comments_with_filters_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET) + .path("/comments") + .query_param("parent_entity_type", "Event") + .query_param("parent_entity_id", "123") + .query_param("limit", "10"); + then.status(StatusCode::OK).json_body(json!([])); + }); + + let request = CommentsRequest::builder() + .parent_entity_type(ParentEntityType::Event) + .parent_entity_id("123") + .limit(10) + .build(); + let response = client.comments(&request).await?; + + assert!(response.is_empty()); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn comments_by_id_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/comments/42"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "42", + "body": "This is the comment", + "parentEntityType": "Event", + "parentEntityID": 100 + } + ])); + }); + + let request = CommentsByIdRequest::builder().id("42").build(); + let response = client.comments_by_id(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "42"); + assert_eq!(response[0].body, Some("This is the comment".to_owned())); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn comments_by_user_address_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + // Address is serialized with EIP-55 checksum format + when.method(GET) + .path("/comments/user_address/0x56687BF447DB6fFA42FFE2204a05EDAA20f55839"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "body": "User comment", + "userAddress": "0x56687BF447DB6fFA42FFE2204a05EDAA20f55839" + }, + { + "id": "2", + "body": "Another comment", + "userAddress": "0x56687BF447DB6fFA42FFE2204a05EDAA20f55839" + } + ])); + }); + + let request = CommentsByUserAddressRequest::builder() + .user_address(address!("0x56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + let response = client.comments_by_user_address(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].body, Some("User comment".to_owned())); + assert_eq!(response[1].body, Some("Another comment".to_owned())); + mock.assert(); + + Ok(()) + } +} + +mod profiles { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{Client, types::request::PublicProfileRequest}; + use polymarket_client_sdk::types::address; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn public_profile_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + // Address serializes to lowercase hex via serde + when.method(GET) + .path("/public-profile") + .query_param("address", "0x56687bf447db6ffa42ffe2204a05edaa20f55839"); + then.status(StatusCode::OK).json_body(json!({ + "proxyWallet": "0x56687bf447db6ffa42ffe2204a05edaa20f55839", + "name": "Polymarket Trader", + "pseudonym": "PolyTrader", + "bio": "Trading prediction markets", + "displayUsernamePublic": true, + "verifiedBadge": false + })); + }); + + let request = PublicProfileRequest::builder() + .address(address!("0x56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + let response = client.public_profile(&request).await?; + + assert_eq!(response.name, Some("Polymarket Trader".to_owned())); + assert_eq!(response.pseudonym, Some("PolyTrader".to_owned())); + assert_eq!(response.verified_badge, Some(false)); + mock.assert(); + + Ok(()) + } +} + +mod event_tags { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{Client, types::request::EventTagsRequest}; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn event_tags_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/events/123/tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "1", + "label": "Politics", + "slug": "politics" + }, + { + "id": "2", + "label": "Elections", + "slug": "elections" + } + ])); + }); + + let request = EventTagsRequest::builder().id("123").build(); + let response = client.event_tags(&request).await?; + + assert_eq!(response.len(), 2); + assert_eq!(response[0].id, "1"); + assert_eq!(response[0].label, Some("Politics".to_owned())); + assert_eq!(response[1].id, "2"); + assert_eq!(response[1].label, Some("Elections".to_owned())); + mock.assert(); + + Ok(()) + } +} + +mod market_tags { + use httpmock::{Method::GET, MockServer}; + use polymarket_client_sdk::gamma::{Client, types::request::MarketTagsRequest}; + use reqwest::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn market_tags_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = Client::new(&server.base_url())?; + + let mock = server.mock(|when, then| { + when.method(GET).path("/markets/456/tags"); + then.status(StatusCode::OK).json_body(json!([ + { + "id": "3", + "label": "Crypto", + "slug": "crypto" + } + ])); + }); + + let request = MarketTagsRequest::builder().id("456").build(); + let response = client.market_tags(&request).await?; + + assert_eq!(response.len(), 1); + assert_eq!(response[0].id, "3"); + assert_eq!(response[0].label, Some("Crypto".to_owned())); + mock.assert(); + + Ok(()) + } +} + +// ============================================================================= +// Unit Tests for QueryParams and Common Types +// ============================================================================= + +mod query_string { + use chrono::{TimeZone as _, Utc}; + use polymarket_client_sdk::ToQueryParams as _; + use polymarket_client_sdk::gamma::types::request::{ + CommentsByIdRequest, CommentsByUserAddressRequest, CommentsRequest, EventByIdRequest, + EventBySlugRequest, EventTagsRequest, EventsRequest, MarketByIdRequest, + MarketBySlugRequest, MarketTagsRequest, MarketsRequest, PublicProfileRequest, + RelatedTagsByIdRequest, RelatedTagsBySlugRequest, SearchRequest, SeriesByIdRequest, + SeriesListRequest, TagByIdRequest, TagBySlugRequest, TagsRequest, TeamsRequest, + }; + use polymarket_client_sdk::gamma::types::{ParentEntityType, RelatedTagsStatus}; + use polymarket_client_sdk::types::{address, b256}; + use rust_decimal_macros::dec; + + use crate::common::{token_1, token_2}; + + #[test] + fn teams_request_all_params() { + let request = TeamsRequest::builder() + .limit(10) + .offset(5) + .order("name".to_owned()) + .ascending(true) + .league(vec!["NBA".to_owned(), "NFL".to_owned()]) + .name(vec!["Lakers".to_owned()]) + .abbreviation(vec!["LAL".to_owned(), "BOS".to_owned()]) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=10")); + assert!(qs.contains("offset=5")); + assert!(qs.contains("order=name")); + assert!(qs.contains("ascending=true")); + // Arrays should be repeated params, not comma-separated + assert!(qs.contains("league=NBA")); + assert!(qs.contains("league=NFL")); + assert!(qs.contains("name=Lakers")); + assert!(qs.contains("abbreviation=LAL")); + assert!(qs.contains("abbreviation=BOS")); + } + + #[test] + fn teams_request_empty_arrays_not_included() { + let request = TeamsRequest::builder() + .league(vec![]) + .name(vec![]) + .abbreviation(vec![]) + .build(); + + let qs = request.query_params(None); + assert!(!qs.contains("league=")); + assert!(!qs.contains("name=")); + assert!(!qs.contains("abbreviation=")); + } + + #[test] + fn tags_request_all_params() { + let request = TagsRequest::builder() + .limit(20) + .offset(10) + .order("label".to_owned()) + .ascending(false) + .include_template(true) + .is_carousel(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=20")); + assert!(qs.contains("offset=10")); + assert!(qs.contains("order=label")); + assert!(qs.contains("ascending=false")); + assert!(qs.contains("include_template=true")); + assert!(qs.contains("is_carousel=true")); + } + + #[test] + fn tag_by_id_request_with_include_template() { + let request = TagByIdRequest::builder() + .id("42") + .include_template(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_template=true")); + } + + #[test] + fn tag_by_slug_request_with_include_template() { + let request = TagBySlugRequest::builder() + .slug("politics") + .include_template(false) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_template=false")); + } + + #[test] + fn related_tags_by_id_all_params() { + let request = RelatedTagsByIdRequest::builder() + .id("42") + .omit_empty(true) + .status(RelatedTagsStatus::Active) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("omit_empty=true")); + assert!(qs.contains("status=active")); + } + + #[test] + fn related_tags_by_slug_all_params() { + let request = RelatedTagsBySlugRequest::builder() + .slug("crypto") + .omit_empty(false) + .status(RelatedTagsStatus::Closed) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("omit_empty=false")); + assert!(qs.contains("status=closed")); + } + + #[test] + fn related_tags_status_all() { + let request = RelatedTagsByIdRequest::builder() + .id("1") + .status(RelatedTagsStatus::All) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("status=all")); + } + + #[test] + fn events_request_all_params() { + let start_date = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let end_date = Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap(); + + let request = EventsRequest::builder() + .limit(50) + .offset(10) + .order(vec!["startDate".to_owned()]) + .ascending(true) + .id(vec!["1".to_owned(), "2".to_owned(), "3".to_owned()]) + .tag_id("42") + .exclude_tag_id(vec!["10".to_owned(), "20".to_owned()]) + .slug(vec!["event-1".to_owned(), "event-2".to_owned()]) + .tag_slug("politics".to_owned()) + .related_tags(true) + .active(true) + .archived(false) + .featured(true) + .cyom(false) + .include_chat(true) + .include_template(true) + .recurrence("weekly".to_owned()) + .closed(false) + .liquidity_min(dec!(1000)) + .liquidity_max(dec!(100_000)) + .volume_min(dec!(500)) + .volume_max(dec!(50000)) + .start_date_min(start_date) + .start_date_max(end_date) + .end_date_min(start_date) + .end_date_max(end_date) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=50")); + assert!(qs.contains("offset=10")); + assert!(qs.contains("order=startDate")); + assert!(qs.contains("ascending=true")); + // Arrays should be repeated params, not comma-separated + assert!(qs.contains("id=1")); + assert!(qs.contains("id=2")); + assert!(qs.contains("id=3")); + assert!(qs.contains("tag_id=42")); + assert!(qs.contains("exclude_tag_id=10")); + assert!(qs.contains("exclude_tag_id=20")); + assert!(qs.contains("slug=event-1")); + assert!(qs.contains("slug=event-2")); + assert!(qs.contains("tag_slug=politics")); + assert!(qs.contains("related_tags=true")); + assert!(qs.contains("active=true")); + assert!(qs.contains("archived=false")); + assert!(qs.contains("featured=true")); + assert!(qs.contains("cyom=false")); + assert!(qs.contains("include_chat=true")); + assert!(qs.contains("include_template=true")); + assert!(qs.contains("recurrence=weekly")); + assert!(qs.contains("closed=false")); + assert!(qs.contains("liquidity_min=1000")); + assert!(qs.contains("liquidity_max=100000")); + assert!(qs.contains("volume_min=500")); + assert!(qs.contains("volume_max=50000")); + assert!(qs.contains("start_date_min=")); + assert!(qs.contains("start_date_max=")); + assert!(qs.contains("end_date_min=")); + assert!(qs.contains("end_date_max=")); + } + + #[test] + fn events_request_empty_arrays_not_included() { + let request = EventsRequest::builder() + .id(vec![]) + .exclude_tag_id(vec![]) + .slug(vec![]) + .build(); + + let qs = request.query_params(None); + assert!(!qs.contains("id=")); + assert!(!qs.contains("exclude_tag_id=")); + assert!(!qs.contains("slug=")); + } + + #[test] + fn event_by_id_request_all_params() { + let request = EventByIdRequest::builder() + .id("123") + .include_chat(true) + .include_template(false) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_chat=true")); + assert!(qs.contains("include_template=false")); + } + + #[test] + fn event_by_slug_request_all_params() { + let request = EventBySlugRequest::builder() + .slug("my-event") + .include_chat(false) + .include_template(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_chat=false")); + assert!(qs.contains("include_template=true")); + } + + #[test] + fn event_tags_request_empty_params() { + let request = EventTagsRequest::builder().id("123").build(); + let qs = request.query_params(None); + assert!(qs.is_empty()); + } + + #[test] + fn markets_request_all_params() { + let start_date = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let end_date = Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap(); + + let request = MarketsRequest::builder() + .limit(100) + .offset(50) + .order("volume".to_owned()) + .ascending(false) + .id(vec!["1".to_owned(), "2".to_owned()]) + .slug(vec!["market-1".to_owned()]) + .clob_token_ids(vec![token_1(), token_2()]) + .condition_ids(vec![b256!( + "0x0000000000000000000000000000000000000000000000000000000000000001" + )]) + .market_maker_address(vec![address!("0x0000000000000000000000000000000000000123")]) + .liquidity_num_min(dec!(1000)) + .liquidity_num_max(dec!(100_000)) + .volume_num_min(dec!(500)) + .volume_num_max(dec!(50000)) + .start_date_min(start_date) + .start_date_max(end_date) + .end_date_min(start_date) + .end_date_max(end_date) + .tag_id("42") + .related_tags(true) + .cyom(false) + .uma_resolution_status("resolved".to_owned()) + .game_id("game123".to_owned()) + .sports_market_types(vec!["moneyline".to_owned(), "spread".to_owned()]) + .rewards_min_size(dec!(100)) + .question_ids(vec![ + b256!("0x0000000000000000000000000000000000000000000000000000000000000001"), + b256!("0x0000000000000000000000000000000000000000000000000000000000000002"), + ]) + .include_tag(true) + .closed(false) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=100")); + assert!(qs.contains("offset=50")); + assert!(qs.contains("order=volume")); + assert!(qs.contains("ascending=false")); + // Arrays should be repeated params, not comma-separated + assert!(qs.contains("id=1")); + assert!(qs.contains("id=2")); + assert!(qs.contains("slug=market-1")); + // clob_token_ids is now handled with repeated params like all other arrays + assert!(qs.contains(&format!("clob_token_ids={}", token_1()))); + assert!(qs.contains(&format!("clob_token_ids={}", token_2()))); + // B256 and Address serialize to lowercase hex via serde (repeated params) + assert!(qs.contains( + "condition_ids=0x0000000000000000000000000000000000000000000000000000000000000001" + )); + assert!(qs.contains("market_maker_address=0x0000000000000000000000000000000000000123")); + assert!(qs.contains("liquidity_num_min=1000")); + assert!(qs.contains("liquidity_num_max=100000")); + assert!(qs.contains("volume_num_min=500")); + assert!(qs.contains("volume_num_max=50000")); + assert!(qs.contains("start_date_min=")); + assert!(qs.contains("start_date_max=")); + assert!(qs.contains("end_date_min=")); + assert!(qs.contains("end_date_max=")); + assert!(qs.contains("tag_id=42")); + assert!(qs.contains("related_tags=true")); + assert!(qs.contains("cyom=false")); + assert!(qs.contains("uma_resolution_status=resolved")); + assert!(qs.contains("game_id=game123")); + assert!(qs.contains("sports_market_types=moneyline")); + assert!(qs.contains("sports_market_types=spread")); + assert!(qs.contains("rewards_min_size=100")); + // B256 question_ids serialize to lowercase hex via serde (repeated params) + assert!(qs.contains( + "question_ids=0x0000000000000000000000000000000000000000000000000000000000000001" + )); + assert!(qs.contains( + "question_ids=0x0000000000000000000000000000000000000000000000000000000000000002" + )); + assert!(qs.contains("include_tag=true")); + assert!(qs.contains("closed=false")); + } + + #[test] + fn markets_request_empty_arrays_not_included() { + let request = MarketsRequest::builder() + .id(vec![]) + .slug(vec![]) + .clob_token_ids(vec![]) + .condition_ids(vec![]) + .market_maker_address(vec![]) + .sports_market_types(vec![]) + .question_ids(vec![]) + .build(); + + let qs = request.query_params(None); + assert!(!qs.contains("id=")); + assert!(!qs.contains("slug=")); + assert!(!qs.contains("clob_token_ids=")); + assert!(!qs.contains("condition_ids=")); + assert!(!qs.contains("market_maker_address=")); + assert!(!qs.contains("sports_market_types=")); + assert!(!qs.contains("question_ids=")); + } + + #[test] + fn market_by_id_request_with_include_tag() { + let request = MarketByIdRequest::builder() + .id("42") + .include_tag(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_tag=true")); + } + + #[test] + fn market_by_slug_request_with_include_tag() { + let request = MarketBySlugRequest::builder() + .slug("my-market") + .include_tag(false) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_tag=false")); + } + + #[test] + fn market_tags_request_empty_params() { + let request = MarketTagsRequest::builder().id("456").build(); + let qs = request.query_params(None); + assert!(qs.is_empty()); + } + + #[test] + fn series_list_request_all_params() { + let request = SeriesListRequest::builder() + .limit(25) + .offset(5) + .order("title".to_owned()) + .ascending(true) + .slug(vec!["series-1".to_owned(), "series-2".to_owned()]) + .categories_ids(vec!["1".to_owned(), "2".to_owned(), "3".to_owned()]) + .categories_labels(vec!["Sports".to_owned(), "Politics".to_owned()]) + .closed(false) + .include_chat(true) + .recurrence("daily".to_owned()) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=25")); + assert!(qs.contains("offset=5")); + assert!(qs.contains("order=title")); + assert!(qs.contains("ascending=true")); + // Arrays should be repeated params, not comma-separated + assert!(qs.contains("slug=series-1")); + assert!(qs.contains("slug=series-2")); + assert!(qs.contains("categories_ids=1")); + assert!(qs.contains("categories_ids=2")); + assert!(qs.contains("categories_ids=3")); + assert!(qs.contains("categories_labels=Sports")); + assert!(qs.contains("categories_labels=Politics")); + assert!(qs.contains("closed=false")); + assert!(qs.contains("include_chat=true")); + assert!(qs.contains("recurrence=daily")); + } + + #[test] + fn series_list_request_empty_arrays_not_included() { + let request = SeriesListRequest::builder() + .slug(vec![]) + .categories_ids(vec![]) + .categories_labels(vec![]) + .build(); + + let qs = request.query_params(None); + assert!(!qs.contains("slug=")); + assert!(!qs.contains("categories_ids=")); + assert!(!qs.contains("categories_labels=")); + } + + #[test] + fn series_by_id_request_with_include_chat() { + let request = SeriesByIdRequest::builder() + .id("42") + .include_chat(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("include_chat=true")); + } + + #[test] + fn comments_request_all_params() { + let request = CommentsRequest::builder() + .limit(50) + .offset(10) + .order("createdAt".to_owned()) + .ascending(false) + .parent_entity_type(ParentEntityType::Event) + .parent_entity_id("123") + .get_positions(true) + .holders_only(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=50")); + assert!(qs.contains("offset=10")); + assert!(qs.contains("order=createdAt")); + assert!(qs.contains("ascending=false")); + assert!(qs.contains("parent_entity_type=Event")); + assert!(qs.contains("parent_entity_id=123")); + assert!(qs.contains("get_positions=true")); + assert!(qs.contains("holders_only=true")); + } + + #[test] + fn comments_request_series_entity_type() { + let request = CommentsRequest::builder() + .parent_entity_type(ParentEntityType::Series) + .parent_entity_id("series-123") + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("parent_entity_type=Series")); + assert!(qs.contains("parent_entity_id=series-123")); + } + + #[test] + fn comments_request_market_entity_type() { + let request = CommentsRequest::builder() + .parent_entity_type(ParentEntityType::Market) + .parent_entity_id("market-456") + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("parent_entity_type=market")); + assert!(qs.contains("parent_entity_id=market-456")); + } + + #[test] + fn comments_by_id_request_with_get_positions() { + let request = CommentsByIdRequest::builder() + .id("42") + .get_positions(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("get_positions=true")); + } + + #[test] + fn comments_by_user_address_request_all_params() { + let request = CommentsByUserAddressRequest::builder() + .user_address(address!("0x56687bf447db6ffa42ffe2204a05edaa20f55839")) + .limit(20) + .offset(5) + .order("createdAt".to_owned()) + .ascending(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("limit=20")); + assert!(qs.contains("offset=5")); + assert!(qs.contains("order=createdAt")); + assert!(qs.contains("ascending=true")); + } + + #[test] + fn public_profile_request_params() { + let request = PublicProfileRequest::builder() + .address(address!("0x56687bf447db6ffa42ffe2204a05edaa20f55839")) + .build(); + + let qs = request.query_params(None); + // Address serializes to lowercase hex via serde + assert!(qs.contains("address=0x56687bf447db6ffa42ffe2204a05edaa20f55839")); + } + + #[test] + fn search_request_all_params() { + let request = SearchRequest::builder() + .q("bitcoin") + .cache(true) + .events_status("active".to_owned()) + .limit_per_type(10) + .page(2) + .events_tag(vec!["crypto".to_owned(), "finance".to_owned()]) + .keep_closed_markets(5) + .sort("volume".to_owned()) + .ascending(false) + .search_tags(true) + .search_profiles(true) + .recurrence("weekly".to_owned()) + .exclude_tag_id(vec!["1".to_owned(), "2".to_owned()]) + .optimized(true) + .build(); + + let qs = request.query_params(None); + assert!(qs.contains("q=bitcoin")); + assert!(qs.contains("cache=true")); + assert!(qs.contains("events_status=active")); + assert!(qs.contains("limit_per_type=10")); + assert!(qs.contains("page=2")); + // Arrays should be repeated params, not comma-separated + assert!(qs.contains("events_tag=crypto")); + assert!(qs.contains("events_tag=finance")); + assert!(qs.contains("keep_closed_markets=5")); + assert!(qs.contains("sort=volume")); + assert!(qs.contains("ascending=false")); + assert!(qs.contains("search_tags=true")); + assert!(qs.contains("search_profiles=true")); + assert!(qs.contains("recurrence=weekly")); + assert!(qs.contains("exclude_tag_id=1")); + assert!(qs.contains("exclude_tag_id=2")); + assert!(qs.contains("optimized=true")); + } + + #[test] + fn search_request_empty_arrays_not_included() { + let request = SearchRequest::builder() + .q("test") + .events_tag(vec![]) + .exclude_tag_id(vec![]) + .build(); + + let qs = request.query_params(None); + assert!(!qs.contains("events_tag=")); + assert!(!qs.contains("exclude_tag_id=")); + } + + #[test] + fn unit_query_string_returns_empty() { + let qs = ().query_params(None); + assert!(qs.is_empty()); + } +} diff --git a/polymarket-client-sdk/tests/order.rs b/polymarket-client-sdk/tests/order.rs new file mode 100644 index 0000000..1ce6413 --- /dev/null +++ b/polymarket-client-sdk/tests/order.rs @@ -0,0 +1,3178 @@ +#![cfg(feature = "clob")] +#![allow( + clippy::unwrap_used, + reason = "Do not need additional syntax for setting up tests, and https://github.com/rust-lang/rust-clippy/issues/13981" +)] + +mod common; + +use std::str::FromStr as _; + +use alloy::primitives::U256; +use chrono::{DateTime, Utc}; +use httpmock::MockServer; +use polymarket_client_sdk::clob::types::response::OrderSummary; +use polymarket_client_sdk::clob::types::{Amount, OrderType, Side, SignatureType, TickSize}; +use polymarket_client_sdk::types::{Address, Decimal, address}; +use reqwest::StatusCode; +use rust_decimal_macros::dec; + +use crate::common::{ + USDC_DECIMALS, create_authenticated, ensure_requirements, to_decimal, token_1, token_2, +}; + +/// Tests for the lifecycle of a [`Client`] as it moves from [`Unauthenticated`] to [`Authenticated`] +mod lifecycle { + use alloy::signers::Signer as _; + use alloy::signers::local::LocalSigner; + use polymarket_client_sdk::POLYGON; + use polymarket_client_sdk::clob::{Client, Config}; + use polymarket_client_sdk::error::Validation; + use serde_json::json; + + use super::*; + use crate::common::{API_KEY, PASSPHRASE, POLY_ADDRESS, PRIVATE_KEY, SECRET}; + + #[tokio::test] + async fn order_parameters_should_reset_on_new_order() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + ensure_requirements(&server, token_2(), TickSize::Thousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + let signable_order_2 = client + .limit_order() + .token_id(token_2()) + .price(dec!(0.512)) + .size(Decimal::ONE_HUNDRED) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.nonce, U256::from(1)); + assert_eq!(signable_order_2.order.nonce, U256::ZERO); + assert_ne!(signable_order, signable_order_2); + + Ok(()) + } + + #[tokio::test] + async fn client_order_fields_should_persist_new_order() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .salt_generator(|| 1) + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + ensure_requirements(&server, token_2(), TickSize::Thousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + let signable_order_2 = client + .limit_order() + .token_id(token_2()) + .price(dec!(0.512)) + .size(Decimal::ONE_HUNDRED) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.salt, U256::from(1)); + assert_eq!(signable_order_2.order.salt, U256::from(1)); + assert_ne!(signable_order, signable_order_2); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn client_order_fields_should_reset_on_deauthenticate() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .salt_generator(|| 1) + .funder(address!("0xd1615A7B6146cDbA40a559eC876A3bcca4050890")) + .signature_type(SignatureType::GnosisSafe) + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.salt, U256::from(1)); + assert_eq!( + signable_order.order.signatureType, + SignatureType::GnosisSafe as u8 + ); + + let client = client + .deauthenticate() + .await? + .authentication_builder(&signer) + .salt_generator(|| 123) + .authenticate() + .await?; + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.salt, U256::from(123)); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + assert_eq!(signable_order.order.maker, signer.address()); + + mock.assert_calls(2); + + Ok(()) + } + + #[tokio::test] + async fn client_with_funder_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let funder = address!("0xaDEFf2158d668f64308C62ef227C5CcaCAAf976D"); + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .funder(funder) + .signature_type(SignatureType::Proxy) + .authenticate() + .await?; + + mock.assert(); + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.maker, funder); + assert_eq!( + signable_order.order.signatureType, + SignatureType::Proxy as u8 + ); + assert_eq!(signable_order.order.nonce, U256::from(1)); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_ne!(signable_order.order.maker, signable_order.order.signer); + + ensure_requirements(&server, token_2(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_2()) + .size(Decimal::TEN) + .price(dec!(0.2)) + .nonce(2) + .side(Side::Sell) + .build() + .await?; + + // Funder and signature type propagate from setting on the auth builder + assert_eq!(signable_order.order.maker, funder); + assert_eq!( + signable_order.order.signatureType, + SignatureType::Proxy as u8 + ); + assert_eq!(signable_order.order.nonce, U256::from(2)); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_ne!(signable_order.order.maker, signable_order.order.signer); + + Ok(()) + } + + #[tokio::test] + async fn client_logged_in_then_out_should_reset_funder_and_signature_type() -> anyhow::Result<()> + { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let funder = address!("0xaDEFf2158d668f64308C62ef227C5CcaCAAf976D"); + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .funder(funder) + .signature_type(SignatureType::Proxy) + .authenticate() + .await?; + + mock.assert(); + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.1)) + .nonce(1) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.maker, funder); + assert_eq!( + signable_order.order.signatureType, + SignatureType::Proxy as u8 + ); + assert_eq!(signable_order.order.nonce, U256::from(1)); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_ne!(signable_order.order.maker, signable_order.order.signer); + + ensure_requirements(&server, token_2(), TickSize::Tenth); + + client.deauthenticate().await?; + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .authenticate() + .await?; + + let signable_order = client + .limit_order() + .token_id(token_2()) + .size(Decimal::TEN) + .price(dec!(0.2)) + .nonce(2) + .side(Side::Sell) + .build() + .await?; + + // Funder and signature type propagate from setting on the auth builder + assert_eq!(signable_order.order.maker, signer.address()); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + assert_eq!(signable_order.order.nonce, U256::from(2)); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.maker, signable_order.order.signer); + + Ok(()) + } + + #[tokio::test] + async fn incompatible_funder_and_signature_types_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + let funder = address!("0xaDEFf2158d668f64308C62ef227C5CcaCAAf976D"); + let err = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .funder(funder) + .signature_type(SignatureType::Eoa) + .authenticate() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Cannot have a funder address with a Eoa signature type" + ); + + // Note: Using GnosisSafe without explicit funder now auto-derives from signer.address() + // So this case now succeeds - tested in funder_auto_derived_from_signer_for_proxy_types + + let err = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .funder(Address::ZERO) + .signature_type(SignatureType::GnosisSafe) + .authenticate() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Cannot have a zero funder address with a GnosisSafe signature type" + ); + + Ok(()) + } + + /// Tests that the funder address is automatically derived using CREATE2 from + /// the signer's EOA when using Proxy or `GnosisSafe` signature types without + /// explicit funder. + #[tokio::test] + async fn funder_auto_derived_from_signer_for_proxy_types() -> anyhow::Result<()> { + use polymarket_client_sdk::{POLYGON, derive_proxy_wallet, derive_safe_wallet}; + + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + + // Expected CREATE2-derived addresses for this signer + let expected_safe_addr = + derive_safe_wallet(signer.address(), POLYGON).expect("Safe derivation failed"); + let expected_proxy_addr = + derive_proxy_wallet(signer.address(), POLYGON).expect("Proxy derivation failed"); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + // GnosisSafe without explicit funder - should auto-derive using CREATE2 + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .signature_type(SignatureType::GnosisSafe) + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.5)) + .side(Side::Buy) + .build() + .await?; + + // Verify maker (funder) is the CREATE2-derived Safe address + assert_eq!(signable_order.order.maker, expected_safe_addr); + // Signer remains the EOA + assert_eq!(signable_order.order.signer, signer.address()); + // Maker and signer should be different for proxy types + assert_ne!(signable_order.order.maker, signable_order.order.signer); + assert_eq!( + signable_order.order.signatureType, + SignatureType::GnosisSafe as u8 + ); + + // Now test with SignatureType::Proxy + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .signature_type(SignatureType::Proxy) + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.5)) + .side(Side::Buy) + .build() + .await?; + + // Verify maker (funder) is the CREATE2-derived Proxy address + assert_eq!(signable_order.order.maker, expected_proxy_addr); + // Signer remains the EOA + assert_eq!(signable_order.order.signer, signer.address()); + // Maker and signer should be different for proxy types + assert_ne!(signable_order.order.maker, signable_order.order.signer); + assert_eq!( + signable_order.order.signatureType, + SignatureType::Proxy as u8 + ); + + Ok(()) + } + + /// Tests that explicit funder address overrides the auto-derivation. + #[tokio::test] + async fn explicit_funder_overrides_auto_derivation() -> anyhow::Result<()> { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let explicit_funder = address!("0xaDEFf2158d668f64308C62ef227C5CcaCAAf976D"); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + // GnosisSafe with explicit funder - should use the explicit one + let client = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .funder(explicit_funder) + .signature_type(SignatureType::GnosisSafe) + .authenticate() + .await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .size(Decimal::ONE_HUNDRED) + .price(dec!(0.5)) + .side(Side::Buy) + .build() + .await?; + + // Verify maker (funder) is the explicitly provided one, not auto-derived + assert_eq!(signable_order.order.maker, explicit_funder); + assert_eq!(signable_order.order.signer, signer.address()); + assert_ne!(signable_order.order.maker, signable_order.order.signer); + assert_eq!( + signable_order.order.signatureType, + SignatureType::GnosisSafe as u8 + ); + + Ok(()) + } + + #[tokio::test] + async fn signer_with_no_chain_id_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?; + + let err = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .authenticate() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Chain id not set, be sure to provide one on the signer" + ); + + Ok(()) + } + + #[tokio::test] + async fn signer_with_unsupported_chain_id_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(1)); + + let err = Client::new(&server.base_url(), Config::default())? + .authentication_builder(&signer) + .authenticate() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Only Polygon and AMOY are supported, got 1"); + + Ok(()) + } +} + +mod limit { + use polymarket_client_sdk::error::Validation; + + use super::*; + + #[tokio::test] + async fn should_fail_on_expiration_for_gtc() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .size(dec!(21.04)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Only GTD orders may have a non-zero expiration"); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_post_only_for_non_gtc_gtd() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .size(dec!(21.04)) + .side(Side::Buy) + .order_type(OrderType::FOK) + .post_only(true) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "postOnly is only supported for GTC and GTD orders"); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_missing_fields() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let err = client + .limit_order() + .token_id(token_1()) + .size(dec!(21.04)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to missing price"); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to missing size"); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_too_granular_of_a_price() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.005)) + .size(dec!(21.04)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Unable to build Order: Price 0.005 has 3 decimal places. Minimum tick size 0.01 has 2 decimal places. Price decimal places <= minimum tick size decimal places" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_negative_price_and_size() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(-0.5)) + .size(dec!(21.04)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to negative price -0.5"); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .size(dec!(-21.04)) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to negative size -21.04"); + + Ok(()) + } + + mod buy { + use super::*; + + #[tokio::test] + async fn should_succeed_0_1() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .size(dec!(21.04)) + .side(Side::Buy) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.50)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(10_520_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_01() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.56)) + .size(dec!(21.04)) + .side(Side::Buy) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.56)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(11_782_400)); + assert_eq!(signable_order.order.takerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.056)) + .size(dec!(21.04)) + .side(Side::Buy) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(1_178_240)); + assert_eq!(signable_order.order.takerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_0001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::TenThousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.0056)) + .size(dec!(21.04)) + .side(Side::Buy) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.0056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(117_824)); + assert_eq!(signable_order.order.takerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn buy_should_succeed_decimal_accuracy() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.24)) + .size(dec!(15)) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.makerAmount, U256::from(3_600_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(15_000_000)); + + Ok(()) + } + + #[tokio::test] + async fn buy_should_succeed_decimal_accuracy_2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.82)) + .size(dec!(101)) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.makerAmount, U256::from(82_820_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(101_000_000)); + + Ok(()) + } + + #[tokio::test] + async fn buy_should_fail_on_too_granular_of_lot_size() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.78)) + .size(dec!(12.8205)) + .side(Side::Buy) + .build() + .await + .unwrap_err(); + let validation_err = err.downcast_ref::().unwrap(); + + assert_eq!( + validation_err.reason, + "Unable to build Order: Size 12.8205 has 4 decimal places. Maximum lot size is 2" + ); + + Ok(()) + } + + #[tokio::test] + async fn buy_should_succeed_decimal_accuracy_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.58)) + .size(dec!(18233.33)) + .side(Side::Buy) + .build() + .await?; + + assert_eq!( + signable_order.order.makerAmount, + U256::from(10_575_331_400_u64) + ); + assert_eq!( + signable_order.order.takerAmount, + U256::from(18_233_330_000_u64) + ); + + Ok(()) + } + } + + mod sell { + use super::*; + + #[tokio::test] + async fn should_succeed_0_1() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.5)) + .size(dec!(21.04)) + .side(Side::Sell) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.50)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(10_520_000)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_01() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.56)) + .size(dec!(21.04)) + .side(Side::Sell) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.56)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(11_782_400)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.056)) + .size(dec!(21.04)) + .side(Side::Sell) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(1_178_240)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_0001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::TenThousandth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.0056)) + .size(dec!(21.04)) + .side(Side::Sell) + .order_type(OrderType::GTD) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.0056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(21_040_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(117_824)); + assert_eq!(signable_order.order.expiration, U256::from(50000)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn sell_should_succeed_decimal_accuracy() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.24)) + .size(dec!(15)) + .side(Side::Sell) + .build() + .await?; + + assert_eq!(signable_order.order.makerAmount, U256::from(15_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(3_600_000)); + + Ok(()) + } + + #[tokio::test] + async fn sell_should_succeed_decimal_accuracy_2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.82)) + .size(dec!(101)) + .side(Side::Sell) + .build() + .await?; + + assert_eq!(signable_order.order.makerAmount, U256::from(101_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(82_820_000)); + + Ok(()) + } + + #[tokio::test] + async fn sell_should_succeed_decimal_accuracy_3() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let err = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.78)) + .size(dec!(12.8205)) + .side(Side::Sell) + .build() + .await + .unwrap_err(); + + let validation_err = err.downcast_ref::().unwrap(); + + assert_eq!( + validation_err.reason, + "Unable to build Order: Size 12.8205 has 4 decimal places. Maximum lot size is 2" + ); + + Ok(()) + } + + #[tokio::test] + async fn sell_should_succeed_decimal_accuracy_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.39)) + .size(dec!(2435.89)) + .side(Side::Sell) + .build() + .await?; + + assert_eq!( + signable_order.order.makerAmount, + U256::from(2_435_890_000_u64) + ); + assert_eq!(signable_order.order.takerAmount, U256::from(949_997_100)); + + Ok(()) + } + + #[tokio::test] + async fn sell_should_succeed_decimal_accuracy_5() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.43)) + .size(dec!(19.1)) + .side(Side::Sell) + .build() + .await?; + + assert_eq!(signable_order.order.makerAmount, U256::from(19_100_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(8_213_000)); + + Ok(()) + } + } + + #[tokio::test] + async fn should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + ensure_requirements(&server, token_2(), TickSize::Hundredth); + + assert_eq!( + client.tick_size(token_1()).await?.minimum_tick_size, + TickSize::Thousandth + ); + + let signable_order = client + .limit_order() + .token_id(token_1()) + .price(dec!(0.512)) + .size(Decimal::ONE_HUNDRED) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(51_200_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + let signable_order = client + .limit_order() + .token_id(token_2()) + .price(dec!(0.78)) + .size(dec!(12.82)) + .side(Side::Buy) + .build() + .await?; + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_2()); + assert_eq!(signable_order.order.makerAmount, U256::from(9_999_600)); + assert_eq!(signable_order.order.takerAmount, U256::from(12_820_000)); + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + let _order = client + .limit_order() + .token_id(token_2()) + .order_type(OrderType::GTC) + .price(dec!(0.78)) + .size(dec!(12.82)) + .side(Side::Sell) + .build() + .await?; + + Ok(()) + } +} + +mod market { + use polymarket_client_sdk::error::Validation; + use serde_json::json; + + use super::*; + + fn ensure_requirements_for_market_price( + server: &MockServer, + token_id: U256, + bids: &[OrderSummary], + asks: &[OrderSummary], + ) { + let minimum_tick_size = TickSize::Tenth; + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/book") + .query_param("token_id", token_id.to_string()); + then.status(StatusCode::OK).json_body(json!({ + "market": "0xbd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af", + "asset_id": token_id, + "timestamp": "1000", + "bids": bids, + "asks": asks, + "min_order_size": "5", + "neg_risk": false, + "tick_size": minimum_tick_size.as_decimal(), + })); + }); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/tick-size") + .query_param("token_id", token_id.to_string()); + then.status(StatusCode::OK).json_body(json!({ + "minimum_tick_size": minimum_tick_size.as_decimal(), + })); + }); + + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/fee-rate") + .query_param("token_id", token_id.to_string()); + then.status(StatusCode::OK) + .json_body(json!({ "base_fee": 0 })); + }); + } + + mod buy { + use super::*; + + mod fok { + use polymarket_client_sdk::error::Validation; + + use super::*; + + #[tokio::test] + async fn should_fail_on_no_asks() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "No opposing orders for 15871154585880608648532107628464183779895785213830018178010423617714102767076 which means there is no market price" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_insufficient_liquidity() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Insufficient liquidity to fill order for 15871154585880608648532107628464183779895785213830018178010423617714102767076 at 100" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!( + signable_order.order.tokenId, + U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076" + )? + ); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); // 100 USDC + assert_eq!(signable_order.order.takerAmount, U256::from(200_000_000)); // 200 `token_1()` tokens + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(250_000_000)); // 250 `token_1()` tokens + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_3() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(120)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.2)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + + Ok(()) + } + } + + mod fak { + use super::*; + + #[tokio::test] + async fn should_fail_on_no_asks() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "No opposing orders for 15871154585880608648532107628464183779895785213830018178010423617714102767076 which means there is no market price" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!( + signable_order.order.tokenId, + U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076" + )? + ); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); // 100 USDC + assert_eq!(signable_order.order.takerAmount, U256::from(200_000_000)); // 200 `token_1()` tokens + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_3() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(250_000_000)); // 250 `token_1()` tokens + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(120)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_5() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 USDC + assert_eq!(taker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + + Ok(()) + } + } + + #[tokio::test] + async fn should_succeed_0_1() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + // Always gives a market price of 0.5 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build()], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(maker_amount) / to_decimal(taker_amount); + assert_eq!(price, dec!(0.50)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(200_000_000)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_01() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + // Always gives a market price of 0.56 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[OrderSummary::builder() + .price(dec!(0.56)) + .size(Decimal::ONE_HUNDRED) + .build()], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(maker_amount) / to_decimal(taker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.56)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(178_571_400)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + // Always gives a market price of 0.056 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[OrderSummary::builder() + .price(dec!(0.056)) + .size(Decimal::ONE_HUNDRED) + .build()], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(maker_amount) / to_decimal(taker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(1_785_714_280)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_0001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::TenThousandth); + // Always gives a market price of 0.0056 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[OrderSummary::builder() + .price(dec!(0.0056)) + .size(Decimal::ONE_HUNDRED) + .build()], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(maker_amount) / to_decimal(taker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.0056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!( + signable_order.order.takerAmount, + U256::from(17_857_142_857_u64) + ); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Buy as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn market_buy_with_shares_fok_should_fail_on_no_asks() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + + let msg = &err + .downcast_ref::() + .unwrap() + .reason; + assert_eq!( + msg, + "No opposing orders for 15871154585880608648532107628464183779895785213830018178010423617714102767076 which means there is no market price" + ); + Ok(()) + } + + #[tokio::test] + async fn market_buy_with_shares_fok_should_fail_on_insufficient_liquidity() + -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + // only 50 shares available on asks + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(50)) + .build()], + ); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + + let msg = &err + .downcast_ref::() + .unwrap() + .reason; + assert_eq!( + msg, + "Insufficient liquidity to fill order for 15871154585880608648532107628464183779895785213830018178010423617714102767076 at 100" + ); + Ok(()) + } + + #[tokio::test] + async fn market_buy_with_shares_should_succeed_and_encode_maker_as_usdc() + -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + // cutoff price should end at 0.4 for 250 shares + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(100)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(300)) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(250))?) + .side(Side::Buy) + .order_type(OrderType::FOK) + .build() + .await?; + + // maker = USDC, taker = shares + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); // 250 * 0.4 = 100 + assert_eq!(signable_order.order.takerAmount, U256::from(250_000_000)); + Ok(()) + } + + #[tokio::test] + async fn market_buy_with_price_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + // cutoff price should end at 0.4 for 250 shares + ensure_requirements_for_market_price( + &server, + token_1(), + &[], + &[ + OrderSummary::builder() + .price(dec!(0.5)) + .size(dec!(100)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(300)) + .build(), + ], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(250))?) + .side(Side::Buy) + .price(dec!(0.5)) + .order_type(OrderType::FOK) + .build() + .await?; + + // maker = USDC, taker = shares + assert_eq!(signable_order.order.makerAmount, U256::from(125_000_000)); // 250 * 0.5 = 125 + assert_eq!(signable_order.order.takerAmount, U256::from(250_000_000)); + Ok(()) + } + } + + mod sell { + use super::*; + + mod fok { + use super::*; + + #[tokio::test] + async fn should_fail_on_no_bids() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "No opposing orders for 15871154585880608648532107628464183779895785213830018178010423617714102767076 which means there is no market price" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_insufficient_liquidity() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::TEN) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Insufficient liquidity to fill order for 15871154585880608648532107628464183779895785213830018178010423617714102767076 at 100" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!( + signable_order.order.tokenId, + U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076" + )? + ); + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 `token_1()` tokens + assert_eq!(taker_amount, U256::from(50_000_000)); // 50 USDC + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(300)) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 `token_1()` tokens + assert_eq!(taker_amount, U256::from(40_000_000)); // 40 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_3() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(200))?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + assert_eq!(taker_amount, U256::from(80_000_000)); // 80 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(dec!(300)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(300))?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.3)); + + assert_eq!(maker_amount, U256::from(300_000_000)); // 300 `token_1()` tokens + assert_eq!(taker_amount, U256::from(90_000_000)); // 90 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_5() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(dec!(334)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(300))?) + .side(Side::Sell) + .order_type(OrderType::FOK) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.3)); + + assert_eq!(maker_amount, U256::from(300_000_000)); // 300 `token_1()` tokens + assert_eq!(taker_amount, U256::from(90_000_000)); // 90 USDC + + Ok(()) + } + } + + mod fak { + use super::*; + + #[tokio::test] + async fn should_fail_on_no_bids() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "No opposing orders for 15871154585880608648532107628464183779895785213830018178010423617714102767076 which means there is no market price" + ); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::TEN) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!( + signable_order.order.tokenId, + U256::from_str( + "15871154585880608648532107628464183779895785213830018178010423617714102767076" + )? + ); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); // 100 USDC + assert_eq!(signable_order.order.takerAmount, U256::from(40_000_000)); // 40 `token_1()` tokens + assert_eq!(signable_order.order.expiration, U256::ZERO); + assert_eq!(signable_order.order.nonce, U256::ZERO); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_2() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 `token_1()` tokens + assert_eq!(taker_amount, U256::from(50_000_000)); // 50 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_3() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(300)) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 `token_1()` tokens + assert_eq!(taker_amount, U256::from(40_000_000)); // 40 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_4() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(dec!(200)) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::TEN) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(200))?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.4)); + + assert_eq!(maker_amount, U256::from(200_000_000)); // 200 `token_1()` tokens + assert_eq!(taker_amount, U256::from(80_000_000)); // 80 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_5() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(dec!(300)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.5)); + + assert_eq!(maker_amount, U256::from(100_000_000)); // 100 `token_1()` tokens + assert_eq!(taker_amount, U256::from(50_000_000)); // 50 USDC + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_6() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price( + &server, + token_1(), + &[ + OrderSummary::builder() + .price(dec!(0.3)) + .size(dec!(334)) + .build(), + OrderSummary::builder() + .price(dec!(0.4)) + .size(Decimal::ONE_HUNDRED) + .build(), + OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build(), + ], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(dec!(300))?) + .side(Side::Sell) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.3)); + + assert_eq!(maker_amount, U256::from(300_000_000)); // 300 `token_1()` tokens + assert_eq!(taker_amount, U256::from(90_000_000)); // 90 USDC + + Ok(()) + } + } + + #[tokio::test] + async fn should_succeed_0_1() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Tenth); + // Always gives a market price of 0.5 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[OrderSummary::builder() + .price(dec!(0.5)) + .size(Decimal::ONE_HUNDRED) + .build()], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = to_decimal(taker_amount) / to_decimal(maker_amount); + assert_eq!(price, dec!(0.50)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(50_000_000)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_01() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Hundredth); + // Always gives a market price of 0.56 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[OrderSummary::builder() + .price(dec!(0.56)) + .size(Decimal::ONE_HUNDRED) + .build()], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(taker_amount) / to_decimal(maker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.56)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(56_000_000)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::Thousandth); + // Always gives a market price of 0.056 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[OrderSummary::builder() + .price(dec!(0.056)) + .size(Decimal::ONE_HUNDRED) + .build()], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(taker_amount) / to_decimal(maker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(5_600_000)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + + #[tokio::test] + async fn should_succeed_0_0001() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements(&server, token_1(), TickSize::TenThousandth); + // Always gives a market price of 0.0056 for 100 + ensure_requirements_for_market_price( + &server, + token_1(), + &[OrderSummary::builder() + .price(dec!(0.0056)) + .size(Decimal::ONE_HUNDRED) + .build()], + &[], + ); + + let signable_order = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .nonce(123) + .expiration(DateTime::::from_str("1970-01-01T13:53:20Z").unwrap()) + .build() + .await?; + + let maker_amount = signable_order.order.makerAmount; + let taker_amount = signable_order.order.takerAmount; + + let price = (to_decimal(taker_amount) / to_decimal(maker_amount)) + .trunc_with_scale(USDC_DECIMALS); + assert_eq!(price, dec!(0.0056)); + + assert_eq!(signable_order.order.maker, client.address()); + assert_eq!(signable_order.order.signer, client.address()); + assert_eq!(signable_order.order.taker, Address::ZERO); + assert_eq!(signable_order.order.tokenId, token_1()); + assert_eq!(signable_order.order.makerAmount, U256::from(100_000_000)); + assert_eq!(signable_order.order.takerAmount, U256::from(560_000)); + assert_eq!(signable_order.order.expiration, U256::from(0)); + assert_eq!(signable_order.order.nonce, U256::from(123)); + assert_eq!(signable_order.order.feeRateBps, U256::ZERO); + assert_eq!(signable_order.order.side, Side::Sell as u8); + assert_eq!(signable_order.order.signatureType, SignatureType::Eoa as u8); + + Ok(()) + } + } + + #[tokio::test] + async fn should_fail_on_missing_required_fields() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let err = client + .market_order() + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Buy) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to missing token ID"); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to missing token side"); + + let err = client + .market_order() + .token_id(token_1()) + .side(Side::Buy) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!(msg, "Unable to build Order due to missing amount"); + + Ok(()) + } + + #[tokio::test] + async fn should_fail_on_gtc() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::shares(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .order_type(OrderType::GTC) + .build() + .await + .unwrap_err(); + let msg = &err.downcast_ref::().unwrap().reason; + + assert_eq!( + msg, + "Cannot set an order type other than FAK/FOK for a market order" + ); + + Ok(()) + } + + #[tokio::test] + async fn market_sell_with_usdc_should_fail() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + ensure_requirements_for_market_price(&server, token_1(), &[], &[]); + + let err = client + .market_order() + .token_id(token_1()) + .amount(Amount::usdc(Decimal::ONE_HUNDRED)?) + .side(Side::Sell) + .build() + .await + .unwrap_err(); + let msg = &err + .downcast_ref::() + .unwrap() + .reason; + + assert_eq!(msg, "Sell Orders must specify their `amount`s in shares"); + Ok(()) + } +} diff --git a/polymarket-client-sdk/tests/rfq.rs b/polymarket-client-sdk/tests/rfq.rs new file mode 100644 index 0000000..717f7c3 --- /dev/null +++ b/polymarket-client-sdk/tests/rfq.rs @@ -0,0 +1,432 @@ +#![cfg(all(feature = "clob", feature = "rfq"))] +#![allow( + clippy::unwrap_used, + reason = "Do not need additional syntax for setting up tests" +)] + +mod common; + +use alloy::primitives::Address; +use httpmock::MockServer; +use polymarket_client_sdk::clob::types::{ + AcceptRfqQuoteRequest, ApproveRfqOrderRequest, CancelRfqQuoteRequest, CancelRfqRequestRequest, + CreateRfqQuoteRequest, CreateRfqRequestRequest, RfqQuotesRequest, RfqRequestsRequest, Side, + SignatureType, +}; +use reqwest::StatusCode; +use rust_decimal_macros::dec; +use serde_json::json; +use uuid::Uuid; + +use crate::common::{POLY_ADDRESS, create_authenticated}; + +mod request { + use std::str::FromStr as _; + + use polymarket_client_sdk::clob::types::request::Asset; + use polymarket_client_sdk::types::U256; + + use super::*; + + #[tokio::test] + async fn rfq_create_request_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/rfq/request") + .header_exists(POLY_ADDRESS) + .json_body(json!({ + "assetIn": "12345", + "assetOut": "0", + "amountIn": "50000000", + "amountOut": "3000000", + "userType": 0 + })); + then.status(StatusCode::OK).json_body(json!({ + "requestId": "0196464a-a1fa-75e6-821e-31aa0794f7ad", + "expiry": 1_744_936_318 + })); + }); + + let request = CreateRfqRequestRequest::builder() + .asset_in(Asset::Asset(U256::from_str("12345")?)) + .asset_out(Asset::Usdc) + .amount_in(dec!(50000000)) + .amount_out(dec!(3000000)) + .user_type(SignatureType::Eoa) + .build(); + + let response = client.create_request(&request).await?; + + assert_eq!(response.request_id, "0196464a-a1fa-75e6-821e-31aa0794f7ad"); + assert_eq!(response.expiry, 1_744_936_318); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_cancel_request_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::DELETE) + .path("/rfq/request") + .header_exists(POLY_ADDRESS) + .json_body(json!({ + "requestId": "0196464a-a1fa-75e6-821e-31aa0794f7ad" + })); + then.status(StatusCode::OK).body("OK"); + }); + + let request = CancelRfqRequestRequest::builder() + .request_id("0196464a-a1fa-75e6-821e-31aa0794f7ad") + .build(); + + client.cancel_request(&request).await?; + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_requests_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/rfq/data/requests") + .header_exists(POLY_ADDRESS); + then.status(StatusCode::OK).json_body(json!({ + "data": [{ + "requestId": "01968f1e-1182-71c4-9d40-172db9be82af", + "userAddress": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5", + "proxyAddress": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5", + "condition": "0x37a6a2dd9f3469495d9ec2467b0a764c5905371a294ce544bc3b2c944eb3e84a", + "token": "34097058504275310827233323421517291090691602969494795225921954353603704046623", + "complement": "32868290514114487320702931554221558599637733115139769311383916145370132125101", + "side": "BUY", + "sizeIn": 100, + "sizeOut": 50, + "price": 0.5, + "expiry": 1_746_159_634 + }], + "next_cursor": "LTE=", + "limit": 100, + "count": 1 + })); + }); + + let request = RfqRequestsRequest::default(); + let response = client.requests(&request, None).await?; + + assert_eq!(response.count, 1); + assert_eq!(response.data.len(), 1); + assert_eq!( + response.data[0].request_id, + "01968f1e-1182-71c4-9d40-172db9be82af" + ); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_requests_with_cursor_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/rfq/data/requests") + .query_param("next_cursor", "abc123") + .header_exists(POLY_ADDRESS); + then.status(StatusCode::OK).json_body(json!({ + "data": [], + "next_cursor": "", + "limit": 100, + "count": 0 + })); + }); + + let request = RfqRequestsRequest::default(); + let response = client.requests(&request, Some("abc123")).await?; + + assert_eq!(response.count, 0); + mock.assert(); + + Ok(()) + } +} + +mod quote { + use std::str::FromStr as _; + + use polymarket_client_sdk::clob::types::request::Asset; + use polymarket_client_sdk::types::U256; + + use super::*; + + #[tokio::test] + async fn rfq_create_quote_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/rfq/quote") + .header_exists(POLY_ADDRESS) + .json_body(json!({ + "requestId": "01968f1e-1182-71c4-9d40-172db9be82af", + "assetIn": "0", + "assetOut": "12345", + "amountIn": "3000000", + "amountOut": "50000000", + "userType": 0 + })); + then.status(StatusCode::OK).json_body(json!({ + "quoteId": "0196f484-9fbd-74c1-bfc1-75ac21c1cf84" + })); + }); + + let request = CreateRfqQuoteRequest::builder() + .request_id("01968f1e-1182-71c4-9d40-172db9be82af") + .asset_in(Asset::Usdc) + .asset_out(Asset::Asset(U256::from_str("12345")?)) + .amount_in(dec!(3000000)) + .amount_out(dec!(50000000)) + .user_type(SignatureType::Eoa) + .build(); + + let response = client.create_quote(&request).await?; + + assert_eq!(response.quote_id, "0196f484-9fbd-74c1-bfc1-75ac21c1cf84"); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_cancel_quote_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::DELETE) + .path("/rfq/quote") + .header_exists(POLY_ADDRESS) + .json_body(json!({ + "quoteId": "0196f484-9fbd-74c1-bfc1-75ac21c1cf84" + })); + then.status(StatusCode::OK).body("OK"); + }); + + let request = CancelRfqQuoteRequest::builder() + .quote_id("0196f484-9fbd-74c1-bfc1-75ac21c1cf84") + .build(); + + client.cancel_quote(&request).await?; + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_quotes_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/rfq/data/quotes") + .header_exists(POLY_ADDRESS); + then.status(StatusCode::OK).json_body(json!({ + "data": [{ + "quoteId": "0196f484-9fbd-74c1-bfc1-75ac21c1cf84", + "requestId": "01968f1e-1182-71c4-9d40-172db9be82af", + "userAddress": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5", + "proxyAddress": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5", + "condition": "0x37a6a2dd9f3469495d9ec2467b0a764c5905371a294ce544bc3b2c944eb3e84a", + "token": "34097058504275310827233323421517291090691602969494795225921954353603704046623", + "complement": "32868290514114487320702931554221558599637733115139769311383916145370132125101", + "side": "BUY", + "sizeIn": 100, + "sizeOut": 50, + "price": 0.5 + }], + "next_cursor": "LTE=", + "limit": 100, + "count": 1 + })); + }); + + let request = RfqQuotesRequest::default(); + let response = client.quotes(&request, None).await?; + + assert_eq!(response.count, 1); + assert_eq!(response.data.len(), 1); + assert_eq!( + response.data[0].quote_id, + "0196f484-9fbd-74c1-bfc1-75ac21c1cf84" + ); + mock.assert(); + + Ok(()) + } +} + +mod execution { + use super::*; + use crate::common::token_1; + + #[tokio::test] + async fn rfq_accept_quote_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let maker: Address = "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5".parse()?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/rfq/request/accept") + .header_exists(POLY_ADDRESS); + then.status(StatusCode::OK).body("OK"); + }); + + let request = AcceptRfqQuoteRequest::builder() + .request_id("01968f1e-1182-71c4-9d40-172db9be82af") + .quote_id("0196f484-9fbd-74c1-bfc1-75ac21c1cf84") + .maker_amount(dec!(50000000)) + .taker_amount(dec!(3000000)) + .token_id(token_1()) + .maker(maker) + .signer(maker) + .taker(Address::ZERO) + .nonce(0) + .expiration(0) + .side(Side::Buy) + .fee_rate_bps(0) + .signature("0x1234") + .salt("123") + .owner(Uuid::nil()) + .build(); + + client.accept_quote(&request).await?; + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_approve_order_should_succeed() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let maker: Address = "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5".parse()?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/rfq/quote/approve") + .header_exists(POLY_ADDRESS); + then.status(StatusCode::OK).json_body(json!({ + "tradeIds": ["019af0f7-eb77-764f-b40f-6de8a3562e12"] + })); + }); + + let request = ApproveRfqOrderRequest::builder() + .request_id("01968f1e-1182-71c4-9d40-172db9be82af") + .quote_id("0196f484-9fbd-74c1-bfc1-75ac21c1cf84") + .maker_amount(dec!(50000000)) + .taker_amount(dec!(3000000)) + .token_id(token_1()) + .maker(maker) + .signer(maker) + .taker(Address::ZERO) + .nonce(0) + .expiration(0) + .side(Side::Buy) + .fee_rate_bps(0) + .signature("0x1234") + .salt("123") + .owner(Uuid::nil()) + .build(); + + let response = client.approve_order(&request).await?; + + assert_eq!(response.trade_ids.len(), 1); + assert_eq!( + response.trade_ids[0], + "019af0f7-eb77-764f-b40f-6de8a3562e12" + ); + mock.assert(); + + Ok(()) + } +} + +mod error_handling { + use std::str::FromStr as _; + + use polymarket_client_sdk::clob::types::request::Asset; + use polymarket_client_sdk::error::Kind; + use polymarket_client_sdk::types::U256; + + use super::*; + + #[tokio::test] + async fn rfq_create_request_error_should_return_status() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/rfq/request"); + then.status(StatusCode::BAD_REQUEST) + .body("Invalid request parameters"); + }); + + let request = CreateRfqRequestRequest::builder() + .asset_in(Asset::Asset(U256::from_str("12345")?)) + .asset_out(Asset::Usdc) + .amount_in(dec!(50000000)) + .amount_out(dec!(3000000)) + .user_type(SignatureType::Eoa) + .build(); + + let result = client.create_request(&request).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), Kind::Status); + mock.assert(); + + Ok(()) + } + + #[tokio::test] + async fn rfq_cancel_request_error_should_return_status() -> anyhow::Result<()> { + let server = MockServer::start(); + let client = create_authenticated(&server).await?; + + let mock = server.mock(|when, then| { + when.method(httpmock::Method::DELETE).path("/rfq/request"); + then.status(StatusCode::NOT_FOUND).body("Request not found"); + }); + + let request = CancelRfqRequestRequest::builder() + .request_id("nonexistent") + .build(); + + let result = client.cancel_request(&request).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), Kind::Status); + mock.assert(); + + Ok(()) + } +} diff --git a/polymarket-client-sdk/tests/websocket.rs b/polymarket-client-sdk/tests/websocket.rs new file mode 100644 index 0000000..404ac15 --- /dev/null +++ b/polymarket-client-sdk/tests/websocket.rs @@ -0,0 +1,1769 @@ +#![cfg(all(feature = "clob", feature = "ws"))] +#![allow( + clippy::unwrap_used, + clippy::missing_panics_doc, + reason = "Do not need additional syntax for setting up tests" +)] + +mod common; + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt as _, StreamExt as _}; +use polymarket_client_sdk::clob::ws::{Client, WsMessage}; +use polymarket_client_sdk::types::{Address, U256, b256}; +use polymarket_client_sdk::ws::config::Config; +use serde_json::json; +use tokio::net::TcpListener; +use tokio::sync::{broadcast, mpsc}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; + +/// Mock WebSocket server. +struct MockWsServer { + addr: SocketAddr, + /// Broadcast messages to ALL connected clients + message_tx: broadcast::Sender, + /// Receives subscription requests from clients + subscription_rx: mpsc::UnboundedReceiver, +} + +impl MockWsServer { + /// Start a mock WebSocket server on a random port. + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Broadcast channel for sending to ALL clients + let (message_tx, _) = broadcast::channel::(100); + let (subscription_tx, subscription_rx) = mpsc::unbounded_channel::(); + + let broadcast_tx = message_tx.clone(); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + + let Ok(ws_stream) = tokio_tungstenite::accept_async(stream).await else { + continue; + }; + + let (mut write, mut read) = ws_stream.split(); + let sub_tx = subscription_tx.clone(); + let mut msg_rx = broadcast_tx.subscribe(); + + // Spawn a task to handle this connection + tokio::spawn(async move { + loop { + tokio::select! { + // Handle incoming messages from client + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) if text != "PING" => { + drop(sub_tx.send(text.to_string())); + } + Some(Ok(_)) => {} + _ => break, + } + } + // Handle outgoing messages to client + msg = msg_rx.recv() => { + match msg { + Ok(text) => { + if write.send(Message::Text(text.into())).await.is_err() { + break; + } + } + Err(_) => break, + } + } + } + } + }); + } + }); + + Self { + addr, + message_tx, + subscription_rx, + } + } + + fn ws_url(&self, path: &str) -> String { + format!("ws://{}{}", self.addr, path) + } + + /// Send a message to all connected clients. + fn send(&self, message: &str) { + drop(self.message_tx.send(message.to_owned())); + } + + /// Receive the next subscription request. + async fn recv_subscription(&mut self) -> Option { + timeout(Duration::from_secs(2), self.subscription_rx.recv()) + .await + .ok() + .flatten() + } +} + +/// Example payloads from CLOB documentation. +/// +/// +pub mod payloads { + use std::str::FromStr as _; + + use polymarket_client_sdk::types::{B256, U256, b256}; + use serde_json::{Value, json}; + + pub const ASSET_ID_STR: &str = + "65818619657568813474341868652308942079804919287380422192892211131408793125422"; + + pub const OTHER_ASSET_ID_STR: &str = + "99999999999999999999999999999999999999999999999999999999999999999"; + pub const MARKET_STR: &str = + "0xbd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af"; + pub const MARKET: B256 = + b256!("bd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af"); + + #[must_use] + pub fn asset_id() -> U256 { + U256::from_str(ASSET_ID_STR).unwrap() + } + + #[must_use] + pub fn other_asset_id() -> U256 { + U256::from_str(OTHER_ASSET_ID_STR).unwrap() + } + + #[must_use] + pub fn book() -> Value { + json!({ + "event_type": "book", + "asset_id": ASSET_ID_STR, + "market": MARKET_STR, + "bids": [ + { "price": ".48", "size": "30" }, + { "price": ".49", "size": "20" }, + { "price": ".50", "size": "15" } + ], + "asks": [ + { "price": ".52", "size": "25" }, + { "price": ".53", "size": "60" }, + { "price": ".54", "size": "10" } + ], + "timestamp": "123456789000", + "hash": "0x1234567890abcdef" + }) + } + + #[must_use] + pub fn price_change_batch(asset_id: U256) -> Value { + json!({ + "market": "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1", + "price_changes": [ + { + "asset_id": asset_id.to_string(), + "price": "0.5", + "size": "200", + "side": "BUY", + "hash": "56621a121a47ed9333273e21c83b660cff37ae50", + "best_bid": "0.5", + "best_ask": "1" + } + ], + "timestamp": "1757908892351", + "event_type": "price_change" + }) + } + + #[must_use] + pub fn tick_size_change() -> Value { + json!({ + "event_type": "tick_size_change", + "asset_id": ASSET_ID_STR, + "market": MARKET_STR, + "old_tick_size": "0.01", + "new_tick_size": "0.001", + "timestamp": "100000000" + }) + } + + #[must_use] + pub fn last_trade_price(asset_id: &str) -> Value { + json!({ + "asset_id": asset_id, + "event_type": "last_trade_price", + "fee_rate_bps": "0", + "market": "0x6a67b9d828d53862160e470329ffea5246f338ecfffdf2cab45211ec578b0347", + "price": "0.456", + "side": "BUY", + "size": "219.217767", + "timestamp": "1750428146322" + }) + } + + #[must_use] + pub fn trade() -> Value { + json!({ + "asset_id": "52114319501245915516055106046884209969926127482827954674443846427813813222426", + "event_type": "trade", + "id": "28c4d2eb-bbea-40e7-a9f0-b2fdb56b2c2e", + "last_update": "1672290701", + "maker_orders": [ + { + "asset_id": "52114319501245915516055106046884209969926127482827954674443846427813813222426", + "matched_amount": "10", + "order_id": "0xff354cd7ca7539dfa9c28d90943ab5779a4eac34b9b37a757d7b32bdfb11790b", + "outcome": "YES", + "owner": "9180014b-33c8-9240-a14b-bdca11c0a465", + "price": "0.57" + } + ], + "market": MARKET_STR, + "matchtime": "1672290701", + "outcome": "YES", + "owner": "9180014b-33c8-9240-a14b-bdca11c0a465", + "price": "0.57", + "side": "BUY", + "size": "10", + "status": "MATCHED", + "taker_order_id": "0x06bc63e346ed4ceddce9efd6b3af37c8f8f440c92fe7da6b2d0f9e4ccbc50c42", + "timestamp": "1672290701", + "trade_owner": "9180014b-33c8-9240-a14b-bdca11c0a465", + "type": "TRADE" + }) + } + + #[must_use] + pub fn order() -> Value { + json!({ + "asset_id": "52114319501245915516055106046884209969926127482827954674443846427813813222426", + "associate_trades": null, + "event_type": "order", + "id": "0xff354cd7ca7539dfa9c28d90943ab5779a4eac34b9b37a757d7b32bdfb11790b", + "market": MARKET_STR, + "order_owner": "9180014b-33c8-9240-a14b-bdca11c0a465", + "original_size": "10", + "outcome": "YES", + "owner": "9180014b-33c8-9240-a14b-bdca11c0a465", + "price": "0.57", + "side": "SELL", + "size_matched": "0", + "status": "LIVE", + "timestamp": "1672290687", + "type": "PLACEMENT" + }) + } +} + +mod market_channel { + use std::str::FromStr as _; + + use rust_decimal_macros::dec; + + use super::*; + use crate::payloads::OTHER_ASSET_ID_STR; + + #[tokio::test] + async fn subscribe_orderbook_receives_book_updates() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let stream = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription request was sent + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"type\":\"market\"")); + assert!(sub_request.contains(&payloads::asset_id().to_string())); + + // Send book update from docs + server.send(&payloads::book().to_string()); + + // Receive and verify + let result = timeout(Duration::from_secs(2), stream.next()).await; + let book = result.unwrap().unwrap().unwrap(); + + assert_eq!(book.asset_id, payloads::asset_id()); + assert_eq!(book.market, payloads::MARKET); + assert_eq!(book.bids.len(), 3); + assert_eq!(book.asks.len(), 3); + assert_eq!(book.bids[0].price, dec!(0.48)); + assert_eq!(book.bids[0].size, dec!(30)); + assert_eq!(book.asks[0].price, dec!(0.52)); + assert_eq!(book.hash, Some("0x1234567890abcdef".to_owned())); + } + + #[tokio::test] + async fn subscribe_prices_receives_price_changes() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let asset_id = U256::from_str( + "71321045679252212594626385532706912750332728571942532289631379312455583992563", + ) + .unwrap(); + let stream = client.subscribe_prices(vec![asset_id]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + server.send(&payloads::price_change_batch(asset_id).to_string()); + + // Receive and verify + let result = timeout(Duration::from_secs(2), stream.next()).await; + let price = result.unwrap().unwrap().unwrap(); + + assert_eq!(price.price_changes[0].asset_id, asset_id); + assert_eq!(price.price_changes[0].price, dec!(0.5)); + assert_eq!(price.price_changes[0].size, Some(dec!(200))); + assert_eq!(price.price_changes[0].best_bid, Some(dec!(0.5))); + assert_eq!(price.price_changes[0].best_ask, Some(dec!(1))); + } + + #[tokio::test] + async fn subscribe_tick_size_change_receives_updates() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let stream = client + .subscribe_tick_size_change(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription request was sent + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"type\":\"market\"")); + assert!(sub_request.contains(&payloads::asset_id().to_string())); + + // Send tick size change event + server.send(&payloads::tick_size_change().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let tsc = result.unwrap().unwrap().unwrap(); + + assert_eq!(tsc.asset_id, payloads::asset_id()); + assert_eq!(tsc.market, payloads::MARKET); + assert_eq!(tsc.old_tick_size, dec!(0.01)); + assert_eq!(tsc.new_tick_size, dec!(0.001)); + assert_eq!(tsc.timestamp, 100_000_000); + } + + #[tokio::test] + async fn filters_messages_by_asset_id() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let subscribed_asset = payloads::asset_id(); + + let stream = client.subscribe_orderbook(vec![subscribed_asset]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send message for non-subscribed asset (should be filtered) + let mut other_book = payloads::book(); + other_book["asset_id"] = serde_json::Value::String(OTHER_ASSET_ID_STR.to_owned()); + server.send(&other_book.to_string()); + + // Send message for subscribed asset + server.send(&payloads::book().to_string()); + + // Should receive only the subscribed asset's message + let result = timeout(Duration::from_secs(2), stream.next()).await; + let book = result.unwrap().unwrap().unwrap(); + assert_eq!(book.asset_id, subscribed_asset); + } + + #[tokio::test] + async fn subscribe_midpoints_calculates_midpoint() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let stream = client + .subscribe_midpoints(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send book with bids at 0.48, 0.49, 0.50 and asks at 0.52, 0.53, 0.54 + // Best bid = 0.48, best ask = 0.52 (from payloads::book()) + // Midpoint = (0.48 + 0.52) / 2 = 0.50 + server.send(&payloads::book().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let midpoint = result.unwrap().unwrap().unwrap(); + + assert_eq!(midpoint.asset_id, payloads::asset_id()); + assert_eq!(midpoint.market, payloads::MARKET); + assert_eq!(midpoint.midpoint, dec!(0.50)); + } + + #[tokio::test] + async fn subscribe_midpoints_skips_empty_orderbook() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let stream = client + .subscribe_midpoints(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send book with no bids (should be skipped) + let empty_book = json!({ + "event_type": "book", + "asset_id": payloads::asset_id(), + "market": payloads::MARKET_STR, + "bids": [], + "asks": [{ "price": ".52", "size": "25" }], + "timestamp": "123456789000" + }); + server.send(&empty_book.to_string()); + + // Send valid book + server.send(&payloads::book().to_string()); + + // Should only receive the valid midpoint (empty book skipped) + let result = timeout(Duration::from_secs(2), stream.next()).await; + let midpoint = result.unwrap().unwrap().unwrap(); + assert_eq!(midpoint.midpoint, dec!(0.50)); + } +} + +mod user_channel { + use polymarket_client_sdk::auth::Credentials; + use polymarket_client_sdk::clob::types::Side; + use polymarket_client_sdk::clob::ws::types::response::{OrderMessageType, TradeMessageStatus}; + use rust_decimal_macros::dec; + use tokio::time::sleep; + + use super::*; + use crate::common::{API_KEY, PASSPHRASE, SECRET}; + use crate::payloads::OTHER_ASSET_ID_STR; + + fn test_credentials() -> Credentials { + Credentials::new(API_KEY, SECRET.to_owned(), PASSPHRASE.to_owned()) + } + + #[tokio::test] + async fn subscribe_user_events_receives_orders() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connections to establish + sleep(Duration::from_millis(100)).await; + + let stream = client.subscribe_user_events(vec![]).unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription request contains auth + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"type\":\"user\"")); + assert!(sub_request.contains("\"auth\"")); + assert!(sub_request.contains("\"apiKey\"")); + + // Send order message from docs + server.send(&payloads::order().to_string()); + + // Receive and verify + let result = timeout(Duration::from_secs(2), stream.next()).await; + match result.unwrap().unwrap().unwrap() { + WsMessage::Order(order) => { + assert_eq!( + order.id, + "0xff354cd7ca7539dfa9c28d90943ab5779a4eac34b9b37a757d7b32bdfb11790b" + ); + assert_eq!(order.market, payloads::MARKET); + assert_eq!(order.price, dec!(0.57)); + assert_eq!(order.side, Side::Sell); + assert_eq!(order.original_size, Some(dec!(10))); + assert_eq!(order.size_matched, Some(dec!(0))); + assert_eq!(order.outcome, Some("YES".to_owned())); + assert_eq!(order.msg_type, Some(OrderMessageType::Placement)); + } + other => panic!("Expected Order, got {other:?}"), + } + } + + #[tokio::test] + async fn subscribe_user_events_receives_trades() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connections to establish + sleep(Duration::from_millis(100)).await; + + let stream = client.subscribe_user_events(vec![]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send trade message from docs + server.send(&payloads::trade().to_string()); + + // Receive and verify + let result = timeout(Duration::from_secs(2), stream.next()).await; + match result.unwrap().unwrap().unwrap() { + WsMessage::Trade(trade) => { + assert_eq!(trade.id, "28c4d2eb-bbea-40e7-a9f0-b2fdb56b2c2e"); + assert_eq!(trade.market, payloads::MARKET); + assert_eq!(trade.price, dec!(0.57)); + assert_eq!(trade.size, dec!(10)); + assert_eq!(trade.side, Side::Buy); + assert_eq!(trade.status, TradeMessageStatus::Matched); + assert_eq!(trade.outcome, Some("YES".to_owned())); + assert_eq!(trade.maker_orders.len(), 1); + assert_eq!(trade.maker_orders[0].matched_amount, dec!(10)); + assert_eq!(trade.maker_orders[0].price, dec!(0.57)); + assert_eq!( + trade.taker_order_id, + Some( + "0x06bc63e346ed4ceddce9efd6b3af37c8f8f440c92fe7da6b2d0f9e4ccbc50c42" + .to_owned() + ) + ); + } + other => panic!("Expected Trade, got {other:?}"), + } + } + + #[tokio::test] + async fn subscribe_orders_filters_to_orders_only() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connections to establish + sleep(Duration::from_millis(100)).await; + + let stream = client.subscribe_orders(vec![]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send a trade (should be filtered) + server.send(&payloads::trade().to_string()); + + // Send an order + server.send(&payloads::order().to_string()); + + // Should only receive the order + let result = timeout(Duration::from_secs(2), stream.next()).await; + let order = result.unwrap().unwrap().unwrap(); + assert_eq!( + order.id, + "0xff354cd7ca7539dfa9c28d90943ab5779a4eac34b9b37a757d7b32bdfb11790b" + ); + } + + #[tokio::test] + async fn subscribe_trades_filters_to_trades_only() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connections to establish + sleep(Duration::from_millis(100)).await; + + let stream = client.subscribe_trades(vec![]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send an order (should be filtered) + server.send(&payloads::order().to_string()); + + // Send a trade + server.send(&payloads::trade().to_string()); + + // Should only receive the trade + let result = timeout(Duration::from_secs(2), stream.next()).await; + let trade = result.unwrap().unwrap().unwrap(); + assert_eq!(trade.id, "28c4d2eb-bbea-40e7-a9f0-b2fdb56b2c2e"); + } + + #[tokio::test] + async fn multiplexing_does_not_send_duplicate_subscription() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // First subscription - should send request + let _stream1 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let sub1 = server.recv_subscription().await.unwrap(); + assert!(sub1.contains(&asset_id.to_string())); + + // Second subscription to SAME asset - should NOT send request (multiplexed) + let _stream2 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + + // Third subscription to DIFFERENT asset - should send request + let _stream3 = client + .subscribe_orderbook(vec![payloads::other_asset_id()]) + .unwrap(); + + // The next message we receive should be for other_asset only + let sub2 = server.recv_subscription().await.unwrap(); + assert!( + sub2.contains(OTHER_ASSET_ID_STR), + "Should receive subscription for new asset" + ); + assert!( + !sub2.contains(&asset_id.to_string()), + "Should NOT contain duplicate of already-subscribed asset" + ); + } + + #[tokio::test] + async fn unsubscribe_user_events_sends_request() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connections to establish + sleep(Duration::from_millis(100)).await; + + let market = payloads::MARKET; + + // Subscribe to user events for a specific market + let _stream = client.subscribe_user_events(vec![market]).unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe from user events + client.unsubscribe_user_events(&[market]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!( + unsub.contains("\"operation\":\"unsubscribe\""), + "Should send unsubscribe request, got: {unsub}" + ); + assert!(unsub.contains(&market.to_string())); + } + + #[tokio::test] + async fn deauthenticate_returns_to_unauthenticated_state() { + let mut server = MockWsServer::start().await; + let base_endpoint = format!("ws://{}", server.addr); + + let config = Config::default(); + let client = Client::new(&base_endpoint, config) + .unwrap() + .authenticate(test_credentials(), Address::ZERO) + .unwrap(); + + // Wait for connection to establish + sleep(Duration::from_millis(100)).await; + + // Deauthenticate should succeed and return unauthenticated client + let unauth_client = client.deauthenticate().unwrap(); + + // Should still be able to subscribe to market data + let stream = unauth_client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + server.send(&payloads::book().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + result.unwrap().unwrap().unwrap(); + } +} + +mod reconnection { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + /// Mock WebSocket server that can simulate disconnections and send messages. + struct ReconnectableMockServer { + addr: SocketAddr, + subscription_rx: mpsc::UnboundedReceiver, + message_tx: broadcast::Sender, + disconnect_signal: Arc, + } + + impl ReconnectableMockServer { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (message_tx, _) = broadcast::channel::(100); + let (subscription_tx, subscription_rx) = mpsc::unbounded_channel::(); + let disconnect_signal = Arc::new(AtomicBool::new(false)); + + let broadcast_tx = message_tx.clone(); + let disconnect = Arc::clone(&disconnect_signal); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + + let Ok(ws_stream) = tokio_tungstenite::accept_async(stream).await else { + continue; + }; + + let (mut write, mut read) = ws_stream.split(); + let sub_tx = subscription_tx.clone(); + let mut msg_rx = broadcast_tx.subscribe(); + let disconnect_clone = Arc::clone(&disconnect); + + tokio::spawn(async move { + loop { + if disconnect_clone.load(Ordering::SeqCst) { + break; + } + + tokio::select! { + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) if text != "PING" => { + drop(sub_tx.send(text.to_string())); + } + Some(Ok(_)) => {} + _ => break, + } + } + msg = msg_rx.recv() => { + match msg { + Ok(text) => { + if write.send(Message::Text(text.into())).await.is_err() { + break; + } + } + Err(_) => break, + } + } + () = tokio::time::sleep(Duration::from_millis(50)) => { + if disconnect_clone.load(Ordering::SeqCst) { + break; + } + } + } + } + }); + } + }); + + Self { + addr, + subscription_rx, + message_tx, + disconnect_signal, + } + } + + fn ws_url(&self, path: &str) -> String { + format!("ws://{}{}", self.addr, path) + } + + fn disconnect_all(&self) { + self.disconnect_signal.store(true, Ordering::SeqCst); + } + + fn allow_reconnect(&self) { + self.disconnect_signal.store(false, Ordering::SeqCst); + } + + fn send(&self, message: &str) { + drop(self.message_tx.send(message.to_owned())); + } + + async fn recv_subscription(&mut self) -> Option { + timeout(Duration::from_secs(2), self.subscription_rx.recv()) + .await + .ok() + .flatten() + } + } + + fn config() -> Config { + let mut config = Config::default(); + config.reconnect.max_attempts = Some(5); + config.reconnect.initial_backoff = Duration::from_millis(50); + config.reconnect.max_backoff = Duration::from_millis(200); + config + } + + #[tokio::test] + async fn resubscribes_and_receives_messages_after_reconnect() { + let mut server = ReconnectableMockServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, config()).unwrap(); + + let asset_id = payloads::asset_id(); + let stream = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let mut stream = Box::pin(stream); + + // Verify initial subscription + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains(&asset_id.to_string())); + + // Verify we can receive messages before disconnect + server.send(&payloads::book().to_string()); + let msg1 = timeout(Duration::from_secs(2), stream.next()).await; + assert!(msg1.is_ok(), "Should receive message before disconnect"); + + // Simulate disconnect + server.disconnect_all(); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Allow reconnection and wait for re-subscription + server.allow_reconnect(); + + // Wait for re-subscription request (proves reconnection happened) + let resub = server.recv_subscription().await; + assert!( + resub.is_some(), + "Should receive re-subscription after reconnect" + ); + assert!(resub.unwrap().contains(&asset_id.to_string())); + + // Send message after reconnection and verify it's received + server.send(&payloads::book().to_string()); + let msg2 = timeout(Duration::from_secs(2), stream.next()).await; + assert!( + msg2.is_ok(), + "Should receive message after reconnection - proves subscription is active" + ); + } + + #[tokio::test] + async fn resubscribes_all_assets_after_reconnect() { + let mut server = ReconnectableMockServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, config()).unwrap(); + + let asset1 = payloads::asset_id(); + let asset2 = payloads::other_asset_id(); + + // Subscribe to both assets + let _stream1 = client.subscribe_orderbook(vec![asset1]).unwrap(); + let _: Option = server.recv_subscription().await; + + let _stream2 = client.subscribe_orderbook(vec![asset2]).unwrap(); + let sub2 = server.recv_subscription().await.unwrap(); + assert!(sub2.contains(&asset2.to_string())); + + // Disconnect and reconnect + server.disconnect_all(); + tokio::time::sleep(Duration::from_millis(100)).await; + server.allow_reconnect(); + + // Verify re-subscription contains BOTH assets + let resub = server.recv_subscription().await; + assert!(resub.is_some(), "Should receive re-subscription"); + let resub_str = resub.unwrap(); + assert!( + resub_str.contains(&asset1.to_string()) && resub_str.contains(&asset2.to_string()), + "Re-subscription should contain all tracked assets, got: {resub_str}" + ); + } + + #[tokio::test] + async fn preserves_custom_features_after_reconnect() { + let mut server = ReconnectableMockServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, config()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe with custom features enabled (e.g., best_bid_ask) + let _stream = client.subscribe_best_bid_ask(vec![asset_id]).unwrap(); + + // Verify initial subscription has custom_feature_enabled + let sub_request = server.recv_subscription().await.unwrap(); + assert!( + sub_request.contains("\"custom_feature_enabled\":true"), + "Initial subscription should have custom_feature_enabled, got: {sub_request}" + ); + + // Disconnect and reconnect + server.disconnect_all(); + tokio::time::sleep(Duration::from_millis(100)).await; + server.allow_reconnect(); + + // Verify re-subscription ALSO has custom_feature_enabled + let resub = server.recv_subscription().await; + assert!(resub.is_some(), "Should receive re-subscription"); + let resub_str = resub.unwrap(); + assert!( + resub_str.contains("\"custom_feature_enabled\":true"), + "Re-subscription should preserve custom_feature_enabled, got: {resub_str}" + ); + } + + /// Test that mirrors the exact usage pattern from GitHub issue #185. + /// + #[tokio::test] + async fn best_bid_ask_stream_continues_after_reconnect() { + let mut server = ReconnectableMockServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, config()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Exact pattern from issue #185: + // let stream = client.subscribe_best_bid_ask(asset_ids)?; + // let mut stream = Box::pin(stream); + let stream = client.subscribe_best_bid_ask(vec![asset_id]).unwrap(); + let mut stream = Box::pin(stream); + + // Consume initial subscription request + let _: Option = server.recv_subscription().await; + + // Send best_bid_ask message before disconnect + let best_bid_ask_msg = serde_json::json!({ + "event_type": "best_bid_ask", + "market": payloads::MARKET_STR, + "asset_id": asset_id.to_string(), + "best_bid": "0.48", + "best_ask": "0.52", + "spread": "0.04", + "timestamp": "1234567890000" + }); + server.send(&best_bid_ask_msg.to_string()); + + // Verify we receive message before disconnect (mirrors the issue's loop pattern) + let msg1 = timeout(Duration::from_secs(2), stream.next()).await; + assert!( + msg1.is_ok() && msg1.unwrap().is_some(), + "Should receive best_bid_ask message before disconnect" + ); + + // Simulate disconnect (what the user experienced) + server.disconnect_all(); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Allow reconnection + server.allow_reconnect(); + + // Wait for re-subscription (proves reconnection happened) + let resub = server.recv_subscription().await; + assert!( + resub.is_some(), + "Should receive re-subscription after reconnect" + ); + assert!( + resub.unwrap().contains("\"custom_feature_enabled\":true"), + "Re-subscription must include custom_feature_enabled for best_bid_ask to work" + ); + + // Send best_bid_ask message AFTER reconnection + let best_bid_ask_msg2 = serde_json::json!({ + "event_type": "best_bid_ask", + "market": payloads::MARKET_STR, + "asset_id": asset_id.to_string(), + "best_bid": "0.50", + "best_ask": "0.54", + "spread": "0.04", + "timestamp": "1234567891000" + }); + server.send(&best_bid_ask_msg2.to_string()); + + // THE FIX: This should now work - stream should receive message after reconnection + // Before the fix, this would hang forever because the server wasn't sending + // best_bid_ask messages (custom_feature_enabled was not included in re-subscription) + let msg2 = timeout(Duration::from_secs(2), stream.next()).await; + assert!( + msg2.is_ok() && msg2.unwrap().is_some(), + "Should receive best_bid_ask message after reconnection - this was the bug in issue #185" + ); + } +} + +mod unsubscribe { + use super::*; + use crate::payloads::OTHER_ASSET_ID_STR; + + #[tokio::test] + async fn unsubscribe_sends_request_when_refcount_reaches_zero() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe once + let _stream = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let sub = server.recv_subscription().await.unwrap(); + assert!(sub.contains(&asset_id.to_string())); + + // Unsubscribe - should send unsubscribe request since refcount goes to 0 + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!( + unsub.contains("\"operation\":\"unsubscribe\""), + "Should send unsubscribe request, got: {unsub}" + ); + assert!(unsub.contains(&asset_id.to_string())); + } + + #[tokio::test] + async fn unsubscribe_does_not_send_request_when_refcount_above_zero() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe twice to same asset + let _stream1 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + let _stream2 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + // Second subscribe should not send (multiplexed) + + // Unsubscribe once - refcount goes from 2 to 1, should NOT send request + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + + // Subscribe to different asset to verify server is still responsive + let _stream3 = client + .subscribe_orderbook(vec![payloads::other_asset_id()]) + .unwrap(); + + let next_msg = server.recv_subscription().await.unwrap(); + // Should be a subscribe for OTHER_ASSET_ID, not an unsubscribe for ASSET_ID + assert!( + next_msg.contains(OTHER_ASSET_ID_STR), + "Should receive subscribe for new asset, not unsubscribe. Got: {next_msg}" + ); + assert!( + !next_msg.contains("\"operation\":\"unsubscribe\""), + "Should not have sent unsubscribe yet" + ); + } + + #[tokio::test] + async fn multiple_streams_unsubscribe_independently() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe three times + let _stream1 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + let _stream2 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let _stream3 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + + // Unsubscribe twice - still one stream left + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + + // Third unsubscribe - now refcount hits 0, should send request + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!( + unsub.contains("\"operation\":\"unsubscribe\""), + "Should send unsubscribe when last stream unsubscribes, got: {unsub}" + ); + } + + #[tokio::test] + async fn resubscribe_after_full_unsubscribe() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe + let _stream1 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let sub1 = server.recv_subscription().await.unwrap(); + assert!(sub1.contains(&asset_id.to_string())); + + // Fully unsubscribe + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + let unsub = server.recv_subscription().await.unwrap(); + assert!(unsub.contains("\"operation\":\"unsubscribe\"")); + + // Re-subscribe should send a new subscription request + let stream2 = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let mut stream2 = Box::pin(stream2); + + let sub2 = server.recv_subscription().await.unwrap(); + assert!( + sub2.contains("\"type\":\"market\""), + "Should send new subscribe request after full unsubscribe" + ); + assert!(sub2.contains(&asset_id.to_string())); + + // Verify stream works + server.send(&payloads::book().to_string()); + let result = timeout(Duration::from_secs(2), stream2.next()).await; + assert!( + result.is_ok(), + "Should receive messages on re-subscribed stream" + ); + } + + #[tokio::test] + async fn unsubscribe_empty_asset_ids_returns_error() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + // Subscribe to something first + let _stream = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe with empty array should error + let result = client.unsubscribe_orderbook(&[]); + assert!(result.is_err(), "Should return error for empty asset_ids"); + } + + #[tokio::test] + async fn unsubscribe_nonexistent_subscription_is_noop() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + let nonexistent_asset = payloads::other_asset_id(); + + // Subscribe to one asset + let _stream = client.subscribe_orderbook(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe from asset we never subscribed to - should be no-op + client.unsubscribe_orderbook(&[nonexistent_asset]).unwrap(); + + // Subscribe to another asset to verify server didn't receive unsubscribe + let _stream2 = client.subscribe_orderbook(vec![nonexistent_asset]).unwrap(); + + let next_msg = server.recv_subscription().await.unwrap(); + // Should be a subscribe, not an unsubscribe + assert!( + next_msg.contains("\"type\":\"market\""), + "Should receive subscribe, not unsubscribe for non-existent sub. Got: {next_msg}" + ); + } + + /// Stress test for concurrent subscribe/unsubscribe operations. + /// + /// This test verifies that the atomic reference counting in + /// `SubscriptionManager` prevents race conditions when multiple + /// tasks subscribe and unsubscribe to the same asset concurrently. + /// + /// The test creates N concurrent tasks that each subscribe and + /// unsubscribe in a loop, then verifies that the final state is + /// consistent (either fully subscribed or fully unsubscribed). + #[tokio::test] + async fn concurrent_subscribe_unsubscribe_maintains_consistency() { + const NUM_TASKS: usize = 10; + const ITERATIONS: usize = 50; + + let server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Arc::new(Client::new(&endpoint, Config::default()).unwrap()); + let asset_id = payloads::asset_id(); + + // Spawn multiple tasks that race to subscribe and unsubscribe + let mut handles = Vec::with_capacity(NUM_TASKS); + for _ in 0..NUM_TASKS { + let client = Arc::clone(&client); + let handle = tokio::spawn(async move { + for _ in 0..ITERATIONS { + // Subscribe + let _stream = client.subscribe_orderbook(vec![asset_id]).unwrap(); + + // Small yield to increase interleaving + tokio::task::yield_now().await; + + // Unsubscribe + client.unsubscribe_orderbook(&[asset_id]).unwrap(); + } + }); + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.expect("task should not panic"); + } + + // Final verification: after all tasks complete, subscription count should be 0 + // This verifies no reference count corruption occurred during concurrent operations + assert_eq!( + client.subscription_count(), + 0, + "All subscriptions should be cleaned up after concurrent operations" + ); + } +} + +mod client_state { + use polymarket_client_sdk::clob::ws::ChannelType; + + use super::*; + + #[tokio::test] + async fn is_connected_returns_false_before_subscription() { + let server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + // Before any subscription, connection should not be established + assert!(!client.is_connected(ChannelType::Market)); + assert!(!client.is_connected(ChannelType::User)); + } + + #[tokio::test] + async fn is_connected_returns_true_after_subscription() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + // Subscribe to trigger connection + let _stream = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let _: Option = server.recv_subscription().await; + + // Now should be connected + assert!(client.is_connected(ChannelType::Market)); + assert!(!client.is_connected(ChannelType::User)); + } + + #[tokio::test] + async fn connection_state_is_connected_after_subscription() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + // Subscribe to trigger connection + let _stream = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let _: Option = server.recv_subscription().await; + + // Allow connection to establish + tokio::time::sleep(Duration::from_millis(50)).await; + + // Now should be connected + assert!(client.connection_state(ChannelType::Market).is_connected()); + assert!(!client.connection_state(ChannelType::User).is_connected()); + } + + #[tokio::test] + async fn subscription_count_increases_with_subscriptions() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let _stream1 = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let _: Option = server.recv_subscription().await; + + assert_eq!(client.subscription_count(), 1); + + let _stream2 = client + .subscribe_prices(vec![payloads::other_asset_id()]) + .unwrap(); + let _: Option = server.recv_subscription().await; + + assert_eq!(client.subscription_count(), 2); + } +} + +mod unsubscribe_variants { + use super::*; + + #[tokio::test] + async fn unsubscribe_prices_sends_request() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe via prices + let _stream = client.subscribe_prices(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe via prices + client.unsubscribe_prices(&[asset_id]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!(unsub.contains("\"operation\":\"unsubscribe\"")); + assert!(unsub.contains(&asset_id.to_string())); + } + + #[tokio::test] + async fn unsubscribe_tick_size_change_sends_request() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe via tick size changes + let _stream = client.subscribe_tick_size_change(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe via tick size changes + client.unsubscribe_tick_size_change(&[asset_id]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!(unsub.contains("\"operation\":\"unsubscribe\"")); + assert!(unsub.contains(&asset_id.to_string())); + } + + #[tokio::test] + async fn unsubscribe_midpoints_sends_request() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let asset_id = payloads::asset_id(); + + // Subscribe via midpoints + let _stream = client.subscribe_midpoints(vec![asset_id]).unwrap(); + let _: Option = server.recv_subscription().await; + + // Unsubscribe via midpoints + client.unsubscribe_midpoints(&[asset_id]).unwrap(); + + let unsub = server.recv_subscription().await.unwrap(); + assert!(unsub.contains("\"operation\":\"unsubscribe\"")); + assert!(unsub.contains(&asset_id.to_string())); + } +} + +mod custom_features { + use rust_decimal_macros::dec; + + use super::*; + + pub fn best_bid_ask() -> serde_json::Value { + json!({ + "event_type": "best_bid_ask", + "market": payloads::MARKET_STR, + "asset_id": payloads::asset_id(), + "best_bid": "0.48", + "best_ask": "0.52", + "spread": "0.04", + "timestamp": "1234567890000" + }) + } + + pub fn new_market() -> serde_json::Value { + json!({ + "event_type": "new_market", + "id": "12345", + "question": "Will it rain tomorrow?", + "market": payloads::MARKET_STR, + "slug": "will-it-rain-tomorrow", + "description": "A test market", + "assets_ids": [payloads::asset_id()], + "outcomes": ["Yes", "No"], + "timestamp": "1234567890000" + }) + } + + pub fn market_resolved() -> serde_json::Value { + json!({ + "event_type": "market_resolved", + "id": "12345", + "question": "Will it rain tomorrow?", + "market": payloads::MARKET_STR, + "slug": "will-it-rain-tomorrow", + "description": "A test market", + "assets_ids": [payloads::asset_id()], + "outcomes": ["Yes", "No"], + "winning_asset_id": payloads::asset_id(), + "winning_outcome": "Yes", + "timestamp": "1234567890000" + }) + } + + #[tokio::test] + async fn subscribe_best_bid_ask_receives_updates() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_best_bid_ask(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription with custom_feature_enabled + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"type\":\"market\"")); + assert!(sub_request.contains("\"custom_feature_enabled\":true")); + + // Send best_bid_ask message + server.send(&best_bid_ask().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let bba = result.unwrap().unwrap().unwrap(); + + assert_eq!(bba.asset_id, payloads::asset_id()); + assert_eq!(bba.market, payloads::MARKET); + assert_eq!(bba.best_bid, dec!(0.48)); + assert_eq!(bba.best_ask, dec!(0.52)); + assert_eq!(bba.spread, dec!(0.04)); + } + + #[tokio::test] + async fn subscribe_new_markets_receives_updates() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_new_markets(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription with custom_feature_enabled + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"custom_feature_enabled\":true")); + + // Send new_market message + server.send(&new_market().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let nm = result.unwrap().unwrap().unwrap(); + + assert_eq!(nm.id, "12345"); + assert_eq!(nm.question, "Will it rain tomorrow?"); + assert_eq!(nm.market, payloads::MARKET); + assert_eq!(nm.slug, "will-it-rain-tomorrow"); + assert_eq!(nm.asset_ids, vec![payloads::asset_id()]); + assert_eq!(nm.outcomes, vec!["Yes", "No"]); + } + + #[tokio::test] + async fn subscribe_market_resolutions_receives_updates() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_market_resolutions(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + // Verify subscription with custom_feature_enabled + let sub_request = server.recv_subscription().await.unwrap(); + assert!(sub_request.contains("\"custom_feature_enabled\":true")); + + // Send market_resolved message + server.send(&market_resolved().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let mr = result.unwrap().unwrap().unwrap(); + + assert_eq!(mr.id, "12345"); + assert_eq!(mr.question, Some("Will it rain tomorrow?".to_owned())); + assert_eq!(mr.market, payloads::MARKET); + assert_eq!(mr.slug, Some("will-it-rain-tomorrow".to_owned())); + assert_eq!(mr.asset_ids, vec![payloads::asset_id()]); + } + + #[tokio::test] + async fn subscribe_best_bid_ask_filters_other_messages() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_best_bid_ask(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send a book message (should be filtered out) + server.send(&payloads::book().to_string()); + + // Send best_bid_ask message + server.send(&best_bid_ask().to_string()); + + // Should only receive best_bid_ask + let result = timeout(Duration::from_secs(2), stream.next()).await; + let bba = result.unwrap().unwrap().unwrap(); + assert_eq!(bba.best_bid, dec!(0.48)); + } + + #[tokio::test] + async fn subscribe_new_markets_filters_other_messages() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_new_markets(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send a book message (should be filtered out) + server.send(&payloads::book().to_string()); + + // Send a best_bid_ask message (should also be filtered out) + server.send(&best_bid_ask().to_string()); + + // Send new_market message + server.send(&new_market().to_string()); + + // Should only receive new_market + let result = timeout(Duration::from_secs(2), stream.next()).await; + let nm = result.unwrap().unwrap().unwrap(); + assert_eq!(nm.id, "12345"); + } + + #[tokio::test] + async fn subscribe_market_resolutions_filters_other_messages() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let client = Client::new(&endpoint, Config::default()).unwrap(); + + let stream = client + .subscribe_market_resolutions(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send a book message (should be filtered out) + server.send(&payloads::book().to_string()); + + // Send a new_market message (should also be filtered out) + server.send(&new_market().to_string()); + + // Send market_resolved message + server.send(&market_resolved().to_string()); + + // Should only receive market_resolved + let result = timeout(Duration::from_secs(2), stream.next()).await; + let mr = result.unwrap().unwrap().unwrap(); + assert_eq!(mr.id, "12345"); + } +} + +mod message_parsing { + use std::str::FromStr as _; + + use polymarket_client_sdk::clob::types::Side; + use polymarket_client_sdk::clob::ws::{LastTradePrice, TickSizeChange}; + use rust_decimal_macros::dec; + + use super::*; + + #[tokio::test] + async fn parses_book_with_hash() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let stream = client + .subscribe_orderbook(vec![payloads::asset_id()]) + .unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + server.send(&payloads::book().to_string()); + + let result = timeout(Duration::from_secs(2), stream.next()).await; + let book = result.unwrap().unwrap().unwrap(); + + // Verify all fields from docs example + assert_eq!(book.timestamp, 123_456_789_000); + assert_eq!(book.hash, Some("0x1234567890abcdef".to_owned())); + assert_eq!(book.bids[1].price, dec!(0.49)); + assert_eq!(book.bids[1].size, dec!(20)); + assert_eq!(book.asks[2].price, dec!(0.54)); + assert_eq!(book.asks[2].size, dec!(10)); + } + + #[tokio::test] + async fn parses_batch_price_changes() { + let mut server = MockWsServer::start().await; + let endpoint = server.ws_url("/ws/market"); + + let config = Config::default(); + let client = Client::new(&endpoint, config).unwrap(); + + let asset_a_str = + "71321045679252212594626385532706912750332728571942532289631379312455583992563"; + let asset_b_str = + "88888888888888888888888888888888888888888888888888888888888888888888888888888"; + let asset_a = U256::from_str(asset_a_str).unwrap(); + let asset_b = U256::from_str(asset_b_str).unwrap(); + + let stream = client.subscribe_prices(vec![asset_a, asset_b]).unwrap(); + let mut stream = Box::pin(stream); + + let _: Option = server.recv_subscription().await; + + // Send batch price change with two assets + let batch_msg = json!({ + "market": "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1", + "price_changes": [ + { + "asset_id": asset_a_str, + "price": "0.5", + "size": "200", + "side": "BUY", + "hash": "56621a121a47ed9333273e21c83b660cff37ae50", + "best_bid": "0.5", + "best_ask": "1" + }, + { + "asset_id": asset_b_str, + "price": "0.75", + "side": "SELL" + } + ], + "timestamp": "1757908892351", + "event_type": "price_change" + }); + server.send(&batch_msg.to_string()); + + // Should receive two price changes + let result1 = timeout(Duration::from_secs(2), stream.next()).await; + let prices = result1.unwrap().unwrap().unwrap(); + assert_eq!(prices.price_changes[0].asset_id, asset_a); + assert_eq!(prices.price_changes[0].price, dec!(0.5)); + assert_eq!(prices.price_changes[0].size, Some(dec!(200))); + assert_eq!( + prices.price_changes[0].hash, + Some("56621a121a47ed9333273e21c83b660cff37ae50".to_owned()) + ); + + assert_eq!(prices.price_changes[1].asset_id, asset_b); + assert_eq!(prices.price_changes[1].price, dec!(0.75)); + assert!(prices.price_changes[1].size.is_none()); + } + + #[test] + fn parses_tick_size_change() { + let payload = payloads::tick_size_change().to_string(); + let tsc: TickSizeChange = serde_json::from_str(&payload).unwrap(); + + assert_eq!(tsc.asset_id, payloads::asset_id()); + assert_eq!(tsc.market, payloads::MARKET); + assert_eq!(tsc.old_tick_size, dec!(0.01)); + assert_eq!(tsc.new_tick_size, dec!(0.001)); + assert_eq!(tsc.timestamp, 100_000_000); + } + + #[test] + fn parses_last_trade_price() { + let asset_id_str = + "114122071509644379678018727908709560226618148003371446110114509806601493071694"; + let asset_id = U256::from_str(asset_id_str).unwrap(); + let payload = payloads::last_trade_price(asset_id_str).to_string(); + let ltp: LastTradePrice = serde_json::from_str(&payload).unwrap(); + + assert_eq!(ltp.asset_id, asset_id); + assert_eq!( + ltp.market, + b256!("6a67b9d828d53862160e470329ffea5246f338ecfffdf2cab45211ec578b0347") + ); + assert_eq!(ltp.price, dec!(0.456)); + assert_eq!(ltp.side, Some(Side::Buy)); + assert_eq!(ltp.timestamp, 1_750_428_146_322); + } +} diff --git a/src/commands/ctf.rs b/src/commands/ctf.rs index eec7170..b8a6a27 100644 --- a/src/commands/ctf.rs +++ b/src/commands/ctf.rs @@ -1,15 +1,18 @@ -use alloy::primitives::U256; +use alloy::primitives::{Bytes, U256}; +use alloy::sol; +use alloy::sol_types::SolCall as _; use anyhow::{Context, Result}; use clap::{Args, Subcommand}; +use polymarket_client_sdk::auth::Signer as _; use polymarket_client_sdk::ctf::types::{ CollectionIdRequest, ConditionIdRequest, MergePositionsRequest, PositionIdRequest, RedeemNegRiskRequest, RedeemPositionsRequest, SplitPositionRequest, }; use polymarket_client_sdk::types::{Address, B256}; -use polymarket_client_sdk::{POLYGON, ctf}; +use polymarket_client_sdk::{contract_config, derive_safe_wallet, POLYGON, ctf}; use rust_decimal::Decimal; -use crate::auth; +use crate::{auth, config}; use crate::output::OutputFormat; use crate::output::ctf as ctf_output; @@ -183,7 +186,138 @@ fn default_index_sets() -> Vec { vec![U256::from(1), U256::from(2)] } -pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&str>) -> Result<()> { +// Gnosis Safe interface for routing CTF transactions through Safe wallets. +// When signature_type is "gnosis-safe", outcome tokens are held by the Safe (not the EOA), +// so CTF calls must go through Safe's execTransaction. +sol! { + #[sol(rpc)] + interface IGnosisSafe { + function nonce() external view returns (uint256); + function getTransactionHash( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes32); + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + } + + // CTF function signatures for encoding calldata (not for RPC calls) + interface ICtfEncode { + function redeemPositions( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata indexSets + ); + function splitPosition( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata partition, + uint256 amount + ); + function mergePositions( + address collateralToken, + bytes32 parentCollectionId, + bytes32 conditionId, + uint256[] calldata partition, + uint256 amount + ); + } + + // NegRisk adapter function signatures for encoding calldata + interface INegRiskEncode { + function redeemPositions( + bytes32 conditionId, + uint256[] calldata amounts + ); + } +} + +/// Execute a transaction through a Gnosis Safe via `execTransaction`. +/// +/// The EOA signs the Safe transaction hash and submits the outer tx on-chain. +/// The Safe verifies the signature and calls the target contract internally, +/// so `msg.sender` in the target is the Safe address (which holds the tokens). +async fn safe_exec( + provider: impl alloy::providers::Provider + Clone, + signer: &impl polymarket_client_sdk::auth::Signer, + safe_address: Address, + target: Address, + calldata: Vec, +) -> Result<(B256, u64)> { + let safe = IGnosisSafe::new(safe_address, provider); + + let nonce = safe.nonce().call().await + .context("Failed to get Safe nonce")?; + + let safe_tx_hash = safe.getTransactionHash( + target, + U256::ZERO, + Bytes::from(calldata.clone()), + 0u8, // operation: Call + U256::ZERO, // safeTxGas (0 = forward all gas) + U256::ZERO, // baseGas + U256::ZERO, // gasPrice (0 = no refund) + Address::ZERO, // gasToken + Address::ZERO, // refundReceiver + nonce, + ).call().await + .context("Failed to compute Safe transaction hash")?; + + let signature = signer.sign_hash(&safe_tx_hash).await + .map_err(|e| anyhow::anyhow!("Failed to sign Safe transaction: {e}"))?; + let sig_bytes = signature.as_bytes(); + + let pending = safe.execTransaction( + target, + U256::ZERO, + Bytes::from(calldata), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + Bytes::from(sig_bytes.to_vec()), + ).send().await + .context("Failed to send Safe transaction")?; + + let tx_hash = *pending.tx_hash(); + let receipt = pending.get_receipt().await + .context("Failed to get Safe transaction receipt")?; + + let block = receipt.block_number + .context("Block number not in receipt")?; + + Ok((tx_hash, block)) +} + +pub async fn execute( + args: CtfArgs, + output: OutputFormat, + private_key: Option<&str>, + signature_type: Option<&str>, +) -> Result<()> { + let is_gnosis_safe = config::resolve_signature_type(signature_type) == "gnosis-safe"; match args.command { CtfCommand::Split { condition, @@ -201,23 +335,43 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s None => default_partition(), }; - let provider = auth::create_provider(private_key).await?; - let client = ctf::Client::new(provider, POLYGON)?; - - let req = SplitPositionRequest::builder() - .collateral_token(collateral_addr) - .parent_collection_id(parent) - .condition_id(condition_id) - .partition(partition) - .amount(usdc_amount) - .build(); - - let resp = client - .split_position(&req) - .await - .context("Split position failed")?; - - ctf_output::print_tx_result("split", resp.transaction_hash, resp.block_number, &output) + if is_gnosis_safe { + let signer = auth::resolve_signer(private_key)?; + let safe_addr = derive_safe_wallet(signer.address(), POLYGON) + .context("Safe wallet derivation not supported on this chain")?; + let provider = auth::create_provider(private_key).await?; + let ctf_addr = contract_config(POLYGON, false) + .context("CTF config not found")?.conditional_tokens; + + let calldata = ICtfEncode::splitPositionCall { + collateralToken: collateral_addr, + parentCollectionId: parent, + conditionId: condition_id, + partition, + amount: usdc_amount, + }.abi_encode(); + + let (tx_hash, block) = safe_exec(provider, &signer, safe_addr, ctf_addr, calldata).await?; + ctf_output::print_tx_result("split", tx_hash, block, &output) + } else { + let provider = auth::create_provider(private_key).await?; + let client = ctf::Client::new(provider, POLYGON)?; + + let req = SplitPositionRequest::builder() + .collateral_token(collateral_addr) + .parent_collection_id(parent) + .condition_id(condition_id) + .partition(partition) + .amount(usdc_amount) + .build(); + + let resp = client + .split_position(&req) + .await + .context("Split position failed")?; + + ctf_output::print_tx_result("split", resp.transaction_hash, resp.block_number, &output) + } } CtfCommand::Merge { condition, @@ -235,23 +389,43 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s None => default_partition(), }; - let provider = auth::create_provider(private_key).await?; - let client = ctf::Client::new(provider, POLYGON)?; - - let req = MergePositionsRequest::builder() - .collateral_token(collateral_addr) - .parent_collection_id(parent) - .condition_id(condition_id) - .partition(partition) - .amount(usdc_amount) - .build(); - - let resp = client - .merge_positions(&req) - .await - .context("Merge positions failed")?; - - ctf_output::print_tx_result("merge", resp.transaction_hash, resp.block_number, &output) + if is_gnosis_safe { + let signer = auth::resolve_signer(private_key)?; + let safe_addr = derive_safe_wallet(signer.address(), POLYGON) + .context("Safe wallet derivation not supported on this chain")?; + let provider = auth::create_provider(private_key).await?; + let ctf_addr = contract_config(POLYGON, false) + .context("CTF config not found")?.conditional_tokens; + + let calldata = ICtfEncode::mergePositionsCall { + collateralToken: collateral_addr, + parentCollectionId: parent, + conditionId: condition_id, + partition, + amount: usdc_amount, + }.abi_encode(); + + let (tx_hash, block) = safe_exec(provider, &signer, safe_addr, ctf_addr, calldata).await?; + ctf_output::print_tx_result("merge", tx_hash, block, &output) + } else { + let provider = auth::create_provider(private_key).await?; + let client = ctf::Client::new(provider, POLYGON)?; + + let req = MergePositionsRequest::builder() + .collateral_token(collateral_addr) + .parent_collection_id(parent) + .condition_id(condition_id) + .partition(partition) + .amount(usdc_amount) + .build(); + + let resp = client + .merge_positions(&req) + .await + .context("Merge positions failed")?; + + ctf_output::print_tx_result("merge", resp.transaction_hash, resp.block_number, &output) + } } CtfCommand::Redeem { condition, @@ -267,46 +441,84 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s None => default_index_sets(), }; - let provider = auth::create_provider(private_key).await?; - let client = ctf::Client::new(provider, POLYGON)?; - - let req = RedeemPositionsRequest::builder() - .collateral_token(collateral_addr) - .parent_collection_id(parent) - .condition_id(condition_id) - .index_sets(index_sets) - .build(); - - let resp = client - .redeem_positions(&req) - .await - .context("Redeem positions failed")?; - - ctf_output::print_tx_result("redeem", resp.transaction_hash, resp.block_number, &output) + if is_gnosis_safe { + let signer = auth::resolve_signer(private_key)?; + let safe_addr = derive_safe_wallet(signer.address(), POLYGON) + .context("Safe wallet derivation not supported on this chain")?; + let provider = auth::create_provider(private_key).await?; + let ctf_addr = contract_config(POLYGON, false) + .context("CTF config not found")?.conditional_tokens; + + let calldata = ICtfEncode::redeemPositionsCall { + collateralToken: collateral_addr, + parentCollectionId: parent, + conditionId: condition_id, + indexSets: index_sets, + }.abi_encode(); + + let (tx_hash, block) = safe_exec(provider, &signer, safe_addr, ctf_addr, calldata).await?; + ctf_output::print_tx_result("redeem", tx_hash, block, &output) + } else { + let provider = auth::create_provider(private_key).await?; + let client = ctf::Client::new(provider, POLYGON)?; + + let req = RedeemPositionsRequest::builder() + .collateral_token(collateral_addr) + .parent_collection_id(parent) + .condition_id(condition_id) + .index_sets(index_sets) + .build(); + + let resp = client + .redeem_positions(&req) + .await + .context("Redeem positions failed")?; + + ctf_output::print_tx_result("redeem", resp.transaction_hash, resp.block_number, &output) + } } CtfCommand::RedeemNegRisk { condition, amounts } => { let condition_id = super::parse_condition_id(&condition)?; let amounts = parse_usdc_amounts(&amounts)?; - let provider = auth::create_provider(private_key).await?; - let client = ctf::Client::with_neg_risk(provider, POLYGON)?; - - let req = RedeemNegRiskRequest::builder() - .condition_id(condition_id) - .amounts(amounts) - .build(); - - let resp = client - .redeem_neg_risk(&req) - .await - .context("Redeem neg-risk positions failed")?; - - ctf_output::print_tx_result( - "redeem-neg-risk", - resp.transaction_hash, - resp.block_number, - &output, - ) + if is_gnosis_safe { + let signer = auth::resolve_signer(private_key)?; + let safe_addr = derive_safe_wallet(signer.address(), POLYGON) + .context("Safe wallet derivation not supported on this chain")?; + let provider = auth::create_provider(private_key).await?; + let neg_risk_addr = contract_config(POLYGON, true) + .context("NegRisk config not found")? + .neg_risk_adapter + .context("NegRisk adapter address not configured")?; + + let calldata = INegRiskEncode::redeemPositionsCall { + conditionId: condition_id, + amounts, + }.abi_encode(); + + let (tx_hash, block) = safe_exec(provider, &signer, safe_addr, neg_risk_addr, calldata).await?; + ctf_output::print_tx_result("redeem-neg-risk", tx_hash, block, &output) + } else { + let provider = auth::create_provider(private_key).await?; + let client = ctf::Client::with_neg_risk(provider, POLYGON)?; + + let req = RedeemNegRiskRequest::builder() + .condition_id(condition_id) + .amounts(amounts) + .build(); + + let resp = client + .redeem_neg_risk(&req) + .await + .context("Redeem neg-risk positions failed")?; + + ctf_output::print_tx_result( + "redeem-neg-risk", + resp.transaction_hash, + resp.block_number, + &output, + ) + } } CtfCommand::ConditionId { oracle, diff --git a/src/commands/setup.rs b/src/commands/setup.rs index dd04671..7d5082f 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -5,9 +5,9 @@ use std::str::FromStr; use anyhow::{Context, Result}; use polymarket_client_sdk::auth::{LocalSigner, Signer as _}; use polymarket_client_sdk::types::Address; -use polymarket_client_sdk::{POLYGON, derive_proxy_wallet}; +use polymarket_client_sdk::POLYGON; -use super::wallet::normalize_key; +use super::wallet::{derive_trading_wallet, normalize_key}; use crate::config; fn print_banner() { @@ -154,18 +154,18 @@ fn setup_wallet() -> Result

{ fn finish_setup(address: Address) -> Result<()> { let total = 4; - step_header(2, total, "Proxy Wallet"); + step_header(2, total, "Trading Wallet"); - let proxy = derive_proxy_wallet(address, POLYGON); - match proxy { - Some(proxy) => { - println!(" ✓ Proxy wallet derived"); - println!(" Proxy: {proxy}"); + let sig_type = config::resolve_signature_type(None); + let trading = derive_trading_wallet(address, POLYGON, &sig_type); + match trading { + Some(tw) => { + println!(" ✓ Trading wallet derived ({sig_type})"); + println!(" Trading wallet: {tw}"); println!(" Deposit USDC to this address to start trading."); } None => { - println!(" ✗ Could not derive proxy wallet"); - println!(" You may need to use --signature-type eoa"); + println!(" ℹ Using EOA directly (signature type: {sig_type})"); } } @@ -173,7 +173,7 @@ fn finish_setup(address: Address) -> Result<()> { step_header(3, total, "Fund Wallet"); - let deposit_addr = proxy.unwrap_or(address); + let deposit_addr = trading.unwrap_or(address); println!(" ○ Deposit USDC to your wallet to start trading"); println!(" Run: polymarket bridge deposit {deposit_addr}"); println!(" Or transfer USDC directly on Polygon"); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ce7597f..8b46109 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -5,11 +5,27 @@ use anyhow::{Context, Result, bail}; use clap::{Args, Subcommand}; use polymarket_client_sdk::auth::LocalSigner; use polymarket_client_sdk::auth::Signer as _; -use polymarket_client_sdk::{POLYGON, derive_proxy_wallet}; +use polymarket_client_sdk::{POLYGON, derive_proxy_wallet, derive_safe_wallet}; use crate::config; use crate::output::OutputFormat; +/// Derive the trading wallet address based on the configured signature type. +/// - "proxy" → Polymarket Proxy wallet (Magic/email) +/// - "gnosis-safe" → Gnosis Safe wallet (browser/MetaMask) +/// - anything else (e.g. "eoa") → None (the EOA itself is used) +pub(crate) fn derive_trading_wallet( + address: polymarket_client_sdk::types::Address, + chain_id: u64, + signature_type: &str, +) -> Option { + match signature_type { + "proxy" => derive_proxy_wallet(address, chain_id), + "gnosis-safe" => derive_safe_wallet(address, chain_id), + _ => None, + } +} + #[derive(Args)] pub struct WalletArgs { #[command(subcommand)] @@ -103,7 +119,7 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul config::save_wallet(&key_hex, POLYGON, signature_type)?; let config_path = config::config_path()?; - let proxy_addr = derive_proxy_wallet(address, POLYGON); + let trading_addr = derive_trading_wallet(address, POLYGON, signature_type); match output { OutputFormat::Json => { @@ -111,7 +127,7 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul "{}", serde_json::json!({ "address": address.to_string(), - "proxy_address": proxy_addr.map(|a| a.to_string()), + "trading_wallet": trading_addr.map(|a| a.to_string()), "signature_type": signature_type, "config_path": config_path.display().to_string(), }) @@ -120,8 +136,8 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul OutputFormat::Table => { println!("Wallet created successfully!"); println!("Address: {address}"); - if let Some(proxy) = proxy_addr { - println!("Proxy wallet: {proxy}"); + if let Some(tw) = trading_addr { + println!("Trading wallet: {tw}"); } println!("Signature type: {signature_type}"); println!("Config: {}", config_path.display()); @@ -144,7 +160,7 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st config::save_wallet(&normalized, POLYGON, signature_type)?; let config_path = config::config_path()?; - let proxy_addr = derive_proxy_wallet(address, POLYGON); + let trading_addr = derive_trading_wallet(address, POLYGON, signature_type); match output { OutputFormat::Json => { @@ -152,7 +168,7 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st "{}", serde_json::json!({ "address": address.to_string(), - "proxy_address": proxy_addr.map(|a| a.to_string()), + "trading_wallet": trading_addr.map(|a| a.to_string()), "signature_type": signature_type, "config_path": config_path.display().to_string(), }) @@ -161,8 +177,8 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st OutputFormat::Table => { println!("Wallet imported successfully!"); println!("Address: {address}"); - if let Some(proxy) = proxy_addr { - println!("Proxy wallet: {proxy}"); + if let Some(tw) = trading_addr { + println!("Trading wallet: {tw}"); } println!("Signature type: {signature_type}"); println!("Config: {}", config_path.display()); @@ -193,12 +209,13 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> let (key, source) = config::resolve_key(private_key_flag); let signer = key.as_deref().and_then(|k| LocalSigner::from_str(k).ok()); let address = signer.as_ref().map(|s| s.address().to_string()); - let proxy_addr = signer + + let sig_type = config::resolve_signature_type(None); + let trading_addr = signer .as_ref() - .and_then(|s| derive_proxy_wallet(s.address(), POLYGON)) + .and_then(|s| derive_trading_wallet(s.address(), POLYGON, &sig_type)) .map(|a| a.to_string()); - let sig_type = config::resolve_signature_type(None); let config_path = config::config_path()?; match output { @@ -207,7 +224,7 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> "{}", serde_json::json!({ "address": address, - "proxy_address": proxy_addr, + "trading_wallet": trading_addr, "signature_type": sig_type, "config_path": config_path.display().to_string(), "source": source.label(), @@ -220,8 +237,8 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> Some(addr) => println!("Address: {addr}"), None => println!("Address: (not configured)"), } - if let Some(proxy) = &proxy_addr { - println!("Proxy wallet: {proxy}"); + if let Some(tw) = &trading_addr { + println!("Trading wallet: {tw}"); } println!("Signature type: {sig_type}"); println!("Config path: {}", config_path.display()); diff --git a/src/config.rs b/src/config.rs index d2f5395..6442218 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; const ENV_VAR: &str = "POLYMARKET_PRIVATE_KEY"; const SIG_TYPE_ENV_VAR: &str = "POLYMARKET_SIGNATURE_TYPE"; +const PROXY_ENV_VAR: &str = "POLYMARKET_PROXY"; pub const DEFAULT_SIGNATURE_TYPE: &str = "proxy"; pub const NO_WALLET_MSG: &str = @@ -13,10 +14,13 @@ pub const NO_WALLET_MSG: &str = #[derive(Serialize, Deserialize)] pub struct Config { - pub private_key: String, + #[serde(default)] + pub private_key: Option, pub chain_id: u64, #[serde(default = "default_signature_type")] pub signature_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy: Option, } fn default_signature_type() -> String { @@ -94,10 +98,12 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; } + let existing_proxy = load_config().and_then(|c| c.proxy); let config = Config { - private_key: key.to_string(), + private_key: Some(key.to_string()), chain_id, signature_type: signature_type.to_string(), + proxy: existing_proxy, }; let json = serde_json::to_string_pretty(&config)?; let path = config_path()?; @@ -125,6 +131,19 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> Ok(()) } +/// Priority: CLI flag > env var > config file. +pub fn resolve_proxy(cli_flag: Option<&str>) -> Option { + if let Some(url) = cli_flag { + return Some(url.to_string()); + } + if let Ok(url) = std::env::var(PROXY_ENV_VAR) + && !url.is_empty() + { + return Some(url); + } + load_config().and_then(|c| c.proxy) +} + /// Priority: CLI flag > env var > config file. pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { if let Some(key) = cli_flag { @@ -135,8 +154,11 @@ pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { { return (Some(key), KeySource::EnvVar); } - if let Some(config) = load_config() { - return (Some(config.private_key), KeySource::ConfigFile); + if let Some(config) = load_config() + && let Some(key) = config.private_key + && !key.is_empty() + { + return (Some(key), KeySource::ConfigFile); } (None, KeySource::None) } diff --git a/src/main.rs b/src/main.rs index 61af087..c602a51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,10 @@ pub(crate) struct Cli { /// Signature type: eoa, proxy, or gnosis-safe #[arg(long, global = true)] signature_type: Option, + + /// SOCKS5 or HTTP proxy URL (e.g., socks5://127.0.0.1:1080) + #[arg(long, global = true)] + proxy: Option, } #[derive(Subcommand)] @@ -66,12 +70,32 @@ enum Commands { Upgrade, } -#[tokio::main] -async fn main() -> ExitCode { +fn main() -> ExitCode { + // Resolve proxy BEFORE tokio spawns worker threads. + // Parse CLI args early (sync) to get --proxy flag. let cli = Cli::parse(); + + // Apply proxy: --proxy flag > POLYMARKET_PROXY env > config file proxy field. + // Only set HTTP(S)_PROXY for CLOB/Gamma API calls. + // Exclude the Polygon RPC so alloy (which uses reqwest 0.12 without socks + // support) can still reach the RPC directly. + if let Some(ref url) = config::resolve_proxy(cli.proxy.as_deref()) { + // SAFETY: no threads exist yet — called before tokio runtime is built. + unsafe { + std::env::set_var("HTTPS_PROXY", url); + std::env::set_var("HTTP_PROXY", url); + std::env::set_var("NO_PROXY", "polygon.drpc.org,drpc.org"); + } + } + let output = cli.output; - if let Err(e) = run(cli).await { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to build tokio runtime"); + + if let Err(e) = runtime.block_on(run(cli)) { match output { OutputFormat::Json => { println!("{}", serde_json::json!({"error": e.to_string()})); @@ -88,6 +112,15 @@ async fn main() -> ExitCode { #[allow(clippy::too_many_lines)] pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { + // Apply proxy from config/env so all reqwest clients (including SDK) use it. + // Only set ALL_PROXY if not already present — the user's explicit env takes precedence. + if std::env::var("ALL_PROXY").is_err() { + if let Some(proxy_url) = config::resolve_proxy() { + // SAFETY: single-threaded at this point (before any async work). + unsafe { std::env::set_var("ALL_PROXY", &proxy_url) }; + } + } + match cli.command { Commands::Setup => commands::setup::execute(), Commands::Shell => { @@ -163,7 +196,13 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { .await } Commands::Ctf(args) => { - commands::ctf::execute(args, cli.output, cli.private_key.as_deref()).await + commands::ctf::execute( + args, + cli.output, + cli.private_key.as_deref(), + cli.signature_type.as_deref(), + ) + .await } Commands::Data(args) => { commands::data::execute(