diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index e830e44..a3d14c1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -335,7 +335,8 @@ data class TradeResponse( val timestamp: String, // ISO 8601 格式字符串或时间戳 val user: String?, val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO) - val outcome: String? = null // 结果名称(如 "Up", "Down") + val outcome: String? = null, // 结果名称(如 "Up", "Down") + val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单) ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt index 5487260..5948bb3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt @@ -62,19 +62,21 @@ data class GammaEventMarketItem( /** * 事件响应(从 MarketResponse.events 解析) + * Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约 */ data class EventResponse( val id: String? = null, val ticker: String? = null, - val slug: String, - val title: String, + val slug: String? = null, + val title: String? = null, val category: String? = null, val active: Boolean? = null, val closed: Boolean? = null, val archived: Boolean? = null, val startDate: String? = null, val endDate: String? = null, - val createdAt: String? = null + val createdAt: String? = null, + val negRisk: Boolean? = null ) /** @@ -106,6 +108,8 @@ data class MarketResponse( val events: List? = null, // 事件列表(从 events[0] 获取 slug) // 以下字段可能存在于响应中,但不在标准文档中 val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组) - val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本) + val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本) + val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market) + val negRiskOther: Boolean? = null // Market 级 neg risk 标记 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt index af5f000..992c426 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt @@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.entity.Market import com.wrbug.polymarketbot.repository.MarketRepository import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.getEventSlug +import com.wrbug.polymarketbot.util.parseStringArray import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -207,6 +208,36 @@ class MarketService( } } + /** + * 按 tokenId 从 Gamma 解析市场信息(conditionId、outcomeIndex) + * 用于链上解析时 Gamma 失败、仅带 tokenId 的交易在 processBuyTrade 中补查市场 + */ + suspend fun getMarketInfoByTokenId(tokenId: String): MarketInfoByTokenId? { + if (tokenId.isBlank()) return null + return try { + val gammaApi = retrofitFactory.createGammaApi() + val response = gammaApi.listMarkets( + conditionIds = null, + clobTokenIds = listOf(tokenId), + includeTag = null + ) + if (!response.isSuccessful || response.body().isNullOrEmpty()) return null + val market = response.body()!!.first() + val conditionId = market.conditionId ?: return null + val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids + val clobTokenIds = (clobTokenIdsRaw ?: "").parseStringArray() + val outcomeIndex = clobTokenIds.indexOfFirst { it.equals(tokenId, ignoreCase = true) }.takeIf { it >= 0 } + ?: return null + val outcomes = market.outcomes.parseStringArray() + val outcome = if (outcomeIndex < outcomes.size) outcomes[outcomeIndex] else null + saveMarketFromResponse(conditionId, market) + MarketInfoByTokenId(conditionId = conditionId, outcomeIndex = outcomeIndex, outcome = outcome) + } catch (e: Exception) { + logger.warn("按 tokenId 查询市场失败: tokenId=$tokenId, error=${e.message}") + null + } + } + /** * 清除缓存(用于测试或手动刷新) */ @@ -230,5 +261,33 @@ class MarketService( null } } + + /** + * 根据 conditionId 查询该市场是否为 Neg Risk(需使用 Neg Risk Exchange 签约) + * 用于跟单下单时选择正确的 exchange 合约,避免 invalid signature + */ + suspend fun getNegRiskByConditionId(conditionId: String): Boolean? { + if (conditionId.isBlank()) return null + return try { + val gammaApi = retrofitFactory.createGammaApi() + val response = gammaApi.listMarkets(conditionIds = listOf(conditionId)) + if (!response.isSuccessful || response.body().isNullOrEmpty()) return null + val marketResponse = response.body()!!.first() + val fromEvent = marketResponse.events?.firstOrNull()?.negRisk + val fromMarket = marketResponse.negRisk ?: marketResponse.negRiskOther + fromEvent ?: fromMarket + } catch (e: Exception) { + logger.warn("查询市场 negRisk 失败: conditionId=$conditionId, error=${e.message}") + null + } + } } +/** + * 按 tokenId 查询 Gamma 得到的市场信息(用于补全 trade.market / outcomeIndex) + */ +data class MarketInfoByTokenId( + val conditionId: String, + val outcomeIndex: Int, + val outcome: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt index f5f1a0e..fb22cc6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt @@ -220,10 +220,10 @@ object OnChainWsUtils { return null } - // 尝试通过 Gamma API 查询市场信息(通过 tokenId) + // 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场 val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory) - // 创建 TradeResponse + // 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查 return TradeResponse( id = txHash, market = marketInfo?.conditionId ?: "", @@ -233,7 +233,8 @@ object OnChainWsUtils { timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(), user = walletAddress, outcomeIndex = marketInfo?.outcomeIndex, - outcome = marketInfo?.outcome + outcome = marketInfo?.outcome, + tokenId = asset.toString() ) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt index 3d8180f..2f94bfd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt @@ -463,6 +463,7 @@ class PolymarketActivityWsService( // 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}" + // asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId TradeResponse( id = tradeId, market = conditionId, @@ -472,7 +473,8 @@ class PolymarketActivityWsService( timestamp = timestamp, user = null, // Activity WS 中不需要 outcomeIndex = outcomeIndex, - outcome = outcome + outcome = outcome, + tokenId = asset ) } catch (e: Exception) { logger.error("解析 Activity Trade 失败: ${e.message}", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt index a4b1c0a..aab8c2d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt @@ -23,6 +23,14 @@ class OrderSigningService { private val logger = LoggerFactory.getLogger(OrderSigningService::class.java) + /** + * 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址 + * @param negRisk true 时使用 Neg Risk CTF Exchange,否则使用标准 CTF Exchange + */ + fun getExchangeContract(negRisk: Boolean): String { + return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT + } + /** * 根据钱包类型返回 CLOB 订单签名类型 * @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包 @@ -33,8 +41,10 @@ class OrderSigningService { return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2 } - // Polygon 主网合约地址 + // Polygon 主网合约地址(标准 CTF Exchange) private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" + // Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature) + private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a" private val CHAIN_ID = 137L // USDC 有 6 位小数 @@ -158,6 +168,7 @@ class OrderSigningService { * @param nonce nonce(默认 "0") * @param feeRateBps 费率基点(默认 "0") * @param expiration 过期时间戳(秒,0 表示永不过期) + * @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange * @return 签名的订单对象 */ fun createAndSignOrder( @@ -170,7 +181,8 @@ class OrderSigningService { signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致) nonce: String = "0", feeRateBps: String = "0", - expiration: String = "0" + expiration: String = "0", + exchangeContract: String? = null ): SignedOrderObject { try { // 1. 从私钥获取签名地址 @@ -203,10 +215,11 @@ class OrderSigningService { logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps") logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID") - // 6. 构建订单数据并签名 + // 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT) + val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT val signature = signOrder( privateKey = privateKey, - exchangeContract = EXCHANGE_CONTRACT, + exchangeContract = contract, chainId = CHAIN_ID, salt = salt, maker = makerAddressLower, @@ -268,20 +281,20 @@ class OrderSigningService { signatureType: Int ): String { try { - // 1. 从私钥创建 BigInteger + // 1. 私钥与密钥对 val cleanPrivateKey = privateKey.removePrefix("0x") val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) - val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt) - - // 2. 编码域分隔符 + val credentials = Credentials.create(privateKeyBigInt.toString(16)) + val ecKeyPair = credentials.ecKeyPair + + // 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致) val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain( chainId = chainId, - verifyingContract = exchangeContract + verifyingContract = exchangeContract.lowercase() ) - + // 3. 编码订单消息哈希 - // signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA - // 使用传入的 signatureType 参数,而不是硬编码 + // signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder( salt = salt, maker = maker, @@ -294,29 +307,25 @@ class OrderSigningService { nonce = nonce, feeRateBps = feeRateBps, side = side, - signatureType = signatureType // 使用传入的参数 + signatureType = signatureType ) - - // 4. 计算完整的结构化数据哈希 + + // 4. 计算完整 EIP-712 结构化数据哈希 val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( domainSeparator = domainSeparator, messageHash = orderHash ) - - // 5. 使用私钥签名 + + // 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名) val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false) - - // 6. 组合签名(r + s + v) + + // 6. 组合 r + s + v val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') - val vBytes = signature.v as ByteArray - val vInt = if (vBytes.isNotEmpty()) { - vBytes[0].toInt() and 0xff - } else { - 0 - } - val vHex = String.format("%02x", vInt) - + val vBytes = signature.v + val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0 + val vHex = "%02x".format(vInt) + return "0x$rHex$sHex$vHex" } catch (e: Exception) { logger.error("订单签名失败", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index cf58159..cfec395 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -258,19 +258,36 @@ open class CopyOrderTrackingService( continue } - // 直接使用outcomeIndex获取tokenId(支持多元市场) - if (trade.outcomeIndex == null) { - logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}") - continue + // 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算 + val tokenId = if (!trade.tokenId.isNullOrBlank()) { + trade.tokenId + } else { + if (trade.outcomeIndex == null) { + logger.warn("交易缺少outcomeIndex且无tokenId,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}") + continue + } + val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex) + if (tokenIdResult.isFailure) { + logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + continue + } + tokenIdResult.getOrNull() ?: continue } - // 获取tokenId(直接使用outcomeIndex,不转换为YES/NO) - val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex) - if (tokenIdResult.isFailure) { - logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + // 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息 + var effectiveMarketId = trade.market + var effectiveOutcomeIndex = trade.outcomeIndex + if (effectiveMarketId.isBlank() && !trade.tokenId.isNullOrBlank()) { + val infoByToken = marketService.getMarketInfoByTokenId(trade.tokenId) + if (infoByToken != null) { + effectiveMarketId = infoByToken.conditionId + effectiveOutcomeIndex = infoByToken.outcomeIndex + } + } + if (effectiveMarketId.isBlank()) { + logger.warn("无法确定市场(conditionId),跳过: tradeId=${trade.id}, tokenId=${trade.tokenId}") continue } - val tokenId = tokenIdResult.getOrNull() ?: continue // 先计算跟单金额(用于仓位检查) // 注意:这里先计算金额,即使后续被过滤也会记录 @@ -293,7 +310,7 @@ open class CopyOrderTrackingService( if (needMarketInfo) { try { - val market = marketService.getMarket(trade.market) + val market = marketService.getMarket(effectiveMarketId) marketTitle = market?.title marketEndDate = market?.endDate } catch (e: Exception) { @@ -312,10 +329,10 @@ open class CopyOrderTrackingService( tokenId, tradePrice = tradePrice, copyOrderAmount = copyOrderAmount, - marketId = trade.market, + marketId = effectiveMarketId, marketTitle = marketTitle, marketEndDate = marketEndDate, - outcomeIndex = trade.outcomeIndex + outcomeIndex = effectiveOutcomeIndex ) val orderbook = filterResult.orderbook // 获取订单簿(如果需要) if (!filterResult.isPassed) { @@ -325,8 +342,8 @@ open class CopyOrderTrackingService( notificationScope.launch { try { // 获取市场信息(标题和slug) - val market = marketService.getMarket(trade.market) - val marketTitle = market?.title ?: trade.market + val market = marketService.getMarket(effectiveMarketId) + val marketTitle = market?.title ?: effectiveMarketId val marketSlug = market?.slug // 显示用的 slug // 从过滤结果中提取 filterType @@ -346,11 +363,11 @@ open class CopyOrderTrackingService( accountId = copyTrading.accountId, leaderId = copyTrading.leaderId, leaderTradeId = trade.id, - marketId = trade.market, + marketId = effectiveMarketId, marketTitle = marketTitle, marketSlug = marketSlug, side = "BUY", - outcomeIndex = trade.outcomeIndex, + outcomeIndex = effectiveOutcomeIndex, outcome = trade.outcome, price = trade.price.toSafeBigDecimal(), size = trade.size.toSafeBigDecimal(), @@ -376,7 +393,7 @@ open class CopyOrderTrackingService( telegramNotificationService?.sendOrderFilteredNotification( marketTitle = marketTitle, - marketId = trade.market, + marketId = effectiveMarketId, marketSlug = marketSlug, side = "BUY", outcome = trade.outcome, @@ -556,6 +573,11 @@ open class CopyOrderTrackingService( logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps") + // Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature + val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true + val exchangeContract = orderSigningService.getExchangeContract(negRisk) + if (negRisk) logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=$effectiveMarketId") + // 调用API创建订单(带重试机制) // 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒 // 每次重试都会重新生成salt并重新签名,确保签名唯一性 @@ -563,6 +585,8 @@ open class CopyOrderTrackingService( clobApi = clobApi, privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, + walletAddress = account.walletAddress, + exchangeContract = exchangeContract, tokenId = tokenId, side = "BUY", price = buyPrice.toString(), @@ -585,8 +609,8 @@ open class CopyOrderTrackingService( notificationScope.launch { try { // 获取市场信息(标题和slug) - val market = marketService.getMarket(trade.market) - val marketTitle = market?.title ?: trade.market + val market = marketService.getMarket(effectiveMarketId) + val marketTitle = market?.title ?: effectiveMarketId val marketSlug = market?.eventSlug // 跳转用的 slug // 获取当前语言设置(从 LocaleContextHolder) @@ -598,7 +622,7 @@ open class CopyOrderTrackingService( telegramNotificationService?.sendOrderFailureNotification( marketTitle = marketTitle, - marketId = trade.market, + marketId = effectiveMarketId, marketSlug = marketSlug, side = "BUY", outcome = null, // 失败时可能没有 outcome @@ -632,9 +656,9 @@ open class CopyOrderTrackingService( copyTradingId = copyTrading.id, accountId = copyTrading.accountId, leaderId = copyTrading.leaderId, - marketId = trade.market, - side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据) - outcomeIndex = trade.outcomeIndex, // 新增字段 + marketId = effectiveMarketId, + side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据) + outcomeIndex = effectiveOutcomeIndex, // 新增字段 buyOrderId = realOrderId, // 使用真实订单ID leaderBuyTradeId = trade.id, leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例) @@ -904,13 +928,21 @@ open class CopyOrderTrackingService( finalNeedMatch = BigDecimal.ONE } - // 4. 获取tokenId(直接使用outcomeIndex,支持多元市场) - val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) - if (tokenIdResult.isFailure) { - logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") - return + // 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算 + val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) { + leaderSellTrade.tokenId + } else { + if (leaderSellTrade.outcomeIndex == null) { + logger.error("卖出交易缺少outcomeIndex且无tokenId: market=${leaderSellTrade.market}") + return + } + val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) + if (tokenIdResult.isFailure) { + logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + return + } + tokenIdResult.getOrNull() ?: return } - val tokenId = tokenIdResult.getOrNull() ?: return // 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算) // 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格 @@ -995,7 +1027,12 @@ open class CopyOrderTrackingService( "0" } - // 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType) + // 9. Neg Risk 市场需用 Neg Risk Exchange 签约 + val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true + val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell) + if (negRiskSell) logger.debug("卖出市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${leaderSellTrade.market}") + + // 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType) val signedOrder = try { orderSigningService.createAndSignOrder( privateKey = decryptedPrivateKey, @@ -1007,14 +1044,15 @@ open class CopyOrderTrackingService( signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType), nonce = "0", feeRateBps = feeRateBps, // 使用动态获取的费率 - expiration = "0" + expiration = "0", + exchangeContract = exchangeContractSell ) } catch (e: Exception) { logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e) return } - // 10. 构建订单请求 + // 11. 构建订单请求 // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 val orderRequest = NewOrderRequest( @@ -1024,7 +1062,7 @@ open class CopyOrderTrackingService( deferExec = false ) - // 11. 创建带认证的CLOB API客户端(使用解密后的凭证) + // 12. 创建带认证的CLOB API客户端(使用解密后的凭证) val clobApi = retrofitFactory.createClobApi( account.apiKey, apiSecret, @@ -1032,12 +1070,13 @@ open class CopyOrderTrackingService( account.walletAddress ) - // 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) - + // 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) val createOrderResult = createOrderWithRetry( clobApi = clobApi, privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, + walletAddress = account.walletAddress, + exchangeContract = exchangeContractSell, tokenId = tokenId, side = "SELL", price = sellPrice.toString(), @@ -1130,7 +1169,9 @@ open class CopyOrderTrackingService( * * @param clobApi CLOB API 客户端 * @param privateKey 私钥(用于签名) - * @param makerAddress 代理钱包地址 + * @param makerAddress 代理钱包地址(funder) + * @param walletAddress 账户 EOA 地址(须与私钥推导的 signer 一致,用于校验及 POLY_ADDRESS) + * @param exchangeContract 签约用 exchange 合约(Neg Risk 市场需用 Neg Risk Exchange) * @param tokenId Token ID * @param side 订单方向(BUY/SELL) * @param price 价格 @@ -1146,6 +1187,8 @@ open class CopyOrderTrackingService( clobApi: PolymarketClobApi, privateKey: String, makerAddress: String, + walletAddress: String, + exchangeContract: String, tokenId: String, side: String, price: String, @@ -1172,9 +1215,17 @@ open class CopyOrderTrackingService( signatureType = signatureType, nonce = "0", feeRateBps = feeRateBps, // 使用动态获取的费率 - expiration = "0" + expiration = "0", + exchangeContract = exchangeContract ) + // 校验 signer 与账户 walletAddress 一致,否则服务端会返回 invalid signature(POLY_ADDRESS 与 order.signer 需一致) + if (signedOrder.signer.lowercase() != walletAddress.lowercase()) { + val msg = "订单 signer 与账户 walletAddress 不一致,会导致 invalid signature。请确认该账户的私钥与 walletAddress 对应同一 EOA,且 API 密钥由该 EOA 创建。signer=${signedOrder.signer.take(10)}..., walletAddress=${walletAddress.take(10)}..." + logger.error(msg) + return Result.failure(IllegalStateException(msg)) + } + // 构建订单请求 // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配