Gnosis Safe+Privy托管钱包调用Polymarket Relayer v2 POST /submit返回400 bad request的问题排查咨询
背景概述
我正在开发一个集成Polymarket Gasless Relayer的平台,技术栈包括:
- Gnosis Safe代理钱包(通过Polymarket的工厂合约
0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b部署) - Privy作为密钥托管方(无法获取原始私钥)
- 官方SDK
py-builder-relayer-client==0.0.1 py-builder-signing-sdk用于HMAC认证
目标是批量提交6笔ERC-20/ERC-1155授权交易到Relayer,实现Gasless操作。
已确认正常的环节
我已经验证了以下所有流程都能正常工作:
- 调用
GET /transactions返回200,说明Builder凭证有效 - 调用
GET /deployed?address=...返回{"deployed": true},确认Safe已在Polygon链上部署 - 调用
GET /nonce返回{"nonce": "0"},非ce值正确 - 使用测试私钥(另一EOA/Safe)调用
client.execute()返回200 OK - SDK推导的proxyWallet地址和链上部署的Safe地址完全匹配
遇到的问题
当我注入自定义的PrivySigner(调用Privy API而非本地私钥签名)后,调用POST /submit始终返回{"error": "bad request"}。
签名逻辑对比
SDK原生Signer实现
SDK的默认Signer类签名逻辑如下:
# SDK original (py-builder-relayer-client/signer.py) def sign_eip712_struct_hash(self, message_hash): msg = encode_defunct(HexBytes(message_hash)) # 自动添加EIP-191前缀 sig = Account.sign_message(msg, self.private_key).signature.hex() return prepend_zx(sig)
它会先通过encode_defunct给消息哈希加上EIP-191前缀,再用本地私钥签名。
我的PrivySigner实现
我替换为调用Privy API的实现:
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) self._chain_id = chain_id 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) # 尝试用Privy的personal_sign+hex编码来匹配encode_defunct的行为 sig_hex = _run_sync(_privy_sign_eip191(self.privy_wallet_id, h)) return sig_hex
其中_privy_sign_eip191的实现是调用Privy的API:
async def _privy_sign_eip191(privy_wallet_id: str, hash_hex: str) -> str: payload = { "method": "personal_sign", "params": {"message": hash_hex, "encoding": "hex"}, } # 此处省略POST到Privy API的逻辑
诊断结果
我通过ecrecover测试验证了签名的有效性:
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" # 替换为实际签名 # 测试1:模拟SDK的encode_defunct逻辑 msg = encode_defunct(HexBytes(struct_hash)) recovered = Account.recover_message(msg, signature=sig_from_privy_personal_sign) print(recovered) # → 0xeBcC372BA40bF88e730e0C901114713428B20f49 ← 错误地址 # 测试2:直接用原始哈希签名(无EIP-191前缀) sig_raw = "0x3ea38967...1c" # 来自Privy的secp256k1_sign接口 recovered2 = Account._recover_hash(HexBytes(struct_hash), signature=sig_raw) print(recovered2) # → 0xD15b9f4Dab19808eb4F25AB647820cD8111cE1ef ← 正确地址
结论:
- Privy的
personal_sign+encoding: hex无法复现eth_account中encode_defunct(HexBytes(hash))的签名效果 - 直接调用Privy的
secp256k1_sign(原始哈希,无前缀)可以恢复出正确的EOA地址
核心疑问
我研究了Gnosis Safe L2 v1.3.0合约的checkSignatures逻辑,对于EOA签名(v=31或v=32),合约内部会执行:
currentOwner = ecrecover( keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s );
也就是说,Safe合约会自动给dataHash加上EIP-191前缀再做ecrecover,并且会把v值减4(从31/32变回27/28)。
但SDK的sign_eip712_struct_hash逻辑是:先通过encode_defunct加EIP-191前缀,再签名,然后split_and_pack_sig会把v值加4(得到31/32)。这就会导致Safe合约再次添加前缀,出现双重前缀的问题?
但为什么用本地私钥时SDK能正常工作,而用Privy的personal_sign添加前缀就会失败?
正确的处理方式应该是:调用Privy的secp256k1_sign(原始哈希,无前缀),然后手动把v值加27再传给SDK的签名处理逻辑吗?还是说Privy的personal_sign+encoding: hex的参数有什么我没注意到的细节?




