You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Polymarket Relayer v2 集成Gnosis Safe与Privy托管钱包时POST /submit接口返回400 Bad Request问题排查

解答:Privy签名适配Polymarket无gas中继器的双重前缀冲突问题

问题本质:签名消息的前缀不匹配

先拆解下为什么SDK用本地私钥能正常工作,但Privy的personal_sign+encoding:hex会失败:

SDK原生签名的逻辑链

SDK的sign_eip712_struct_hash做了这几件事:

  1. 接收EIP-712的struct_hash(32字节哈希)
  2. 用encode_defunct(HexBytes(message_hash))给这个哈希套上EIP-191消息签名前缀:\x19Ethereum Signed Message:\n32 + 32字节哈希内容
  3. 用私钥签名这个带前缀的消息,得到v值为27/28的签名
  4. 内部的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.28 06:42:48