ARTICLE AD BOX
Context
I'm building a platform that integrates with the Polymarket gasless relayer (https://relayer-v2.polymarket.com/submit) using:
Gnosis Safe proxy wallet (deployed via Polymarket's factory 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b)
Privy as the key custodian (no access to raw private key)
py-builder-relayer-client==0.0.1 (official Polymarket SDK)
py-builder-signing-sdk for HMAC authentication
The goal is to submit 6 ERC-20/ERC-1155 approval transactions in a single gasless multisend batch.
What's confirmed working
GET /transactions → 200 (Builder credentials are valid)
GET /deployed?address=... → {"deployed": true} (Safe is deployed on Polygon)
GET /nonce → {"nonce": "0"} (correct current nonce)
client.execute() with a test private key (different EOA/Safe) → 200 OK
proxyWallet derived by SDK matches the deployed Safe on-chain
The problem
When I inject a custom PrivySigner (which calls Privy's API instead of using a local private key), POST /submit consistently returns:
{"error": "bad request"}My PrivySigner implementation
The SDK's Signer class signs like this:
# SDK original (py-builder-relayer-client/signer.py) def sign_eip712_struct_hash(self, message_hash): msg = encode_defunct(HexBytes(message_hash)) # applies EIP-191 prefix sig = Account.sign_message(msg, self.private_key).signature.hex() return prepend_zx(sig)My PrivySigner calls Privy's API instead:
class PrivySigner: def __init__(self, privy_wallet_id: str, eoa_address: str, chain_id: int = 137): self.privy_wallet_id = privy_wallet_id self._address = to_checksum_address(eoa_address) def address(self) -> str: return self._address def get_chain_id(self) -> int: return self._chain_id def sign(self, message_hash) -> str: h = "0x" + message_hash.hex() if isinstance(message_hash, bytes) else str(message_hash) return _run_sync(_privy_sign_raw(self.privy_wallet_id, h)) def sign_eip712_struct_hash(self, message_hash) -> str: h = "0x" + message_hash.hex() if isinstance(message_hash, bytes) else str(message_hash) # Using personal_sign with encoding=hex — intended to match encode_defunct behavior sig_hex = _run_sync(_privy_sign_eip191(self.privy_wallet_id, h)) return sig_hexWhere _privy_sign_eip191 calls:
async def _privy_sign_eip191(privy_wallet_id: str, hash_hex: str) -> str: payload = { "method": "personal_sign", "params": {"message": hash_hex, "encoding": "hex"}, } # ... POST to Privy APIDiagnosis so far
I ran an ecrecover test against the struct hash computed by the SDK:
from py_builder_relayer_client.builder.safe import create_struct_hash from eth_account import Account from eth_account.messages import encode_defunct from hexbytes import HexBytes struct_hash = "0x918fb835628db28a00da7606c72e46c127d45b1a7f0827499d0f5091c3d65185" sig_from_privy_personal_sign = "0xd9965d01...1c" # Test 1: HexBytes (same as SDK) msg = encode_defunct(HexBytes(struct_hash)) recovered = Account.recover_message(msg, signature=sig_from_privy_personal_sign) print(recovered) # → 0xeBcC372BA40bF88e730e0C901114713428B20f49 ← WRONG # Test 2: secp256k1_sign (raw, no EIP-191 prefix) sig_raw = "0x3ea38967...1c" # from Privy secp256k1_sign recovered2 = Account._recover_hash(HexBytes(struct_hash), signature=sig_raw) print(recovered2) # → 0xD15b9f4Dab19808eb4F25AB647820cD8111cE1ef ← CORRECTSo personal_sign with encoding: hex via Privy does not produce the same result as encode_defunct(HexBytes(hash)) from eth_account. The secp256k1_sign (raw, no prefix) does recover the correct owner.
The question
The Gnosis Safe contract (GnosisSafeL2 v1.3.0) in checkSignatures does this for EOA signatures (v = 31 or v = 32):
currentOwner = ecrecover( keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s );So Safe applies the EIP-191 prefix internally. This means the signature passed should be the raw secp256k1 signature (no prefix), and Safe will add the prefix before ecrecover.
But the SDK's sign_eip712_struct_hash uses encode_defunct (which adds EIP-191 prefix before signing), then split_and_pack_sig sets v = v_raw + 4 (producing v=31/32). This would mean Safe subtracts 4, gets v=27/28, and applies the prefix again — resulting in a double-prefix situation.
Why does the SDK work with a regular private key but produce wrong signatures when the EIP-191 prefix is applied via Privy's personal_sign?
Is the correct approach to use secp256k1_sign (raw hash, no prefix) and then set v = v_raw + 27 before passing to split_and_pack_sig? Or is there something else about how Privy handles the personal_sign + encoding: hex combination that differs from eth_account.encode_defunct?
Environment
Python 3.12
py-builder-relayer-client==0.0.1
eth-account==0.13.4
Polygon (chain ID 137)
Gnosis Safe v1.3.0 (GnosisSafeL2)
Privy server-side API (custodial wallet)
