From 12706ea5dfeeb288fde53af1df3961d560d88d54 Mon Sep 17 00:00:00 2001 From: yluoc Date: Mon, 5 Oct 2026 22:43:00 -0400 Subject: [PATCH 1/6] feat(positions): add trading approvals setup and ERC20/ERC-1155 approval helpers --- CMakeLists.txt | 2 + README.md | 12 +- cmake/PolymarketExamples.cmake | 3 + cmake/PolymarketTests.cmake | 10 + docs/position-operations.md | 84 +++++-- examples/approvals_example.cpp | 108 +++++++++ include/polymarket/polymarket_contracts.hpp | 3 + include/polymarket/position_client.hpp | 39 ++- include/polymarket/trading_approvals.hpp | 51 ++++ src/approval_calls.cpp | 196 +++++++++++++++ src/approval_calls.hpp | 49 ++++ src/polymarket_contracts.cpp | 2 + src/trading_approvals.cpp | 74 ++++++ tests/test_approval_calls.cpp | 174 ++++++++++++++ tests/test_polymarket_contracts.cpp | 3 + tests/test_trading_approvals.cpp | 254 ++++++++++++++++++++ 16 files changed, 1040 insertions(+), 24 deletions(-) create mode 100644 examples/approvals_example.cpp create mode 100644 include/polymarket/trading_approvals.hpp create mode 100644 src/approval_calls.cpp create mode 100644 src/approval_calls.hpp create mode 100644 src/trading_approvals.cpp create mode 100644 tests/test_approval_calls.cpp create mode 100644 tests/test_trading_approvals.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 298aa51..defc9c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,6 +149,8 @@ set(POLYMARKET_CLIENT_SOURCES src/polymarket_events.cpp src/polymarket_contracts.cpp src/position_calls.cpp + src/approval_calls.cpp + src/trading_approvals.cpp src/position_gamma.cpp src/position_client.cpp src/position_senders.cpp diff --git a/README.md b/README.md index fb73160..03189af 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ Reusable C++20 client for Polymarket: REST, WebSocket streaming, and order signi - **Decimal math**: shared scaled-integer conversion for trading amounts. - **Structured errors**: opt-in `Result` APIs with typed SDK error classification. - **EVM JSON-RPC**: Polygon HTTP catch-up and WebSocket subscriptions for logs, heads, and pending transaction hashes, plus transaction calls (nonce, gas, `eth_call`, raw send, receipts). -- **On-chain positions**: `PositionClient` splits, merges and redeems CTF and Protocol V2 positions from an EOA or, gaslessly through the Polymarket relayer, from a Gnosis Safe. Built on an ABI encoder, EIP-155 transaction signing, and a `PolymarketContracts` address registry. +- **On-chain positions**: `PositionClient` splits, merges and redeems CTF and Protocol V2 positions, and checks and sets the one-time trading approvals, from an EOA or, gaslessly through the Polymarket relayer, from a Gnosis Safe. Built on an ABI encoder, EIP-155 transaction signing, and a `PolymarketContracts` address registry. - **Resolution Events**: Decoders for UMA adapter and Conditional Tokens resolution/redemption logs. - **Proxy Support**: HTTP/HTTPS proxy with authentication for geo-restricted access. - **Neg-Risk Markets**: Automatic exchange selection for neg_risk markets. -- **Examples**: REST (`rest_example`), signing (`sign_example`), WebSocket (`ws_example`), on-chain positions (`position_example`), onchain watchers. +- **Examples**: REST (`rest_example`), signing (`sign_example`), WebSocket (`ws_example`), on-chain positions (`position_example`), trading approvals (`approvals_example`), onchain watchers. - **Benchmarks and tests**: local benchmark targets plus `ctest` coverage. ## Requirements @@ -155,6 +155,7 @@ int main() { - `ws_example`: connect to Polymarket WS and subscribe to orderbook agg - `user_stream_example`: stream your own order and trade events (requires `PRIVATE_KEY`; optional condition IDs as arguments) - `position_example`: split, merge or redeem a position from an EOA or Safe; dry run unless `--execute` (requires `PRIVATE_KEY`, `POLYGON_RPC_ENDPOINT`) +- `approvals_example`: list a wallet's missing trading approvals and grant them; dry run unless `--execute` (requires `PRIVATE_KEY`, `POLYGON_RPC_ENDPOINT`) - `uma_oracle_watch`: stream UMA adapter lifecycle events over Polygon JSON-RPC - `condition_resolution_watch`: stream Conditional Tokens resolution/redemption events - `evm_event_indexer_example`: persistent HTTP catch-up + live WS indexer with a cursor file @@ -203,8 +204,10 @@ polymarket::PositionClient client(config); client.merge_positions(condition_id, "max").wait(); ``` +A new wallet needs the trading approvals once; `client.setup_trading_approvals()` +grants whichever of them `client.get_trading_approvals_state()` reports missing. See [docs/position-operations.md](docs/position-operations.md) for wallet -types, batching and atomicity, errors, and required approvals. +types, batching and atomicity, errors, and the approval set. ## Structured Errors @@ -285,7 +288,7 @@ required. ## Tests -`test_utils` exercises basic utility helpers. `test_evm_events` covers EVM topic hashing, log filter serialization, and UMA/CTF event decoding. `test_evm_event_indexer` covers block range planning and file-backed cursors. Transport, order execution, typed error, signing, and WebSocket resilience tests are included when `POLYMARKET_CLIENT_BUILD_TESTS=ON`. `test_evm_abi`, `test_evm_abi_shapes`, `test_evm_transaction`, `test_json_rpc_transactions`, `test_polymarket_contracts`, `test_position_calls`, `test_position_client`, `test_safe_relayer`, `test_safe_relayer_flows` and `test_transaction_waiters` check ABI encoding, transaction signing, Safe signing and relayer payloads against vectors from the official Python SDK, and run position flows against local fake RPC, Gamma and relayer servers. Run via `ctest --test-dir build`. +`test_utils` exercises basic utility helpers. `test_evm_events` covers EVM topic hashing, log filter serialization, and UMA/CTF event decoding. `test_evm_event_indexer` covers block range planning and file-backed cursors. Transport, order execution, typed error, signing, and WebSocket resilience tests are included when `POLYMARKET_CLIENT_BUILD_TESTS=ON`. `test_evm_abi`, `test_evm_abi_shapes`, `test_evm_transaction`, `test_json_rpc_transactions`, `test_polymarket_contracts`, `test_position_calls`, `test_position_client`, `test_safe_relayer`, `test_safe_relayer_flows`, `test_approval_calls`, `test_trading_approvals` and `test_transaction_waiters` check ABI encoding, transaction signing, Safe signing and relayer payloads against vectors from the official Python SDK, and run position flows against local fake RPC, Gamma and relayer servers. Run via `ctest --test-dir build`. `test_oracle_watcher` validates in-memory normalization against synthetic UMA/CTF fixtures. `test_oracle_watcher_historical` is labeled `live` and only performs its @@ -310,6 +313,7 @@ Set these env vars explicitly if you need another RPC or contract set. - `src/polymarket_contracts.cpp`: Polygon mainnet contract registry - `src/position_client.cpp`, `src/position_calls.cpp`, `src/position_gamma.cpp`: split/merge/redeem, calldata and Gamma market resolution - `src/position_senders.cpp`, `src/transaction_handle.cpp`: EOA/Safe sending and waiting for outcomes +- `src/trading_approvals.cpp`, `src/approval_calls.cpp`: trading approval state and setup, ERC-20/ERC-1155 approvals and transfers - `src/safe_relayer.cpp`: Gnosis Safe signing and relayer submission - `src/evm_event_indexer.cpp`: persistent log catch-up and live indexing - `src/evm_utils.cpp`: ABI/log utilities diff --git a/cmake/PolymarketExamples.cmake b/cmake/PolymarketExamples.cmake index 1b768fc..eee44b6 100644 --- a/cmake/PolymarketExamples.cmake +++ b/cmake/PolymarketExamples.cmake @@ -42,6 +42,9 @@ if(POLYMARKET_CLIENT_BUILD_EXAMPLES) add_executable(position_example examples/position_example.cpp) target_link_libraries(position_example PRIVATE polymarket::client) + add_executable(approvals_example examples/approvals_example.cpp) + target_link_libraries(approvals_example PRIVATE polymarket::client) + add_executable(uma_oracle_watch examples/uma_oracle_watch.cpp) target_link_libraries(uma_oracle_watch PRIVATE polymarket::client) diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index bace20b..573dbd1 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -42,6 +42,16 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_position_calls PRIVATE polymarket::client) add_test(NAME test_position_calls COMMAND test_position_calls) + add_executable(test_approval_calls tests/test_approval_calls.cpp) + target_include_directories(test_approval_calls PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_approval_calls PRIVATE polymarket::client) + add_test(NAME test_approval_calls COMMAND test_approval_calls) + + add_executable(test_trading_approvals tests/test_trading_approvals.cpp) + target_include_directories(test_trading_approvals PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_trading_approvals PRIVATE polymarket::client) + add_test(NAME test_trading_approvals COMMAND test_trading_approvals) + add_executable(test_transaction_waiters tests/test_transaction_waiters.cpp) target_include_directories(test_transaction_waiters PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_transaction_waiters PRIVATE polymarket::client) diff --git a/docs/position-operations.md b/docs/position-operations.md index 9590260..cf10d89 100644 --- a/docs/position-operations.md +++ b/docs/position-operations.md @@ -5,12 +5,12 @@ binary market positions on Polygon, matching `split_position`, `merge_positions`, `merge_multiple_positions` and `redeem_positions` in the official Python SDK. -| Operation | Effect | -|---|---| -| `split_position(condition_id, amount)` | Lock `amount` pUSD into `amount` YES + `amount` NO shares | +| Operation | Effect | +| ----------------------------------------------- | ------------------------------------------------------------- | +| `split_position(condition_id, amount)` | Lock `amount` pUSD into `amount` YES + `amount` NO shares | | `merge_positions(condition_id, amount = "max")` | Burn YES+NO pairs back into pUSD; `"max"` merges min(YES, NO) | -| `merge_multiple_positions(requests)` | Merge several conditions in one call | -| `redeem_positions(condition_id)` | Redeem every position held in a closed, resolved market | +| `merge_multiple_positions(requests)` | Merge several conditions in one call | +| `redeem_positions(condition_id)` | Redeem every position held in a closed, resolved market | Amounts are integer base units as base-10 strings. pUSD and outcome shares both use 6 decimals, so `"1000000"` is 1 pUSD or 1 share. @@ -37,10 +37,10 @@ auto handle = client.redeem_positions("0x6b04...6fbc"); auto outcome = handle.wait(); // throws on revert, relayer failure or timeout ``` -| `wallet_type` | Holder | Sent by | Gas | -|---|---|---|---| -| `EOA` (default) | the key's address | signed legacy (EIP-155) transaction via `rpc_url` | POL from the EOA | -| `POLY_GNOSIS_SAFE` | the key's Polymarket Safe | Safe transaction via `relayer-v2.polymarket.com` | paid by the relayer | +| `wallet_type` | Holder | Sent by | Gas | +| ------------------ | ------------------------- | ------------------------------------------------- | ------------------- | +| `EOA` (default) | the key's address | signed legacy (EIP-155) transaction via `rpc_url` | POL from the EOA | +| `POLY_GNOSIS_SAFE` | the key's Polymarket Safe | Safe transaction via `relayer-v2.polymarket.com` | paid by the relayer | The Safe address is derived from the key with CREATE2. `funder_address` is optional; if it is set and is not that Safe, the constructor throws. The @@ -116,23 +116,65 @@ calls throws `PartialBatchError` (see [Batches and atomicity](#batches-and-atomi ## Approvals -Operations do not check or set approvals. Before the first operation a wallet -needs: +Operations do not check approvals. Before the first operation a wallet needs: - split: a pUSD `approve` for the operator contract (collateral adapter, neg-risk collateral adapter or V2 router); - merge and redeem: `setApprovalForAll` on the position token (ConditionalTokens or the V2 position manager) for the same operator. -Wallets that have traded through polymarket.com may already have some of -these; check `allowance` and `isApprovedForAll` if unsure. For an EOA, a -missing approval shows up as a revert during gas estimation, before anything -is sent. For a Safe it surfaces only after submission, as a failed relayer -transaction. +For an EOA, a missing approval shows up as a revert during gas estimation, +before anything is sent. For a Safe it surfaces only after submission, as a +failed relayer transaction. + +### Trading approvals + +`setup_trading_approvals()` grants, once per wallet, the same set as the +official SDKs' `setup_trading_approvals` (`required_trading_approvals(contracts)` +in `include/polymarket/trading_approvals.hpp`): 17 approvals that cover CLOB +trading as well as split, merge and redeem. + +| Token | Approval | Spenders / operators | +| ----------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| pUSD `collateral_token` | `approve(spender, 2^256 - 1)` | standard and neg-risk exchanges, both collateral adapters, V2 router, exchange V3, perps deposit contract | +| ConditionalTokens | `setApprovalForAll(operator, true)` | standard and neg-risk exchanges, both collateral adapters, auto-redeem operator, binary module, neg-risk module | +| V2 `position_manager` | `setApprovalForAll(operator, true)` | V2 router, exchange V3, auto-redeem operator | + +```cpp +auto state = client.get_trading_approvals_state(); // or (other_wallet) +if (!state.is_fully_approved) + client.setup_trading_approvals(); // sends only state.missing, then waits +``` + +- `get_trading_approvals_state(wallet = this wallet)` reads every approval + with `eth_call` (`allowance`, `isApprovedForAll`) and lists only the missing + ones. An allowance below `2^256 - 1` counts as missing, as in the official + SDKs. The official SDKs read this from the Data API (`/v2/approvals`), which + can lag; this client always reads the chain. +- `setup_trading_approvals(timeout = 3 min)` sends `approve` calls first, then + `setApprovalForAll`, and waits. A Safe sends them as one atomic MultiSend + transaction; an EOA sends one transaction per approval (not atomic, see + [Batches and atomicity](#batches-and-atomicity); rerunning picks up where it + stopped). It returns `std::nullopt` without sending anything when the wallet + is already fully approved. + +Single approvals and transfers return a `TransactionHandle` without waiting: + +| Method | Call | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `approve_erc20(token, spender, amount)` | ERC-20 `approve`; `amount` is a uint256 in base units or `"max"`, `"0"` revokes | +| `approve_erc1155_for_all(token, operator, approved = true)` | ERC-1155 `setApprovalForAll` | +| `transfer_erc20(token, recipient, amount)` | ERC-20 `transfer` of a positive base-unit amount | + +Each also takes an optional relayer `metadata` string. ## Example ```bash +# Trading approvals: list the missing ones, then grant them +./build/approvals_example +./build/approvals_example --execute + # Dry run: resolve the market and print balances ./build/position_example redeem 0x6b04...6fbc @@ -145,7 +187,7 @@ transaction. ./build/position_example split 0x01...44 1000000 --execute ``` -The example reads `PRIVATE_KEY` and `POLYGON_RPC_ENDPOINT`. With +The examples read `PRIVATE_KEY` and `POLYGON_RPC_ENDPOINT`. With `POLYMARKET_PROXY_ADDRESS` set it uses the Safe and the relayer (`RELAYER_API_KEY`, optional `RELAYER_API_KEY_ADDRESS`); otherwise it sends from the EOA. @@ -154,7 +196,7 @@ from the EOA. - Lookup by market id or position id, and combo (multi-leg) positions. - `POLY_PROXY` and deposit-wallet (`POLY_1271`) relayer paths. -- Setting trading approvals (`setup_trading_approvals`). +- Reading approvals from the Data API (`/v2/approvals`); state is read on chain. ## Verification @@ -163,6 +205,9 @@ from the EOA. by the official Python SDK (`test_evm_abi`, `test_evm_abi_shapes`, `test_evm_transaction`, `test_position_calls`, `test_safe_relayer`). - `test_position_client` and `test_safe_relayer_flows` run EOA and Safe flows; + `test_trading_approvals` runs approval reads, setup (EOA and Safe MultiSend), + single approvals and transfers; `test_approval_calls` checks approval and + transfer calldata against the py-sdk golden vectors; `test_transaction_waiters` covers receipt and relayer waits whose timeout is shorter than the poll interval. - These flows run against local fake RPC, Gamma and relayer servers. @@ -174,5 +219,8 @@ from the EOA. `wait(1s, 2s)` on the pending split timed out after about 1.3 s. - An EOA batch whose second call fails gas estimation threw `PartialBatchError` with the mined hash of the first call. + - `setup_trading_approvals` on a Safe that lacked only the perps deposit + allowance sent that one `approve` through the relayer; a fresh + `get_trading_approvals_state` then reported the Safe fully approved. - No Protocol V2 market was listed on Gamma yet, so 31-byte ids were only checked against fixtures from the official Python SDK. diff --git a/examples/approvals_example.cpp b/examples/approvals_example.cpp new file mode 100644 index 0000000..601233a --- /dev/null +++ b/examples/approvals_example.cpp @@ -0,0 +1,108 @@ +#include "polymarket/position_client.hpp" + +#include +#include +#include +#include + +// Check and set the one-time trading approvals of a wallet. +// +// approvals_example [--execute] +// +// Without --execute the example only reads which approvals are missing; +// nothing is sent. With --execute it grants the missing ones and waits for +// them to be mined. +// +// Environment: PRIVATE_KEY and POLYGON_RPC_ENDPOINT are required. With +// POLYMARKET_PROXY_ADDRESS set, the approvals are set on that Gnosis Safe +// through the relayer in one transaction (gasless; needs RELAYER_API_KEY, +// optional RELAYER_API_KEY_ADDRESS). Otherwise the EOA sends one transaction +// per missing approval and pays gas in POL. + +namespace +{ + std::string env(const char *name) + { + const char *value = std::getenv(name); + return value ? value : ""; + } +} // namespace + +int main(int argc, char **argv) +{ + using namespace polymarket; + + bool execute = false; + for (int i = 1; i < argc; ++i) + { + if (std::strcmp(argv[i], "--execute") != 0) + { + std::cerr << "usage: approvals_example [--execute]\n"; + return 2; + } + execute = true; + } + + PositionClientConfig config; + config.private_key = env("PRIVATE_KEY"); + config.rpc_url = env("POLYGON_RPC_ENDPOINT"); + if (config.private_key.empty() || config.rpc_url.empty()) + { + std::cout << "PRIVATE_KEY or POLYGON_RPC_ENDPOINT not set; skipping approvals example.\n"; + return 0; + } + config.funder_address = env("POLYMARKET_PROXY_ADDRESS"); + if (!config.funder_address.empty()) + { + config.wallet_type = SignatureType::POLY_GNOSIS_SAFE; + config.relayer_api_key = env("RELAYER_API_KEY"); + config.relayer_api_key_address = env("RELAYER_API_KEY_ADDRESS"); + } + + try + { + PositionClient client(config); + const auto state = client.get_trading_approvals_state(); + std::cout << "wallet " << client.wallet_address() + << (config.wallet_type == SignatureType::EOA ? " (EOA)" : " (Safe via relayer)") + << '\n'; + for (const auto &approval : state.missing.erc20) + std::cout << "missing ERC-20 " << approval.token_address << " -> " << approval.spender + << '\n'; + for (const auto &approval : state.missing.erc1155) + std::cout << "missing ERC-1155 " << approval.token_address << " -> " + << approval.operator_address << '\n'; + if (state.is_fully_approved) + { + std::cout << "all trading approvals are set\n"; + return 0; + } + if (!execute) + { + std::cout << "dry run: pass --execute to grant " + << state.missing.erc20.size() + state.missing.erc1155.size() + << " approval(s)\n"; + return 0; + } + + std::cout << "granting approvals and waiting for confirmation...\n"; + const auto outcome = client.setup_trading_approvals(); + if (outcome) + std::cout << "confirmed " << outcome->transaction_hash << " in block " + << outcome->receipt.block_number << '\n'; + } + catch (const PartialBatchError &error) + { + // EOA only: approvals already mined stay in place; rerun to finish. + std::cerr << "error: " << error.what() << '\n'; + for (const auto &hash : error.submitted_hashes()) + std::cerr << "already sent: " << hash << '\n'; + return 1; + } + catch (const std::exception &error) + { + std::cerr << "error: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/include/polymarket/polymarket_contracts.hpp b/include/polymarket/polymarket_contracts.hpp index 094ebe1..f83f4ca 100644 --- a/include/polymarket/polymarket_contracts.hpp +++ b/include/polymarket/polymarket_contracts.hpp @@ -35,6 +35,9 @@ namespace polymarket std::string combinatorial_module; std::string auto_redeem_operator; + // Perps collateral deposits; a pUSD spender in the trading approval set + std::string perps_deposit_contract; + // Wallet infrastructure std::string proxy_factory; std::string proxy_implementation; diff --git a/include/polymarket/position_client.hpp b/include/polymarket/position_client.hpp index dcd6550..3f43fe0 100644 --- a/include/polymarket/position_client.hpp +++ b/include/polymarket/position_client.hpp @@ -3,11 +3,13 @@ #include "polymarket/json_rpc_client.hpp" #include "polymarket/order_signer.hpp" #include "polymarket/polymarket_contracts.hpp" +#include "polymarket/trading_approvals.hpp" #include #include #include #include #include +#include #include #include #include @@ -173,8 +175,9 @@ namespace polymarket // base units (pUSD and outcome tokens use 6 decimals) as base-10 strings. // condition_id is 0x + 32 bytes of hex, or 31 bytes for a Protocol V2 market. // - // Approvals are not checked: split needs a pUSD allowance for the operator - // contract, and merge/redeem need ERC-1155 approval for it. + // The operations do not check approvals: split needs a pUSD allowance for + // the operator contract, and merge/redeem need ERC-1155 approval for it. + // setup_trading_approvals() grants all of them once per wallet. class PositionClient { public: @@ -210,6 +213,38 @@ namespace polymarket // On-chain [yes, no] balances of the wallet, in base units (decimal). std::array position_balances(const MarketPositionContext &market); + // Which of required_trading_approvals() the wallet (default: this + // client's wallet) still lacks, read with one eth_call per approval. + TradingApprovalsState + get_trading_approvals_state(const std::optional &wallet = std::nullopt); + + // Grants the missing trading approvals and waits for them to be mined. + // A Safe sends them as one atomic MultiSend; an EOA sends one + // transaction per approval (see merge_multiple_positions). Returns + // nullopt without sending anything when the wallet is fully approved. + std::optional + setup_trading_approvals(std::chrono::milliseconds timeout = std::chrono::minutes(3)); + + // ERC-20 approve(spender, amount). amount is a uint256 in base units, + // or "max"; "0" revokes. + TransactionHandle approve_erc20(const std::string &token_address, + const std::string &spender_address, + const std::string &amount, + const std::string &metadata = ""); + + // ERC-1155 setApprovalForAll(operator, approved). + TransactionHandle approve_erc1155_for_all(const std::string &token_address, + const std::string &operator_address, + bool approved = true, + const std::string &metadata = ""); + + // ERC-20 transfer(recipient, amount) from the wallet; amount is a + // positive uint256 in base units. + TransactionHandle transfer_erc20(const std::string &token_address, + const std::string &recipient_address, + const std::string &amount, + const std::string &metadata = ""); + // Low-level: send arbitrary calls from the wallet. metadata (at most // 500 characters) is attached to relayer submissions. TransactionHandle execute_calls(const std::vector &calls, const std::string &metadata = ""); diff --git a/include/polymarket/trading_approvals.hpp b/include/polymarket/trading_approvals.hpp new file mode 100644 index 0000000..d27e418 --- /dev/null +++ b/include/polymarket/trading_approvals.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "polymarket/polymarket_contracts.hpp" +#include +#include + +namespace polymarket +{ + // 2^256 - 1, the allowance the trading approval set grants. + inline constexpr const char *max_uint256 = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + + // An ERC-20 allowance of `amount` (base-10 base units) from the wallet to `spender`. + struct Erc20TradingApproval + { + std::string token_address; + std::string spender; + std::string amount; + }; + + // ERC-1155 setApprovalForAll(operator_address, true) by the wallet on `token_address`. + struct Erc1155TradingApproval + { + std::string token_address; + std::string operator_address; + }; + + // A set of approvals: ERC-20 allowances first, then ERC-1155 operators, + // in the order the official SDKs submit them. + struct TradingApprovals + { + std::vector erc20; + std::vector erc1155; + + bool empty() const { return erc20.empty() && erc1155.empty(); } + }; + + struct TradingApprovalsState + { + TradingApprovals missing; // only the approvals the wallet still lacks + bool is_fully_approved{false}; + }; + + // Every approval a wallet needs before it can trade, split, merge and + // redeem: pUSD allowances (max) for the exchanges, collateral adapters, V2 + // router, exchange V3 and perps deposit contract, and ERC-1155 operator + // approval on ConditionalTokens and the V2 position manager. Matches + // py-sdk and ts-sdk setup_trading_approvals. + TradingApprovals required_trading_approvals(const PolymarketContracts &contracts); + +} // namespace polymarket diff --git a/src/approval_calls.cpp b/src/approval_calls.cpp new file mode 100644 index 0000000..95eaacb --- /dev/null +++ b/src/approval_calls.cpp @@ -0,0 +1,196 @@ +#include "approval_calls.hpp" +#include "evm_uint.hpp" +#include "polymarket/evm_abi.hpp" +#include "polymarket/evm_utils.hpp" +#include +#include + +namespace polymarket::detail +{ + namespace + { + std::string single_word(const std::string &return_data, const std::string &context) + { + std::vector words; + try + { + words = evm_data_words(return_data); + } + catch (const std::invalid_argument &error) + { + throw std::runtime_error("invalid " + context + " result: " + error.what()); + } + if (words.size() != 1) + throw std::runtime_error("invalid " + context + " result: expected one word, got " + + std::to_string(words.size())); + return words[0]; + } + + std::vector amount_word(const std::string &amount, const char *label) + { + try + { + return parse_uint256_word(amount); + } + catch (const std::invalid_argument &error) + { + throw std::invalid_argument(std::string(label) + ": " + error.what()); + } + } + } // namespace + + void require_address(const std::string &value, const char *label) + { + bool ok = value.size() == 42 && value.rfind("0x", 0) == 0; + for (size_t i = 2; ok && i < value.size(); ++i) + ok = std::isxdigit(static_cast(value[i])) != 0; + if (!ok) + throw std::invalid_argument(std::string(label) + + " must be 0x followed by 20 bytes of hex, got \"" + value + + "\""); + } + + std::string resolve_approval_amount(const std::string &amount) + { + if (amount == "max") return max_uint256; + (void)amount_word(amount, "approval amount"); + return amount; + } + + ContractCall erc20_approve_call(const std::string &token, const std::string &spender, + const std::string &amount) + { + (void)amount_word(amount, "approval amount"); + return {token, + evm_abi_encode_call("approve(address,uint256)", + {EvmAbiValue::address(spender), EvmAbiValue::uint256(amount)})}; + } + + ContractCall erc20_transfer_call(const std::string &token, const std::string &recipient, + const std::string &amount) + { + (void)amount_word(amount, "transfer amount"); + return {token, + evm_abi_encode_call("transfer(address,uint256)", {EvmAbiValue::address(recipient), + EvmAbiValue::uint256(amount)})}; + } + + ContractCall erc1155_set_approval_for_all_call(const std::string &token, + const std::string &operator_address, + bool approved) + { + return {token, evm_abi_encode_call("setApprovalForAll(address,bool)", + {EvmAbiValue::address(operator_address), + EvmAbiValue::boolean(approved)})}; + } + + ContractCall erc20_allowance_call(const std::string &token, const std::string &owner, + const std::string &spender) + { + return {token, + evm_abi_encode_call("allowance(address,address)", + {EvmAbiValue::address(owner), EvmAbiValue::address(spender)})}; + } + + ContractCall erc1155_is_approved_for_all_call(const std::string &token, + const std::string &owner, + const std::string &operator_address) + { + return {token, evm_abi_encode_call( + "isApprovedForAll(address,address)", + {EvmAbiValue::address(owner), EvmAbiValue::address(operator_address)})}; + } + + std::string decode_uint256_result(const std::string &return_data, const std::string &context) + { + return evm_uint_word_to_decimal(single_word(return_data, context)); + } + + bool decode_bool_result(const std::string &return_data, const std::string &context) + { + const auto value = decode_uint256_result(return_data, context); + if (value != "0" && value != "1") + throw std::runtime_error("invalid " + context + " result: not a bool"); + return value == "1"; + } + + std::vector trading_approval_check_calls(const TradingApprovals &required, + const std::string &owner) + { + std::vector calls; + calls.reserve(required.erc20.size() + required.erc1155.size()); + for (const auto &approval : required.erc20) + calls.push_back(erc20_allowance_call(approval.token_address, owner, approval.spender)); + for (const auto &approval : required.erc1155) + calls.push_back(erc1155_is_approved_for_all_call(approval.token_address, owner, + approval.operator_address)); + return calls; + } + + TradingApprovalsState + trading_approvals_state_from_results(const TradingApprovals &required, + const std::vector &results) + { + if (results.size() != required.erc20.size() + required.erc1155.size()) + throw std::logic_error("trading approval results do not match the checks"); + TradingApprovalsState state; + size_t index = 0; + for (const auto &approval : required.erc20) + { + const auto context = + "allowance(" + approval.token_address + ", " + approval.spender + ")"; + const auto allowance = decode_uint256_result(results[index++], context); + if (parse_uint256_word(allowance) < amount_word(approval.amount, "required amount")) + state.missing.erc20.push_back(approval); + } + for (const auto &approval : required.erc1155) + { + const auto context = "isApprovedForAll(" + approval.token_address + ", " + + approval.operator_address + ")"; + if (!decode_bool_result(results[index++], context)) + state.missing.erc1155.push_back(approval); + } + state.is_fully_approved = state.missing.empty(); + return state; + } + + std::vector trading_approval_calls(const TradingApprovals &missing) + { + std::vector calls; + calls.reserve(missing.erc20.size() + missing.erc1155.size()); + for (const auto &approval : missing.erc20) + calls.push_back( + erc20_approve_call(approval.token_address, approval.spender, approval.amount)); + for (const auto &approval : missing.erc1155) + calls.push_back(erc1155_set_approval_for_all_call(approval.token_address, + approval.operator_address, true)); + return calls; + } +} // namespace polymarket::detail + +namespace polymarket +{ + TradingApprovals required_trading_approvals(const PolymarketContracts &contracts) + { + const auto &pusd = contracts.collateral_token; + const auto &ctf = contracts.conditional_tokens; + const auto &positions = contracts.position_manager; + TradingApprovals required; + for (const auto *spender : + {&contracts.standard_exchange, &contracts.neg_risk_exchange, + &contracts.collateral_adapter, &contracts.neg_risk_collateral_adapter, + &contracts.protocol_v2_router, &contracts.exchange_v3, + &contracts.perps_deposit_contract}) + required.erc20.push_back({pusd, *spender, max_uint256}); + for (const auto *operator_address : + {&contracts.standard_exchange, &contracts.neg_risk_exchange, + &contracts.collateral_adapter, &contracts.neg_risk_collateral_adapter, + &contracts.auto_redeem_operator, &contracts.binary_module, + &contracts.neg_risk_module}) + required.erc1155.push_back({ctf, *operator_address}); + for (const auto *operator_address : {&contracts.protocol_v2_router, &contracts.exchange_v3, + &contracts.auto_redeem_operator}) + required.erc1155.push_back({positions, *operator_address}); + return required; + } +} // namespace polymarket diff --git a/src/approval_calls.hpp b/src/approval_calls.hpp new file mode 100644 index 0000000..0b33bd9 --- /dev/null +++ b/src/approval_calls.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "polymarket/position_client.hpp" +#include "polymarket/trading_approvals.hpp" +#include +#include + +// Calldata builders and result decoding for token approvals and transfers. +// Mirrors py-sdk _internal/actions/relayer/{calls,approvals}.py. +namespace polymarket::detail +{ + // Throws std::invalid_argument naming `label` unless value is 0x + 20 bytes of hex. + void require_address(const std::string &value, const char *label); + + // "max" -> max_uint256; otherwise a uint256 (base-10 or 0x-hex) as given. + std::string resolve_approval_amount(const std::string &amount); + + ContractCall erc20_approve_call(const std::string &token, const std::string &spender, + const std::string &amount); + ContractCall erc20_transfer_call(const std::string &token, const std::string &recipient, + const std::string &amount); + ContractCall erc1155_set_approval_for_all_call(const std::string &token, + const std::string &operator_address, + bool approved); + + // Read-only checks for eth_call. + ContractCall erc20_allowance_call(const std::string &token, const std::string &owner, + const std::string &spender); + ContractCall erc1155_is_approved_for_all_call(const std::string &token, + const std::string &owner, + const std::string &operator_address); + + // Single-word return values. Throw std::runtime_error naming `context` on + // anything but exactly one word (a bool must be 0 or 1). + std::string decode_uint256_result(const std::string &return_data, const std::string &context); + bool decode_bool_result(const std::string &return_data, const std::string &context); + + // One eth_call per approval, ERC-20 checks first, in `required` order. + std::vector trading_approval_check_calls(const TradingApprovals &required, + const std::string &owner); + // Pairs results with trading_approval_check_calls. An ERC-20 approval is + // missing when its allowance is below the required amount. + TradingApprovalsState + trading_approvals_state_from_results(const TradingApprovals &required, + const std::vector &results); + + // approve calls for the missing ERC-20 allowances, then setApprovalForAll(true) calls. + std::vector trading_approval_calls(const TradingApprovals &missing); +} // namespace polymarket::detail diff --git a/src/polymarket_contracts.cpp b/src/polymarket_contracts.cpp index 4f74377..3b36874 100644 --- a/src/polymarket_contracts.cpp +++ b/src/polymarket_contracts.cpp @@ -46,6 +46,7 @@ namespace polymarket c.neg_risk_module = "0x200000900045e3B6259600682756002200028933"; c.combinatorial_module = "0x30000034706c7d8e12009dab006be20000c031a8"; c.auto_redeem_operator = "0xa1200000d0002264C9a1698e001292D00E1b00af"; + c.perps_deposit_contract = "0xDCa4af75705dbB50f62437045afF9921947917d2"; c.proxy_factory = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"; c.proxy_implementation = "0x44e999d5c2F66Ef0861317f9A4805AC2e90aEB4f"; c.safe_factory = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"; @@ -84,6 +85,7 @@ namespace polymarket {"neg_risk_module", &neg_risk_module}, {"combinatorial_module", &combinatorial_module}, {"auto_redeem_operator", &auto_redeem_operator}, + {"perps_deposit_contract", &perps_deposit_contract}, {"proxy_factory", &proxy_factory}, {"proxy_implementation", &proxy_implementation}, {"safe_factory", &safe_factory}, diff --git a/src/trading_approvals.cpp b/src/trading_approvals.cpp new file mode 100644 index 0000000..a218720 --- /dev/null +++ b/src/trading_approvals.cpp @@ -0,0 +1,74 @@ +#include "approval_calls.hpp" +#include "polymarket/position_client.hpp" +#include "position_calls.hpp" +#include "position_client_impl.hpp" + +namespace polymarket +{ + TradingApprovalsState + PositionClient::get_trading_approvals_state(const std::optional &wallet) + { + const auto owner = wallet.value_or(wallet_address()); + detail::require_address(owner, "wallet"); + const auto required = required_trading_approvals(impl_->contracts); + std::vector results; + for (const auto &call : detail::trading_approval_check_calls(required, owner)) + results.push_back(impl_->rpc->eth_call({call.to, call.data, "", ""})); + return detail::trading_approvals_state_from_results(required, results); + } + + std::optional + PositionClient::setup_trading_approvals(std::chrono::milliseconds timeout) + { + // Read over RPC rather than from indexed data, which can lag recent grants. + const auto state = get_trading_approvals_state(); + if (state.is_fully_approved) return std::nullopt; + return execute_calls(detail::trading_approval_calls(state.missing), + "Trading setup approvals") + .wait(timeout); + } + + TransactionHandle PositionClient::approve_erc20(const std::string &token_address, + const std::string &spender_address, + const std::string &amount, + const std::string &metadata) + { + detail::require_address(token_address, "token_address"); + detail::require_address(spender_address, "spender_address"); + const auto call = detail::erc20_approve_call(token_address, spender_address, + detail::resolve_approval_amount(amount)); + return execute_calls({call}, metadata.empty() ? "Approve " + amount + " of " + + token_address + " to " + spender_address + : metadata); + } + + TransactionHandle PositionClient::approve_erc1155_for_all(const std::string &token_address, + const std::string &operator_address, + bool approved, + const std::string &metadata) + { + detail::require_address(token_address, "token_address"); + detail::require_address(operator_address, "operator_address"); + const auto call = + detail::erc1155_set_approval_for_all_call(token_address, operator_address, approved); + return execute_calls({call}, metadata.empty() + ? std::string(approved ? "Approve " : "Revoke ") + + operator_address + " on " + token_address + : metadata); + } + + TransactionHandle PositionClient::transfer_erc20(const std::string &token_address, + const std::string &recipient_address, + const std::string &amount, + const std::string &metadata) + { + detail::require_address(token_address, "token_address"); + detail::require_address(recipient_address, "recipient_address"); + detail::require_positive_amount(amount, "transfer amount"); + const auto call = detail::erc20_transfer_call(token_address, recipient_address, amount); + return execute_calls({call}, metadata.empty() + ? "Transfer " + amount + " of " + token_address + " to " + + recipient_address + : metadata); + } +} // namespace polymarket diff --git a/tests/test_approval_calls.cpp b/tests/test_approval_calls.cpp new file mode 100644 index 0000000..446bf09 --- /dev/null +++ b/tests/test_approval_calls.cpp @@ -0,0 +1,174 @@ +// Approval and transfer calldata, the required approval set, and state +// decoding. Calldata vectors are from py-sdk +// tests/unit/test_relayer_call_encoders_golden.py (b543c9d). +#include "approval_calls.hpp" +#include "check_support.hpp" +#include "polymarket/evm_abi.hpp" + +using namespace polymarket; +using check_support::check; +using check_support::expect_equal; +using check_support::expect_throws; + +namespace +{ + const std::string usdc_e = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; + const std::string ctf = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"; + const std::string neg_risk_adapter = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"; + const std::string dead = "0x000000000000000000000000000000000000dEaD"; + const std::string owner = "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F"; + const auto mainnet = PolymarketContracts::polygon_mainnet(); + + std::string word(const std::string &value) + { + return to_hex(evm_abi_encode({EvmAbiValue::uint256(value)})); + } + + void golden_calldata() + { + const auto approve = detail::erc20_approve_call(usdc_e, ctf, max_uint256); + check(approve.to == usdc_e && approve.value == "0", "approve targets the token"); + expect_equal("approve(CTF, max)", approve.data, + "0x095ea7b3" + "0000000000000000000000004d97dcd97ec945f40cf65f87097ace5ea0476045" + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + expect_equal("transfer(dead, 1)", detail::erc20_transfer_call(usdc_e, dead, "1").data, + "0xa9059cbb" + "000000000000000000000000000000000000000000000000000000000000dead" + "0000000000000000000000000000000000000000000000000000000000000001"); + expect_equal("setApprovalForAll(adapter, true)", + detail::erc1155_set_approval_for_all_call(ctf, neg_risk_adapter, true).data, + "0xa22cb465" + "000000000000000000000000d91e80cf2e7be2e162c6513ced06f1dd0da35296" + "0000000000000000000000000000000000000000000000000000000000000001"); + expect_equal("setApprovalForAll(adapter, false)", + detail::erc1155_set_approval_for_all_call(ctf, neg_risk_adapter, false).data, + "0xa22cb465" + "000000000000000000000000d91e80cf2e7be2e162c6513ced06f1dd0da35296" + "0000000000000000000000000000000000000000000000000000000000000000"); + expect_equal("allowance(owner, CTF)", detail::erc20_allowance_call(usdc_e, owner, ctf).data, + "0xdd62ed3e" + "0000000000000000000000009d8a62f656a8d1615c1294fd71e9cfb3e4855a4f" + "0000000000000000000000004d97dcd97ec945f40cf65f87097ace5ea0476045"); + expect_equal("isApprovedForAll(owner, adapter)", + detail::erc1155_is_approved_for_all_call(ctf, owner, neg_risk_adapter).data, + "0xe985e9c5" + "0000000000000000000000009d8a62f656a8d1615c1294fd71e9cfb3e4855a4f" + "000000000000000000000000d91e80cf2e7be2e162c6513ced06f1dd0da35296"); + } + + void required_set() + { + const auto required = required_trading_approvals(mainnet); + check(required.erc20.size() == 7 && required.erc1155.size() == 10, + "7 ERC-20 + 10 ERC-1155 approvals"); + const std::vector spenders = {mainnet.standard_exchange, + mainnet.neg_risk_exchange, + mainnet.collateral_adapter, + mainnet.neg_risk_collateral_adapter, + mainnet.protocol_v2_router, + mainnet.exchange_v3, + "0xDCa4af75705dbB50f62437045afF9921947917d2"}; + for (size_t i = 0; i < required.erc20.size() && i < spenders.size(); ++i) + { + const auto &a = required.erc20[i]; + check(a.token_address == mainnet.collateral_token && a.spender == spenders[i] && + a.amount == max_uint256, + "ERC-20 approval " + std::to_string(i)); + } + const std::vector> operators = { + {ctf, mainnet.standard_exchange}, + {ctf, mainnet.neg_risk_exchange}, + {ctf, mainnet.collateral_adapter}, + {ctf, mainnet.neg_risk_collateral_adapter}, + {ctf, mainnet.auto_redeem_operator}, + {ctf, mainnet.binary_module}, + {ctf, mainnet.neg_risk_module}, + {mainnet.position_manager, mainnet.protocol_v2_router}, + {mainnet.position_manager, mainnet.exchange_v3}, + {mainnet.position_manager, mainnet.auto_redeem_operator}}; + for (size_t i = 0; i < required.erc1155.size() && i < operators.size(); ++i) + { + const auto &a = required.erc1155[i]; + check(a.token_address == operators[i].first && + a.operator_address == operators[i].second, + "ERC-1155 approval " + std::to_string(i)); + } + check(word(max_uint256) == "0x" + std::string(64, 'f'), "max_uint256 is 2^256 - 1"); + } + + void state_from_results() + { + const auto required = required_trading_approvals(mainnet); + const auto checks = detail::trading_approval_check_calls(required, owner); + check(checks.size() == 17 && checks[0].to == mainnet.collateral_token && + checks[0].data == detail::erc20_allowance_call(mainnet.collateral_token, owner, + mainnet.standard_exchange) + .data && + checks[16].to == mainnet.position_manager, + "check calls follow the required order"); + + std::vector results(7, word(max_uint256)); + results.resize(17, word("1")); + auto state = detail::trading_approvals_state_from_results(required, results); + check(state.is_fully_approved && state.missing.empty(), "everything approved"); + + // A large but finite allowance is still missing, as in the official SDKs. + results[2] = word("1000000000000"); + results[0] = word("0"); + results[9] = word("0"); + state = detail::trading_approvals_state_from_results(required, results); + check(!state.is_fully_approved && state.missing.erc20.size() == 2 && + state.missing.erc1155.size() == 1, + "missing counts"); + check(state.missing.erc20[0].spender == mainnet.standard_exchange && + state.missing.erc20[1].spender == mainnet.collateral_adapter && + state.missing.erc1155[0].operator_address == mainnet.collateral_adapter, + "missing entries keep the required order"); + + const auto calls = detail::trading_approval_calls(state.missing); + check(calls.size() == 3 && calls[0].data.starts_with("0x095ea7b3") && + calls[1].data.starts_with("0x095ea7b3") && calls[2].to == ctf && + calls[2].data.ends_with(std::string(63, '0') + "1"), + "approve calls first, then setApprovalForAll(true)"); + + auto bad = results; + bad[0] = "0x"; + expect_throws( + "empty eth_call result (no contract)", + [&] { (void)detail::trading_approvals_state_from_results(required, bad); }); + bad = results; + bad[10] = word("2"); + expect_throws( + "non-bool isApprovedForAll", + [&] { (void)detail::trading_approvals_state_from_results(required, bad); }); + } + + void input_validation() + { + expect_equal("max amount", detail::resolve_approval_amount("max"), max_uint256); + expect_equal("zero amount revokes", detail::resolve_approval_amount("0"), "0"); + expect_throws("negative amount", + [] { (void)detail::resolve_approval_amount("-1"); }); + expect_throws( + "amount above uint256", + [] { (void)detail::resolve_approval_amount(std::string(max_uint256) + "0"); }); + detail::require_address(owner, "wallet"); + expect_throws( + "unprefixed address", [] { detail::require_address(owner.substr(2), "wallet"); }); + expect_throws( + "short address", [] { detail::require_address(owner.substr(0, 40), "wallet"); }); + expect_throws( + "non-hex address", + [] { detail::require_address("0x" + std::string(40, 'g'), "wallet"); }); + } +} // namespace + +int main() +{ + golden_calldata(); + required_set(); + state_from_results(); + input_validation(); + return check_support::finish("test_approval_calls"); +} diff --git a/tests/test_polymarket_contracts.cpp b/tests/test_polymarket_contracts.cpp index ad03029..93bfb7c 100644 --- a/tests/test_polymarket_contracts.cpp +++ b/tests/test_polymarket_contracts.cpp @@ -80,6 +80,7 @@ namespace {"neg_risk_module", c.neg_risk_module}, {"combinatorial_module", c.combinatorial_module}, {"auto_redeem_operator", c.auto_redeem_operator}, + {"perps_deposit_contract", c.perps_deposit_contract}, {"proxy_factory", c.proxy_factory}, {"proxy_implementation", c.proxy_implementation}, {"safe_factory", c.safe_factory}, @@ -108,6 +109,8 @@ namespace "neg-risk collateral adapter"); check(c.protocol_v2_router == "0x12121212006e4CD160D18e3f00711DA5c3372600", "V2 router address"); check(c.safe_multisend == "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761", "Safe MultiSend address"); + check(c.perps_deposit_contract == "0xDCa4af75705dbB50f62437045afF9921947917d2", + "perps deposit address"); check(c.exchange(false) == c.standard_exchange && c.exchange(true) == c.neg_risk_exchange, "exchange selector"); diff --git a/tests/test_trading_approvals.cpp b/tests/test_trading_approvals.cpp new file mode 100644 index 0000000..c2d08a0 --- /dev/null +++ b/tests/test_trading_approvals.cpp @@ -0,0 +1,254 @@ +// PositionClient trading approvals, approvals and transfers against scripted +// RPC node and relayer servers, for EOA and Safe wallets. +#include "approval_calls.hpp" +#include "position_test_support.hpp" +#include "safe_relayer.hpp" + +using namespace position_test; + +namespace +{ + const std::string recipient = "0x000000000000000000000000000000000000dEaD"; + const auto required_set = required_trading_approvals(kContracts); + + std::string word(const std::string &value) + { + return to_hex(evm_abi_encode({EvmAbiValue::uint256(value)})); + } + + // Answers the 17 approval checks for `owner`. Results are given in + // required order: ERC-20 allowances, then isApprovedForAll flags. + void expect_approval_reads(clob_test::LocalServer &node, const std::string &owner, + const std::vector &results) + { + const auto checks = detail::trading_approval_check_calls(required_set, owner); + for (size_t i = 0; i < checks.size(); ++i) + { + const auto &expected = checks[i]; + const auto &result = results.at(i); + expect_rpc(node, "eth_call", + [expected, result, i](const nlohmann::json ¶ms) + { + check(params.at(0).at("to") == expected.to && + params.at(0).at("data") == expected.data && + params.at(1) == "latest", + "approval check " + std::to_string(i) + " " + params.dump()); + return nlohmann::json(result); + }); + } + } + + std::vector all_approved() + { + std::vector results(required_set.erc20.size(), word(max_uint256)); + results.resize(required_set.erc20.size() + required_set.erc1155.size(), word("1")); + return results; + } + + void expect_transaction(clob_test::LocalServer &node, const ContractCall &call, + bool first_transaction) + { + if (first_transaction) expect_rpc(node, "eth_chainId", "0x89"); + expect_rpc(node, "eth_getTransactionCount", "0x7"); + expect_rpc(node, "eth_gasPrice", "0x6fc23ac00"); + expect_rpc(node, "eth_estimateGas", + [call](const nlohmann::json ¶ms) + { + const auto &request = params.at(0); + check(request.at("to") == call.to && request.at("from") == kWallet && + request.at("data") == call.data, + "estimate call " + request.dump() + " expected data " + call.data); + return nlohmann::json("0x3d090"); + }); + expect_rpc(node, "eth_sendRawTransaction", + [](const nlohmann::json ¶ms) + { + return nlohmann::json( + to_hex(keccak256(from_hex(params.at(0).get())))); + }); + } + + void expect_mined(clob_test::LocalServer &node) + { + expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json ¶ms) + { return receipt(params.at(0).get(), true); }); + } + + PositionClientConfig base_config(const std::string &node_url) + { + PositionClientConfig config; + config.private_key = kKey; + config.rpc_url = node_url; + config.rpc_timeout_ms = 5000; + return config; + } + + size_t count(const clob_test::LocalServer &node, const std::string &method) + { + size_t n = 0; + for (const auto &request : node.requests()) + n += nlohmann::json::parse(request.body).at("method") == method; + return n; + } + + void state_reads() + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + + expect_approval_reads(node, kWallet, all_approved()); + auto state = client.get_trading_approvals_state(); + check(state.is_fully_approved && state.missing.empty(), "fully approved EOA"); + + auto results = all_approved(); + results[5] = word("5"); // exchange_v3 allowance below max + results[16] = word("0"); // position_manager -> auto_redeem_operator + expect_approval_reads(node, recipient, results); + state = client.get_trading_approvals_state(recipient); + check(!state.is_fully_approved && state.missing.erc20.size() == 1 && + state.missing.erc20[0].spender == kContracts.exchange_v3 && + state.missing.erc1155.size() == 1 && + state.missing.erc1155[0].token_address == kContracts.position_manager && + state.missing.erc1155[0].operator_address == kContracts.auto_redeem_operator, + "missing approvals for another wallet"); + check(count(node, "eth_sendRawTransaction") == 0, "reads send nothing"); + + expect_throws( + "invalid wallet", + [&] { (void)client.get_trading_approvals_state(std::string("0x1234")); }); + } + + void eoa_setup() + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + + expect_approval_reads(node, kWallet, all_approved()); + check(!client.setup_trading_approvals().has_value(), "nothing to set up"); + check(count(node, "eth_sendRawTransaction") == 0 && count(node, "eth_chainId") == 0, + "fully approved wallet submits nothing"); + + auto results = all_approved(); + results[1] = word("0"); // neg_risk_exchange allowance + results[7] = word("0"); // CTF -> standard_exchange + expect_approval_reads(node, kWallet, results); + const auto approve = detail::erc20_approve_call(kContracts.collateral_token, + kContracts.neg_risk_exchange, max_uint256); + const auto operator_call = detail::erc1155_set_approval_for_all_call( + kContracts.conditional_tokens, kContracts.standard_exchange, true); + expect_transaction(node, approve, true); + expect_mined(node); // the approve lands before the next is sent + expect_transaction(node, operator_call, false); + expect_mined(node); + const auto outcome = client.setup_trading_approvals(std::chrono::seconds(5)); + check(outcome.has_value() && outcome->receipt.success && outcome->transaction_id.empty(), + "EOA setup waits for the last transaction"); + check(count(node, "eth_sendRawTransaction") == 2, "one transaction per missing approval"); + } + + void eoa_single_calls() + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + const auto &pusd = kContracts.collateral_token; + + expect_transaction( + node, detail::erc20_approve_call(pusd, kContracts.exchange_v3, max_uint256), true); + auto handle = client.approve_erc20(pusd, kContracts.exchange_v3, "max"); + check(handle.transaction_hashes().size() == 1, "approve_erc20 handle"); + + expect_transaction(node, detail::erc20_approve_call(pusd, kContracts.exchange_v3, "0"), + false); + (void)client.approve_erc20(pusd, kContracts.exchange_v3, "0"); + + expect_transaction(node, + detail::erc1155_set_approval_for_all_call( + kContracts.position_manager, kContracts.protocol_v2_router, false), + false); + (void)client.approve_erc1155_for_all(kContracts.position_manager, + kContracts.protocol_v2_router, false); + + expect_transaction(node, detail::erc20_transfer_call(pusd, recipient, "1500000"), false); + handle = client.transfer_erc20(pusd, recipient, "1500000"); + expect_mined(node); + check(handle.wait(std::chrono::seconds(5), std::chrono::milliseconds(5)).receipt.success, + "transfer mined"); + + expect_throws( + "zero transfer", [&] { (void)client.transfer_erc20(pusd, recipient, "0"); }); + expect_throws("bad recipient", [&] + { (void)client.transfer_erc20(pusd, "dead", "1"); }); + expect_throws("bad spender", + [&] { (void)client.approve_erc20(pusd, "", "max"); }); + expect_throws( + "bad approval amount", [&] { (void)client.approve_erc20(pusd, recipient, "1.5"); }); + expect_throws( + "bad operator", + [&] { (void)client.approve_erc1155_for_all(kContracts.conditional_tokens, "0x"); }); + check(count(node, "eth_sendRawTransaction") == 4, "invalid input sends nothing"); + } + + void safe_setup_is_one_multisend() + { + clob_test::LocalServer node; + clob_test::LocalServer relayer; + auto config = base_config(node.url()); + config.wallet_type = SignatureType::POLY_GNOSIS_SAFE; + config.relayer_url = relayer.url(); + config.relayer_api_key = "relayer-key"; + PositionClient client(config); + + auto results = all_approved(); + results[0] = word("0"); + results[4] = word("0"); + results[14] = word("0"); + expect_approval_reads(node, kSafe, results); + expect_rpc(node, "eth_chainId", "0x89"); + relayer.enqueue(nlohmann::json{{"address", kSafe}, {"nonce", "5"}}.dump()); + const auto expected = detail::resolve_safe_call( + {detail::erc20_approve_call(kContracts.collateral_token, kContracts.standard_exchange, + max_uint256), + detail::erc20_approve_call(kContracts.collateral_token, kContracts.protocol_v2_router, + max_uint256), + detail::erc1155_set_approval_for_all_call(kContracts.position_manager, + kContracts.protocol_v2_router, true)}, + kContracts); + relayer.enqueue( + [expected](const clob_test::Request &request) + { + const auto body = nlohmann::json::parse(request.body); + check(request.target == "/submit" && body.at("to") == kContracts.safe_multisend && + body.at("data") == expected.call.data && + body.at("proxyWallet") == kSafe && + body.at("signatureParams").at("operation") == "1" && + body.at("metadata") == "Trading setup approvals", + "setup submit " + body.dump()); + return nlohmann::json{{"transactionID", "tx-9"}, {"state", "STATE_NEW"}}.dump(); + }); + const std::string mined = "0x" + std::string(64, 'e'); + relayer.enqueue(nlohmann::json{ + {"transaction_id", "tx-9"}, + {"transaction_hash", mined}, + {"state", "STATE_CONFIRMED"}, + {"error_msg", + nullptr}}.dump()); + expect_mined(node); + const auto outcome = client.setup_trading_approvals(std::chrono::seconds(5)); + check(outcome.has_value() && outcome->transaction_id == "tx-9" && + outcome->transaction_hash == mined, + "Safe setup outcome"); + size_t submits = 0; + for (const auto &request : relayer.requests()) + submits += request.target == "/submit"; + check(submits == 1, "one relayer submission for every missing approval"); + } +} // namespace + +int main() +{ + state_reads(); + eoa_setup(); + eoa_single_calls(); + safe_setup_is_one_multisend(); + return check_support::finish("test_trading_approvals"); +} From cc3e3b0c24fc998a6d3dcde1abddcca8b3b6e5de Mon Sep 17 00:00:00 2001 From: yluoc Date: Mon, 5 Oct 2026 22:56:28 -0400 Subject: [PATCH 2/6] refactor(position): share uint256 parsing and cover approval setup failures --- cmake/PolymarketTests.cmake | 5 + docs/position-operations.md | 18 ++-- src/approval_calls.cpp | 22 ++-- src/position_calls.cpp | 35 ++++--- src/position_calls.hpp | 5 + tests/test_trading_approvals.cpp | 93 +---------------- tests/test_trading_approvals_failures.cpp | 103 ++++++++++++++++++ tests/trading_approvals_test_support.hpp | 121 ++++++++++++++++++++++ 8 files changed, 270 insertions(+), 132 deletions(-) create mode 100644 tests/test_trading_approvals_failures.cpp create mode 100644 tests/trading_approvals_test_support.hpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 573dbd1..33ddc71 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -52,6 +52,11 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_trading_approvals PRIVATE polymarket::client) add_test(NAME test_trading_approvals COMMAND test_trading_approvals) + add_executable(test_trading_approvals_failures tests/test_trading_approvals_failures.cpp) + target_include_directories(test_trading_approvals_failures PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_trading_approvals_failures PRIVATE polymarket::client) + add_test(NAME test_trading_approvals_failures COMMAND test_trading_approvals_failures) + add_executable(test_transaction_waiters tests/test_transaction_waiters.cpp) target_include_directories(test_transaction_waiters PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_transaction_waiters PRIVATE polymarket::client) diff --git a/docs/position-operations.md b/docs/position-operations.md index cf10d89..2a06437 100644 --- a/docs/position-operations.md +++ b/docs/position-operations.md @@ -5,12 +5,12 @@ binary market positions on Polygon, matching `split_position`, `merge_positions`, `merge_multiple_positions` and `redeem_positions` in the official Python SDK. -| Operation | Effect | -| ----------------------------------------------- | ------------------------------------------------------------- | -| `split_position(condition_id, amount)` | Lock `amount` pUSD into `amount` YES + `amount` NO shares | +| Operation | Effect | +|---|---| +| `split_position(condition_id, amount)` | Lock `amount` pUSD into `amount` YES + `amount` NO shares | | `merge_positions(condition_id, amount = "max")` | Burn YES+NO pairs back into pUSD; `"max"` merges min(YES, NO) | -| `merge_multiple_positions(requests)` | Merge several conditions in one call | -| `redeem_positions(condition_id)` | Redeem every position held in a closed, resolved market | +| `merge_multiple_positions(requests)` | Merge several conditions in one call | +| `redeem_positions(condition_id)` | Redeem every position held in a closed, resolved market | Amounts are integer base units as base-10 strings. pUSD and outcome shares both use 6 decimals, so `"1000000"` is 1 pUSD or 1 share. @@ -37,10 +37,10 @@ auto handle = client.redeem_positions("0x6b04...6fbc"); auto outcome = handle.wait(); // throws on revert, relayer failure or timeout ``` -| `wallet_type` | Holder | Sent by | Gas | -| ------------------ | ------------------------- | ------------------------------------------------- | ------------------- | -| `EOA` (default) | the key's address | signed legacy (EIP-155) transaction via `rpc_url` | POL from the EOA | -| `POLY_GNOSIS_SAFE` | the key's Polymarket Safe | Safe transaction via `relayer-v2.polymarket.com` | paid by the relayer | +| `wallet_type` | Holder | Sent by | Gas | +|---|---|---|---| +| `EOA` (default) | the key's address | signed legacy (EIP-155) transaction via `rpc_url` | POL from the EOA | +| `POLY_GNOSIS_SAFE` | the key's Polymarket Safe | Safe transaction via `relayer-v2.polymarket.com` | paid by the relayer | The Safe address is derived from the key with CREATE2. `funder_address` is optional; if it is set and is not that Safe, the constructor throws. The diff --git a/src/approval_calls.cpp b/src/approval_calls.cpp index 95eaacb..09e9d0b 100644 --- a/src/approval_calls.cpp +++ b/src/approval_calls.cpp @@ -1,4 +1,5 @@ #include "approval_calls.hpp" +#include "position_calls.hpp" #include "evm_uint.hpp" #include "polymarket/evm_abi.hpp" #include "polymarket/evm_utils.hpp" @@ -25,18 +26,6 @@ namespace polymarket::detail std::to_string(words.size())); return words[0]; } - - std::vector amount_word(const std::string &amount, const char *label) - { - try - { - return parse_uint256_word(amount); - } - catch (const std::invalid_argument &error) - { - throw std::invalid_argument(std::string(label) + ": " + error.what()); - } - } } // namespace void require_address(const std::string &value, const char *label) @@ -53,14 +42,14 @@ namespace polymarket::detail std::string resolve_approval_amount(const std::string &amount) { if (amount == "max") return max_uint256; - (void)amount_word(amount, "approval amount"); + (void)parse_uint256_amount(amount, "approval amount"); return amount; } ContractCall erc20_approve_call(const std::string &token, const std::string &spender, const std::string &amount) { - (void)amount_word(amount, "approval amount"); + (void)parse_uint256_amount(amount, "approval amount"); return {token, evm_abi_encode_call("approve(address,uint256)", {EvmAbiValue::address(spender), EvmAbiValue::uint256(amount)})}; @@ -69,7 +58,7 @@ namespace polymarket::detail ContractCall erc20_transfer_call(const std::string &token, const std::string &recipient, const std::string &amount) { - (void)amount_word(amount, "transfer amount"); + (void)parse_uint256_amount(amount, "transfer amount"); return {token, evm_abi_encode_call("transfer(address,uint256)", {EvmAbiValue::address(recipient), EvmAbiValue::uint256(amount)})}; @@ -140,7 +129,8 @@ namespace polymarket::detail const auto context = "allowance(" + approval.token_address + ", " + approval.spender + ")"; const auto allowance = decode_uint256_result(results[index++], context); - if (parse_uint256_word(allowance) < amount_word(approval.amount, "required amount")) + if (parse_uint256_word(allowance) < + parse_uint256_amount(approval.amount, "required amount")) state.missing.erc20.push_back(approval); } for (const auto &approval : required.erc1155) diff --git a/src/position_calls.cpp b/src/position_calls.cpp index 8be266f..5deb19b 100644 --- a/src/position_calls.cpp +++ b/src/position_calls.cpp @@ -17,18 +17,6 @@ namespace polymarket::detail return {EvmAbiValue::uint256(1), EvmAbiValue::uint256(2)}; } - std::vector uint256_word(const std::string &value, const char *label) - { - try - { - return parse_uint256_word(value); - } - catch (const std::invalid_argument &error) - { - throw std::invalid_argument(std::string(label) + ": " + error.what()); - } - } - bool is_zero_word(const std::vector &word) { return std::all_of(word.begin(), word.end(), [](uint8_t byte) { return byte == 0; }); @@ -48,6 +36,18 @@ namespace polymarket::detail } // namespace + std::vector parse_uint256_amount(const std::string &value, const char *label) + { + try + { + return parse_uint256_word(value); + } + catch (const std::invalid_argument &error) + { + throw std::invalid_argument(std::string(label) + ": " + error.what()); + } + } + std::string normalize_condition_id(const std::string &condition_id) { if (!is_hex_string(condition_id, 64)) @@ -175,15 +175,16 @@ namespace polymarket::detail const std::array &balances, const std::string &requested) { - const auto yes = uint256_word(balances[0], "balance"); - const auto no = uint256_word(balances[1], "balance"); + const auto yes = parse_uint256_amount(balances[0], "balance"); + const auto no = parse_uint256_amount(balances[1], "balance"); const auto &max_amount = yes < no ? balances[0] : balances[1]; if (uint256_is_zero(max_amount)) throw std::invalid_argument("no complementary positions to merge for condition " + condition_id); if (requested == "max") return max_amount; require_positive_amount(requested, "merge amount"); - if (uint256_word(max_amount, "balance") < uint256_word(requested, "merge amount")) + if (parse_uint256_amount(max_amount, "balance") < + parse_uint256_amount(requested, "merge amount")) throw std::invalid_argument("merge amount " + requested + " exceeds the mergeable " + max_amount + " for condition " + condition_id); return requested; @@ -191,12 +192,12 @@ namespace polymarket::detail void require_positive_amount(const std::string &amount, const char *label) { - if (is_zero_word(uint256_word(amount, label))) + if (is_zero_word(parse_uint256_amount(amount, label))) throw std::invalid_argument(std::string(label) + " must be positive"); } bool uint256_is_zero(const std::string &value) { - return is_zero_word(uint256_word(value, "amount")); + return is_zero_word(parse_uint256_amount(value, "amount")); } } // namespace polymarket::detail diff --git a/src/position_calls.hpp b/src/position_calls.hpp index fc2ec99..d3b4540 100644 --- a/src/position_calls.hpp +++ b/src/position_calls.hpp @@ -3,6 +3,7 @@ #include "polymarket/polymarket_contracts.hpp" #include "polymarket/position_client.hpp" #include +#include #include #include #include @@ -54,6 +55,10 @@ namespace polymarket::detail const std::array &balances, const std::string &requested); + // Big-endian 32-byte word of a uint256 (base-10 or 0x-hex); words compare + // numerically with <. Throws std::invalid_argument prefixed with label. + std::vector parse_uint256_amount(const std::string &value, const char *label); + // Throws std::invalid_argument unless amount is a positive uint256. void require_positive_amount(const std::string &amount, const char *label); diff --git a/tests/test_trading_approvals.cpp b/tests/test_trading_approvals.cpp index c2d08a0..4f48b66 100644 --- a/tests/test_trading_approvals.cpp +++ b/tests/test_trading_approvals.cpp @@ -1,96 +1,12 @@ // PositionClient trading approvals, approvals and transfers against scripted // RPC node and relayer servers, for EOA and Safe wallets. -#include "approval_calls.hpp" -#include "position_test_support.hpp" #include "safe_relayer.hpp" +#include "trading_approvals_test_support.hpp" -using namespace position_test; +using namespace approvals_test; namespace { - const std::string recipient = "0x000000000000000000000000000000000000dEaD"; - const auto required_set = required_trading_approvals(kContracts); - - std::string word(const std::string &value) - { - return to_hex(evm_abi_encode({EvmAbiValue::uint256(value)})); - } - - // Answers the 17 approval checks for `owner`. Results are given in - // required order: ERC-20 allowances, then isApprovedForAll flags. - void expect_approval_reads(clob_test::LocalServer &node, const std::string &owner, - const std::vector &results) - { - const auto checks = detail::trading_approval_check_calls(required_set, owner); - for (size_t i = 0; i < checks.size(); ++i) - { - const auto &expected = checks[i]; - const auto &result = results.at(i); - expect_rpc(node, "eth_call", - [expected, result, i](const nlohmann::json ¶ms) - { - check(params.at(0).at("to") == expected.to && - params.at(0).at("data") == expected.data && - params.at(1) == "latest", - "approval check " + std::to_string(i) + " " + params.dump()); - return nlohmann::json(result); - }); - } - } - - std::vector all_approved() - { - std::vector results(required_set.erc20.size(), word(max_uint256)); - results.resize(required_set.erc20.size() + required_set.erc1155.size(), word("1")); - return results; - } - - void expect_transaction(clob_test::LocalServer &node, const ContractCall &call, - bool first_transaction) - { - if (first_transaction) expect_rpc(node, "eth_chainId", "0x89"); - expect_rpc(node, "eth_getTransactionCount", "0x7"); - expect_rpc(node, "eth_gasPrice", "0x6fc23ac00"); - expect_rpc(node, "eth_estimateGas", - [call](const nlohmann::json ¶ms) - { - const auto &request = params.at(0); - check(request.at("to") == call.to && request.at("from") == kWallet && - request.at("data") == call.data, - "estimate call " + request.dump() + " expected data " + call.data); - return nlohmann::json("0x3d090"); - }); - expect_rpc(node, "eth_sendRawTransaction", - [](const nlohmann::json ¶ms) - { - return nlohmann::json( - to_hex(keccak256(from_hex(params.at(0).get())))); - }); - } - - void expect_mined(clob_test::LocalServer &node) - { - expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json ¶ms) - { return receipt(params.at(0).get(), true); }); - } - - PositionClientConfig base_config(const std::string &node_url) - { - PositionClientConfig config; - config.private_key = kKey; - config.rpc_url = node_url; - config.rpc_timeout_ms = 5000; - return config; - } - - size_t count(const clob_test::LocalServer &node, const std::string &method) - { - size_t n = 0; - for (const auto &request : node.requests()) - n += nlohmann::json::parse(request.body).at("method") == method; - return n; - } - void state_reads() { clob_test::LocalServer node; @@ -128,10 +44,7 @@ namespace check(count(node, "eth_sendRawTransaction") == 0 && count(node, "eth_chainId") == 0, "fully approved wallet submits nothing"); - auto results = all_approved(); - results[1] = word("0"); // neg_risk_exchange allowance - results[7] = word("0"); // CTF -> standard_exchange - expect_approval_reads(node, kWallet, results); + expect_two_missing(node); const auto approve = detail::erc20_approve_call(kContracts.collateral_token, kContracts.neg_risk_exchange, max_uint256); const auto operator_call = detail::erc1155_set_approval_for_all_call( diff --git a/tests/test_trading_approvals_failures.cpp b/tests/test_trading_approvals_failures.cpp new file mode 100644 index 0000000..6f72b7c --- /dev/null +++ b/tests/test_trading_approvals_failures.cpp @@ -0,0 +1,103 @@ +// PositionClient EOA trading approval setup failures: before the first +// submission, after partial progress, and after a mined revert. +#include "trading_approvals_test_support.hpp" + +using namespace approvals_test; + +namespace +{ + void eoa_setup_failures() + { + const auto approve = detail::erc20_approve_call(kContracts.collateral_token, + kContracts.neg_risk_exchange, max_uint256); + + // Before the first submission: the original error, nothing sent. + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + expect_two_missing(node); + expect_rpc(node, "eth_chainId", "0x89"); + expect_rpc(node, "eth_getTransactionCount", "0x7"); + expect_rpc(node, "eth_gasPrice", "0x6fc23ac00"); + expect_rpc_error(node, "eth_estimateGas"); + try + { + (void)client.setup_trading_approvals(std::chrono::seconds(5)); + check(false, "first approval failure did not throw"); + } + catch (const PartialBatchError &) + { + check(false, "nothing was sent, so the error is not partial"); + } + catch (const std::runtime_error &) + { + } + check(count(node, "eth_sendRawTransaction") == 0, + "failed first approval sends nothing"); + } + + // After partial progress: the mined approve's hash survives. + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + expect_two_missing(node); + expect_transaction(node, approve, true); + expect_mined(node); + expect_rpc(node, "eth_getTransactionCount", "0x8"); + expect_rpc(node, "eth_gasPrice", "0x6fc23ac00"); + expect_rpc_error(node, "eth_estimateGas"); + try + { + (void)client.setup_trading_approvals(std::chrono::seconds(5)); + check(false, "second approval failure did not throw"); + } + catch (const PartialBatchError &error) + { + check(error.submitted_hashes().size() == 1 && error.failed_call_index() == 1 && + error.cause() != nullptr, + "partial setup keeps the first hash and the failing index"); + } + check(count(node, "eth_sendRawTransaction") == 1, "operator approval was not sent"); + } + + // After a mined revert: the reverted approve stops the batch. + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + expect_two_missing(node); + expect_transaction(node, approve, true); + expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json ¶ms) + { return receipt(params.at(0).get(), false); }); + try + { + (void)client.setup_trading_approvals(std::chrono::seconds(5)); + check(false, "reverted approval did not throw"); + } + catch (const PartialBatchError &error) + { + bool reverted = false; + try + { + std::rethrow_exception(error.cause()); + } + catch (const TransactionRevertedError &) + { + reverted = true; + } + catch (...) + { + } + check(error.submitted_hashes().size() == 1 && error.failed_call_index() == 0 && + reverted, + "reverted approve is reported with its hash and cause"); + } + check(count(node, "eth_sendRawTransaction") == 1, "nothing sent after the revert"); + } + } +} // namespace + +int main() +{ + eoa_setup_failures(); + return check_support::finish("test_trading_approvals_failures"); +} diff --git a/tests/trading_approvals_test_support.hpp b/tests/trading_approvals_test_support.hpp new file mode 100644 index 0000000..68bff49 --- /dev/null +++ b/tests/trading_approvals_test_support.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include "approval_calls.hpp" +#include "position_test_support.hpp" + +#include +#include + +// Scripted RPC helpers shared by the trading approval flow tests. +namespace approvals_test +{ + using namespace position_test; + + inline const std::string recipient = "0x000000000000000000000000000000000000dEaD"; + inline const auto required_set = required_trading_approvals(kContracts); + + inline std::string word(const std::string &value) + { + return to_hex(evm_abi_encode({EvmAbiValue::uint256(value)})); + } + + // Answers the 17 approval checks for `owner`. Results are given in + // required order: ERC-20 allowances, then isApprovedForAll flags. + inline void expect_approval_reads(clob_test::LocalServer &node, const std::string &owner, + const std::vector &results) + { + const auto checks = detail::trading_approval_check_calls(required_set, owner); + for (size_t i = 0; i < checks.size(); ++i) + { + const auto &expected = checks[i]; + const auto &result = results.at(i); + expect_rpc(node, "eth_call", + [expected, result, i](const nlohmann::json ¶ms) + { + check(params.at(0).at("to") == expected.to && + params.at(0).at("data") == expected.data && + params.at(1) == "latest", + "approval check " + std::to_string(i) + " " + params.dump()); + return nlohmann::json(result); + }); + } + } + + inline std::vector all_approved() + { + std::vector results(required_set.erc20.size(), word(max_uint256)); + results.resize(required_set.erc20.size() + required_set.erc1155.size(), word("1")); + return results; + } + + inline void expect_transaction(clob_test::LocalServer &node, const ContractCall &call, + bool first_transaction) + { + if (first_transaction) expect_rpc(node, "eth_chainId", "0x89"); + expect_rpc(node, "eth_getTransactionCount", "0x7"); + expect_rpc(node, "eth_gasPrice", "0x6fc23ac00"); + expect_rpc(node, "eth_estimateGas", + [call](const nlohmann::json ¶ms) + { + const auto &request = params.at(0); + check(request.at("to") == call.to && request.at("from") == kWallet && + request.at("data") == call.data, + "estimate call " + request.dump() + " expected data " + call.data); + return nlohmann::json("0x3d090"); + }); + expect_rpc(node, "eth_sendRawTransaction", + [](const nlohmann::json ¶ms) + { + return nlohmann::json( + to_hex(keccak256(from_hex(params.at(0).get())))); + }); + } + + inline void expect_mined(clob_test::LocalServer &node) + { + expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json ¶ms) + { return receipt(params.at(0).get(), true); }); + } + + inline PositionClientConfig base_config(const std::string &node_url) + { + PositionClientConfig config; + config.private_key = kKey; + config.rpc_url = node_url; + config.rpc_timeout_ms = 5000; + return config; + } + + inline size_t count(const clob_test::LocalServer &node, const std::string &method) + { + size_t n = 0; + for (const auto &request : node.requests()) + n += nlohmann::json::parse(request.body).at("method") == method; + return n; + } + + // JSON-RPC error reply, as a node answers eth_estimateGas for a reverting call. + inline void expect_rpc_error(clob_test::LocalServer &node, const std::string &method) + { + node.enqueue( + [method](const clob_test::Request &request) + { + const auto body = nlohmann::json::parse(request.body); + check(body.at("method") == method, "expected " + method); + return nlohmann::json{{"jsonrpc", "2.0"}, + {"id", body.at("id")}, + {"error", {{"code", 3}, {"message", "execution reverted"}}}} + .dump(); + }); + } + + // Two missing approvals: neg-risk exchange allowance, then CTF operator. + inline void expect_two_missing(clob_test::LocalServer &node) + { + auto results = all_approved(); + results[1] = word("0"); + results[7] = word("0"); + expect_approval_reads(node, kWallet, results); + } + +} // namespace approvals_test From fac61e33f705c1e9849e4c14f2c886fe6917dc22 Mon Sep 17 00:00:00 2001 From: yluoc Date: Mon, 5 Oct 2026 23:18:19 -0400 Subject: [PATCH 3/6] fix(test): build approval set at call time to avoid init order abort --- tests/trading_approvals_test_support.hpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/trading_approvals_test_support.hpp b/tests/trading_approvals_test_support.hpp index 68bff49..44e9ddc 100644 --- a/tests/trading_approvals_test_support.hpp +++ b/tests/trading_approvals_test_support.hpp @@ -11,8 +11,14 @@ namespace approvals_test { using namespace position_test; - inline const std::string recipient = "0x000000000000000000000000000000000000dEaD"; - inline const auto required_set = required_trading_approvals(kContracts); + const std::string recipient = "0x000000000000000000000000000000000000dEaD"; + + // Built on use: a namespace-scope inline variable may be initialized + // before kContracts, which Apple clang does. + inline TradingApprovals required_set() + { + return required_trading_approvals(kContracts); + } inline std::string word(const std::string &value) { @@ -24,7 +30,7 @@ namespace approvals_test inline void expect_approval_reads(clob_test::LocalServer &node, const std::string &owner, const std::vector &results) { - const auto checks = detail::trading_approval_check_calls(required_set, owner); + const auto checks = detail::trading_approval_check_calls(required_set(), owner); for (size_t i = 0; i < checks.size(); ++i) { const auto &expected = checks[i]; @@ -43,8 +49,9 @@ namespace approvals_test inline std::vector all_approved() { - std::vector results(required_set.erc20.size(), word(max_uint256)); - results.resize(required_set.erc20.size() + required_set.erc1155.size(), word("1")); + const auto required = required_set(); + std::vector results(required.erc20.size(), word(max_uint256)); + results.resize(required.erc20.size() + required.erc1155.size(), word("1")); return results; } From 9e41c94977c7ff512d3d8261f710068b512d143e Mon Sep 17 00:00:00 2001 From: yluoc Date: Mon, 5 Oct 2026 23:36:26 -0400 Subject: [PATCH 4/6] test: remove bug-fix history redundant comments --- tests/check_support.hpp | 4 ---- tests/trading_approvals_test_support.hpp | 2 -- 2 files changed, 6 deletions(-) diff --git a/tests/check_support.hpp b/tests/check_support.hpp index 5fe2f14..41f2c4f 100644 --- a/tests/check_support.hpp +++ b/tests/check_support.hpp @@ -24,10 +24,6 @@ namespace check_support check(actual == expected, name + " mismatch\n expected: " + expected + "\n actual: " + actual); } - // Matches the type with dynamic_cast instead of a `catch (const Error &)` - // clause. Instantiations that differ only in their catch clause have - // identical code, and AppleClang Release builds folded them into one, so - // every expect_throws in a binary caught the same single type. template void expect_throws(const std::string &name, const std::function &action) { diff --git a/tests/trading_approvals_test_support.hpp b/tests/trading_approvals_test_support.hpp index 44e9ddc..7098a1d 100644 --- a/tests/trading_approvals_test_support.hpp +++ b/tests/trading_approvals_test_support.hpp @@ -13,8 +13,6 @@ namespace approvals_test const std::string recipient = "0x000000000000000000000000000000000000dEaD"; - // Built on use: a namespace-scope inline variable may be initialized - // before kContracts, which Apple clang does. inline TradingApprovals required_set() { return required_trading_approvals(kContracts); From 1358b4a38590b15e73e6925269feb6b4b4df67f1 Mon Sep 17 00:00:00 2001 From: yluoc Date: Tue, 6 Oct 2026 10:07:23 -0400 Subject: [PATCH 5/6] position: keep earlier approval hashes when the final setup wait fails --- src/trading_approvals.cpp | 18 ++++- tests/test_trading_approvals_failures.cpp | 84 +++++++++++++++++++---- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/trading_approvals.cpp b/src/trading_approvals.cpp index a218720..042e58a 100644 --- a/src/trading_approvals.cpp +++ b/src/trading_approvals.cpp @@ -23,9 +23,21 @@ namespace polymarket // Read over RPC rather than from indexed data, which can lag recent grants. const auto state = get_trading_approvals_state(); if (state.is_fully_approved) return std::nullopt; - return execute_calls(detail::trading_approval_calls(state.missing), - "Trading setup approvals") - .wait(timeout); + const auto handle = + execute_calls(detail::trading_approval_calls(state.missing), "Trading setup approvals"); + try + { + return handle.wait(timeout); + } + catch (const std::exception &error) + { + // An EOA batch already mined its earlier approvals; keep their hashes. + const auto &hashes = handle.transaction_hashes(); + if (hashes.size() < 2) + throw; + throw PartialBatchError(hashes, hashes.size() - 1, hashes.size(), + std::current_exception(), error.what()); + } } TransactionHandle PositionClient::approve_erc20(const std::string &token_address, diff --git a/tests/test_trading_approvals_failures.cpp b/tests/test_trading_approvals_failures.cpp index 6f72b7c..981917f 100644 --- a/tests/test_trading_approvals_failures.cpp +++ b/tests/test_trading_approvals_failures.cpp @@ -1,11 +1,29 @@ // PositionClient EOA trading approval setup failures: before the first -// submission, after partial progress, and after a mined revert. +// submission, after partial progress, after a mined revert, and while +// waiting for the final approval. #include "trading_approvals_test_support.hpp" using namespace approvals_test; namespace { + template + bool cause_is(const PartialBatchError &error) + { + try + { + std::rethrow_exception(error.cause()); + } + catch (const Cause &) + { + return true; + } + catch (...) + { + } + return false; + } + void eoa_setup_failures() { const auto approve = detail::erc20_approve_call(kContracts.collateral_token, @@ -75,24 +93,62 @@ namespace } catch (const PartialBatchError &error) { - bool reverted = false; - try - { - std::rethrow_exception(error.cause()); - } - catch (const TransactionRevertedError &) - { - reverted = true; - } - catch (...) - { - } check(error.submitted_hashes().size() == 1 && error.failed_call_index() == 0 && - reverted, + cause_is(error), "reverted approve is reported with its hash and cause"); } check(count(node, "eth_sendRawTransaction") == 1, "nothing sent after the revert"); } + + const auto operator_call = detail::erc1155_set_approval_for_all_call( + kContracts.conditional_tokens, kContracts.standard_exchange, true); + + // Final approval reverts after the first is mined: both hashes survive. + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + expect_two_missing(node); + expect_transaction(node, approve, true); + expect_mined(node); + expect_transaction(node, operator_call, false); + expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json ¶ms) + { return receipt(params.at(0).get(), false); }); + try + { + (void)client.setup_trading_approvals(std::chrono::seconds(5)); + check(false, "reverted final approval did not throw"); + } + catch (const PartialBatchError &error) + { + check(error.submitted_hashes().size() == 2 && error.failed_call_index() == 1 && + cause_is(error), + "final revert keeps both hashes, the failing index and the cause"); + } + } + + // Final approval times out after the first is mined: both hashes survive. + { + clob_test::LocalServer node; + PositionClient client(base_config(node.url())); + expect_two_missing(node); + expect_transaction(node, approve, true); + expect_mined(node); + expect_transaction(node, operator_call, false); + expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json &) + { return nlohmann::json(nullptr); }); + try + { + // A zero timeout polls once, then gives up. + (void)client.setup_trading_approvals(std::chrono::milliseconds(0)); + check(false, "pending final approval did not time out"); + } + catch (const PartialBatchError &error) + { + check(error.submitted_hashes().size() == 2 && error.failed_call_index() == 1 && + cause_is(error), + "final timeout keeps both hashes, the failing index and the cause"); + } + } } } // namespace From 87b1a177e6fbbc020e34ef58ae7852782925268a Mon Sep 17 00:00:00 2001 From: yluoc Date: Tue, 6 Oct 2026 10:36:40 -0400 Subject: [PATCH 6/6] style(positions): apply clang-format 21 to approval setup fix --- src/trading_approvals.cpp | 3 +-- tests/test_trading_approvals_failures.cpp | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/trading_approvals.cpp b/src/trading_approvals.cpp index 042e58a..721dbca 100644 --- a/src/trading_approvals.cpp +++ b/src/trading_approvals.cpp @@ -33,8 +33,7 @@ namespace polymarket { // An EOA batch already mined its earlier approvals; keep their hashes. const auto &hashes = handle.transaction_hashes(); - if (hashes.size() < 2) - throw; + if (hashes.size() < 2) throw; throw PartialBatchError(hashes, hashes.size() - 1, hashes.size(), std::current_exception(), error.what()); } diff --git a/tests/test_trading_approvals_failures.cpp b/tests/test_trading_approvals_failures.cpp index 981917f..6f0f74a 100644 --- a/tests/test_trading_approvals_failures.cpp +++ b/tests/test_trading_approvals_failures.cpp @@ -7,8 +7,7 @@ using namespace approvals_test; namespace { - template - bool cause_is(const PartialBatchError &error) + template bool cause_is(const PartialBatchError &error) { try { @@ -134,8 +133,8 @@ namespace expect_transaction(node, approve, true); expect_mined(node); expect_transaction(node, operator_call, false); - expect_rpc(node, "eth_getTransactionReceipt", [](const nlohmann::json &) - { return nlohmann::json(nullptr); }); + expect_rpc(node, "eth_getTransactionReceipt", + [](const nlohmann::json &) { return nlohmann::json(nullptr); }); try { // A zero timeout polls once, then gives up.