Polymarket Relayer v2 集成Gnosis Safe与Privy托管钱包时POST /submit接口返回400 Bad Request问题排查
问题本质:签名消息的前缀不匹配
先拆解下为什么SDK用本地私钥能正常工作,但Privy的personal_sign+encoding:hex会失败:
SDK原生签名的逻辑链
SDK的sign_eip712_struct_hash做了这几件事:
- 接收EIP-712的
struct_hash(32字节哈希) - 用
encode_defunct(HexBytes(message_hash))给这个哈希套上EIP-191消息签名前缀:\x19Ethereum Signed Message:\n32+ 32字节哈希内容 - 用私钥签名这个带前缀的消息,得到v值为27/28的签名
- 内部的
split_and_pack_sig会把v值加4,变成31/32后提交给中继器
而Gnosis Safe v1.3.0的checkSignatures逻辑是:
currentOwner = ecrecover(
keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)),
v - 4,
r,
s
);
这里的dataHash就是EIP-712的struct_hash——Safe会自动给它套上EIP-191前缀,然后把传入的v值减4(还原成27/28)再执行ecrecover。这时候SDK的签名刚好是对“EIP-191前缀+struct_hash”的签名,所以验证通过。
Privy的personal_sign+encoding:hex哪里错了?
当你传入hash_hex(比如0x918fb835...)并指定encoding:hex时,Privy的personal_sign会把这个hex字符串本身当作原始消息内容,而不是把它解析成32字节的哈希。也就是说:
- eth_account的
encode_defunct处理的是32字节的哈希内容,前缀是\x19Ethereum Signed Message:\n32(因为消息长度是32) - Privy处理的是66字节的字符串内容(
0x+64个字符),前缀变成了\x19Ethereum Signed Message:\n66
两者签名的消息完全不同,自然ecrecover出的地址就不对了。
正确的适配方案:直接签名原始哈希并调整v值
根据你的诊断测试,Privy的secp256k1_sign接口直接对原始哈希字节签名(不添加任何前缀),这正是我们需要的。具体步骤如下:
1. 修改Privy签名调用逻辑
改用Privy的secp256k1_sign接口,直接传入EIP-712的struct_hash(hex格式):
async def _privy_sign_secp256k1(privy_wallet_id: str, hash_hex: str) -> str: payload = { "method": "secp256k1_sign", "params": {"hash": hash_hex}, } # 发送请求到Privy API并返回原始签名(v值为27或28) response = await requests.post(PRIVY_API_URL, json=payload, headers=AUTH_HEADERS) response.raise_for_status() return response.json()["result"]
2. 调整PrivySigner的签名方法
修改sign_eip712_struct_hash,对原始签名的v值加4(适配Safe的v-4逻辑):
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_eip712_struct_hash(self, message_hash) -> str: # 处理哈希格式,转成Privy需要的hex字符串 hash_hex = "0x" + message_hash.hex() if isinstance(message_hash, bytes) else message_hash # 获取原始secp256k1签名(v=27/28) raw_sig_hex = _run_sync(_privy_sign_secp256k1(self.privy_wallet_id, hash_hex)) raw_sig = HexBytes(raw_sig_hex) # 拆分签名并调整v值:原始v+4 → 31/32,适配Safe的v-4逻辑 r = raw_sig[:32] s = raw_sig[32:64] v_raw = raw_sig[64] v_adjusted = v_raw + 4 adjusted_sig = r + s + bytes([v_adjusted]) return f"0x{adjusted_sig.hex()}"
3. 验证逻辑
调整后:
- 我们签名的是原始的EIP-712 struct_hash(无前缀)
- 提交给中继器的签名v值是31/32
- Safe收到后,v-4还原为27/28,然后自动给
struct_hash套上EIP-191前缀执行ecrecover,刚好匹配我们的签名内容,就能正确恢复出所有者地址。
为什么SDK的本地私钥逻辑能工作?
本质是SDK的签名行为和Safe的验证行为刚好“对齐”了:SDK给struct_hash套了一次EIP-191前缀并签名,然后把v值加4;Safe给struct_hash再套一次EIP-191前缀,把v值减4后验证。这看起来是双重前缀,但其实SDK的encode_defunct是把struct_hash当作32字节的消息来签名,而Safe的验证逻辑也是把struct_hash当作32字节的消息来处理,所以两者的签名验证上下文是一致的。但Privy的personal_sign+encoding:hex错误地把哈希字符串当作消息,破坏了这个对齐。
内容的提问来源于stack exchange,提问作者Renan Aquino

