From ab730243e62a6171d7c61b20ac73b59b25a62069 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:47:59 -0700 Subject: [PATCH 1/4] fix: route approve commands through proxy wallet when --signature-type proxy - `approve check` now queries the correct wallet (proxy or EOA) based on --signature-type, so users see actual allowances instead of zeros - `approve set` with proxy signature type routes transactions through the Proxy Wallet Factory, batching USDC and CTF approvals per target - `ctf` commands now accept --signature-type for forward compatibility Fixes #4 Related: #1, #24 Co-Authored-By: Claude Opus 4.6 --- src/auth.rs | 26 ++++- src/commands/approve.rs | 206 +++++++++++++++++++++++++++++----------- src/commands/ctf.rs | 7 +- src/main.rs | 16 +++- 4 files changed, 193 insertions(+), 62 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index a5cf34d..1e3cf3f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,11 +1,12 @@ use std::str::FromStr; +use alloy::primitives::Address; use alloy::providers::ProviderBuilder; use anyhow::{Context, Result}; use polymarket_client_sdk::auth::state::Authenticated; use polymarket_client_sdk::auth::{LocalSigner, Normal, Signer as _}; use polymarket_client_sdk::clob::types::SignatureType; -use polymarket_client_sdk::{POLYGON, clob}; +use polymarket_client_sdk::{POLYGON, clob, derive_proxy_wallet}; use crate::config; @@ -77,6 +78,29 @@ pub async fn create_provider( .context("Failed to connect to Polygon RPC with wallet") } +/// Resolve the wallet address that owns positions and allowances. +/// +/// For `eoa` signature type, returns the EOA address directly. +/// For `proxy`, returns the derived proxy wallet address. +/// For `gnosis-safe`, returns the derived Gnosis Safe address. +pub fn resolve_wallet_address( + private_key: Option<&str>, + signature_type_flag: Option<&str>, +) -> Result
{ + let signer = resolve_signer(private_key)?; + let eoa = polymarket_client_sdk::auth::Signer::address(&signer); + let sig_type = parse_signature_type(&config::resolve_signature_type(signature_type_flag)?); + + match sig_type { + SignatureType::Eoa => Ok(eoa), + SignatureType::Proxy => derive_proxy_wallet(eoa, POLYGON) + .context("Failed to derive proxy wallet address for Polygon"), + SignatureType::GnosisSafe => polymarket_client_sdk::derive_safe_wallet(eoa, POLYGON) + .context("Failed to derive Gnosis Safe wallet address for Polygon"), + _ => Ok(eoa), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/approve.rs b/src/commands/approve.rs index b52dc0c..75b4c1a 100644 --- a/src/commands/approve.rs +++ b/src/commands/approve.rs @@ -1,14 +1,16 @@ #![allow(clippy::exhaustive_enums, reason = "Generated by sol! macro")] #![allow(clippy::exhaustive_structs, reason = "Generated by sol! macro")] -use alloy::primitives::U256; +use alloy::primitives::{Bytes, U256}; use alloy::sol; +use alloy::sol_types::SolCall; use anyhow::{Context, Result}; use clap::{Args, Subcommand}; use polymarket_client_sdk::types::{Address, address}; -use polymarket_client_sdk::{POLYGON, contract_config}; +use polymarket_client_sdk::{POLYGON, contract_config, wallet_contract_config}; use crate::auth; +use crate::config; use crate::output::OutputFormat; use crate::output::approve::{ApprovalStatus, print_approval_status, print_tx_result}; @@ -27,6 +29,17 @@ sol! { function setApprovalForAll(address operator, bool approved) external; function isApprovedForAll(address account, address operator) external view returns (bool); } + + struct ProxyCall { + address to; + uint256 value; + bytes data; + } + + #[sol(rpc)] + interface IProxyWalletFactory { + function proxy(ProxyCall[] memory calls) external payable returns (bytes[] memory); + } } #[derive(Args)] @@ -81,23 +94,26 @@ pub async fn execute( args: ApproveArgs, output: OutputFormat, private_key: Option<&str>, + signature_type: Option<&str>, ) -> Result<()> { match args.command { - ApproveCommand::Check { address } => check(address, private_key, output).await, - ApproveCommand::Set => set(private_key, output).await, + ApproveCommand::Check { address } => { + check(address, private_key, signature_type, output).await + } + ApproveCommand::Set => set(private_key, signature_type, output).await, } } async fn check( address_arg: Option
, private_key: Option<&str>, + signature_type: Option<&str>, output: OutputFormat, ) -> Result<()> { let owner: Address = if let Some(addr) = address_arg { addr } else { - let signer = auth::resolve_signer(private_key)?; - polymarket_client_sdk::auth::Signer::address(&signer) + auth::resolve_wallet_address(private_key, signature_type)? }; let provider = auth::create_readonly_provider().await?; @@ -135,70 +151,144 @@ async fn check( print_approval_status(&statuses, &output) } -async fn set(private_key: Option<&str>, output: OutputFormat) -> Result<()> { - let provider = auth::create_provider(private_key).await?; - let config = contract_config(POLYGON, false).context("No contract config for Polygon")?; +async fn set( + private_key: Option<&str>, + signature_type: Option<&str>, + output: OutputFormat, +) -> Result<()> { + let sig_type = config::resolve_signature_type(signature_type)?; + let is_proxy = sig_type == config::DEFAULT_SIGNATURE_TYPE; - let usdc = IERC20::new(USDC_ADDRESS, provider.clone()); - let ctf = IERC1155::new(config.conditional_tokens, provider.clone()); + let provider = auth::create_provider(private_key).await?; + let ctf_config = contract_config(POLYGON, false).context("No contract config for Polygon")?; let targets = approval_targets()?; let total = targets.len() * 2; if matches!(output, OutputFormat::Table) { - println!("Approving contracts...\n"); + if is_proxy { + println!("Approving contracts via proxy wallet...\n"); + } else { + println!("Approving contracts...\n"); + } } let mut results: Vec = Vec::new(); let mut step = 0; - for target in &targets { - step += 1; - let label = format!("USDC \u{2192} {}", target.name); - let tx_hash = usdc - .approve(target.address, U256::MAX) - .send() - .await - .context(format!("Failed to send USDC approval for {}", target.name))? - .watch() - .await - .context(format!( - "Failed to confirm USDC approval for {}", - target.name - ))?; - - match output { - OutputFormat::Table => print_tx_result(step, total, &label, tx_hash), - OutputFormat::Json => results.push(serde_json::json!({ - "step": step, - "type": "erc20", - "contract": target.name, - "tx_hash": format!("{tx_hash}"), - })), + if is_proxy { + let wallet_config = + wallet_contract_config(POLYGON).context("No wallet contract config for Polygon")?; + let factory_address = wallet_config + .proxy_factory + .context("No proxy factory address for Polygon")?; + let factory = IProxyWalletFactory::new(factory_address, provider.clone()); + + for target in &targets { + step += 1; + let label = format!("USDC \u{2192} {} (proxy)", target.name); + + let usdc_calldata = IERC20::approveCall { + spender: target.address, + value: U256::MAX, + } + .abi_encode(); + let ctf_calldata = IERC1155::setApprovalForAllCall { + operator: target.address, + approved: true, + } + .abi_encode(); + + let calls = vec![ + ProxyCall { + to: USDC_ADDRESS, + value: U256::ZERO, + data: Bytes::from(usdc_calldata), + }, + ProxyCall { + to: ctf_config.conditional_tokens, + value: U256::ZERO, + data: Bytes::from(ctf_calldata), + }, + ]; + + let tx_hash = factory + .proxy(calls) + .send() + .await + .context(format!( + "Failed to send proxy approvals for {}", + target.name + ))? + .watch() + .await + .context(format!( + "Failed to confirm proxy approvals for {}", + target.name + ))?; + + match output { + OutputFormat::Table => print_tx_result(step, total / 2, &label, tx_hash), + OutputFormat::Json => results.push(serde_json::json!({ + "step": step, + "type": "proxy_batch", + "contract": target.name, + "tx_hash": format!("{tx_hash}"), + })), + } } - - step += 1; - let label = format!("CTF \u{2192} {}", target.name); - let tx_hash = ctf - .setApprovalForAll(target.address, true) - .send() - .await - .context(format!("Failed to send CTF approval for {}", target.name))? - .watch() - .await - .context(format!( - "Failed to confirm CTF approval for {}", - target.name - ))?; - - match output { - OutputFormat::Table => print_tx_result(step, total, &label, tx_hash), - OutputFormat::Json => results.push(serde_json::json!({ - "step": step, - "type": "erc1155", - "contract": target.name, - "tx_hash": format!("{tx_hash}"), - })), + } else { + let usdc = IERC20::new(USDC_ADDRESS, provider.clone()); + let ctf = IERC1155::new(ctf_config.conditional_tokens, provider.clone()); + + for target in &targets { + step += 1; + let label = format!("USDC \u{2192} {}", target.name); + let tx_hash = usdc + .approve(target.address, U256::MAX) + .send() + .await + .context(format!("Failed to send USDC approval for {}", target.name))? + .watch() + .await + .context(format!( + "Failed to confirm USDC approval for {}", + target.name + ))?; + + match output { + OutputFormat::Table => print_tx_result(step, total, &label, tx_hash), + OutputFormat::Json => results.push(serde_json::json!({ + "step": step, + "type": "erc20", + "contract": target.name, + "tx_hash": format!("{tx_hash}"), + })), + } + + step += 1; + let label = format!("CTF \u{2192} {}", target.name); + let tx_hash = ctf + .setApprovalForAll(target.address, true) + .send() + .await + .context(format!("Failed to send CTF approval for {}", target.name))? + .watch() + .await + .context(format!( + "Failed to confirm CTF approval for {}", + target.name + ))?; + + match output { + OutputFormat::Table => print_tx_result(step, total, &label, tx_hash), + OutputFormat::Json => results.push(serde_json::json!({ + "step": step, + "type": "erc1155", + "contract": target.name, + "tx_hash": format!("{tx_hash}"), + })), + } } } diff --git a/src/commands/ctf.rs b/src/commands/ctf.rs index 5d06ea5..b656bec 100644 --- a/src/commands/ctf.rs +++ b/src/commands/ctf.rs @@ -171,7 +171,12 @@ fn binary_u256_vec() -> Vec { DEFAULT_BINARY_SETS.iter().map(|&n| U256::from(n)).collect() } -pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&str>) -> Result<()> { +pub async fn execute( + args: CtfArgs, + output: OutputFormat, + private_key: Option<&str>, + _signature_type: Option<&str>, +) -> Result<()> { match args.command { CtfCommand::Split { condition, diff --git a/src/main.rs b/src/main.rs index 2abb55f..da01221 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,13 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { Commands::Profiles(args) => commands::profiles::execute(&gamma, args, cli.output).await, Commands::Sports(args) => commands::sports::execute(&gamma, args, cli.output).await, Commands::Approve(args) => { - commands::approve::execute(args, cli.output, cli.private_key.as_deref()).await + commands::approve::execute( + args, + cli.output, + cli.private_key.as_deref(), + cli.signature_type.as_deref(), + ) + .await } Commands::Clob(args) => { commands::clob::execute( @@ -109,7 +115,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(&data, args, cli.output).await, Commands::Bridge(args) => commands::bridge::execute(&bridge, args, cli.output).await, From 636940604935b334f5703b314c008782e72efb12 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:36:00 -0700 Subject: [PATCH 2/4] fix: compare against literal "proxy" instead of default constant Prevents silent routing breakage if DEFAULT_SIGNATURE_TYPE is ever changed to a non-proxy value. Co-Authored-By: Claude Opus 4.6 --- src/commands/approve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/approve.rs b/src/commands/approve.rs index 75b4c1a..e01e420 100644 --- a/src/commands/approve.rs +++ b/src/commands/approve.rs @@ -157,7 +157,7 @@ async fn set( output: OutputFormat, ) -> Result<()> { let sig_type = config::resolve_signature_type(signature_type)?; - let is_proxy = sig_type == config::DEFAULT_SIGNATURE_TYPE; + let is_proxy = sig_type == "proxy"; let provider = auth::create_provider(private_key).await?; let ctf_config = contract_config(POLYGON, false).context("No contract config for Polygon")?; From 09aaa7aff589de25af04606599c2d1635616466f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:29:56 -0700 Subject: [PATCH 3/4] fix: reject gnosis-safe in approve set with clear error approve check uses resolve_wallet_address which returns the derived Safe address for gnosis-safe, but approve set only handles proxy and falls through to the EOA path. This would silently approve on the wrong address. Add an early bail for gnosis-safe with guidance to use the Safe wallet interface instead. --- src/commands/approve.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/commands/approve.rs b/src/commands/approve.rs index e01e420..f3bb643 100644 --- a/src/commands/approve.rs +++ b/src/commands/approve.rs @@ -159,6 +159,13 @@ async fn set( let sig_type = config::resolve_signature_type(signature_type)?; let is_proxy = sig_type == "proxy"; + if sig_type == "gnosis-safe" { + anyhow::bail!( + "Gnosis Safe approvals must be submitted through your Safe wallet interface.\n\ + Use `approve check --signature-type gnosis-safe` to verify allowances on your Safe address." + ); + } + let provider = auth::create_provider(private_key).await?; let ctf_config = contract_config(POLYGON, false).context("No contract config for Polygon")?; From 51a44592aa4bc9b6e9daeb5d3d035f653cd653bb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:21:13 -0700 Subject: [PATCH 4/4] fix: use DEFAULT_SIGNATURE_TYPE constant and include CTF in proxy label - Replace hardcoded "proxy" string with config::DEFAULT_SIGNATURE_TYPE to stay consistent with auth.rs and prevent silent divergence - Update proxy approval label to "USDC + CTF" since the batch transaction includes both token approvals Co-Authored-By: Claude Opus 4.6 --- src/commands/approve.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/approve.rs b/src/commands/approve.rs index f3bb643..3440226 100644 --- a/src/commands/approve.rs +++ b/src/commands/approve.rs @@ -157,7 +157,7 @@ async fn set( output: OutputFormat, ) -> Result<()> { let sig_type = config::resolve_signature_type(signature_type)?; - let is_proxy = sig_type == "proxy"; + let is_proxy = sig_type == config::DEFAULT_SIGNATURE_TYPE; if sig_type == "gnosis-safe" { anyhow::bail!( @@ -193,7 +193,7 @@ async fn set( for target in &targets { step += 1; - let label = format!("USDC \u{2192} {} (proxy)", target.name); + let label = format!("USDC + CTF \u{2192} {} (proxy)", target.name); let usdc_calldata = IERC20::approveCall { spender: target.address,