From 978672f9f4d0fe7f5e5b969683a0b8b143e4c47d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 19:16:13 +0100 Subject: [PATCH 01/16] feat(transaction-pay-controller): add Polymarket Bridge withdrawal strategy Adds PolymarketBridgeStrategy for predictWithdraw transactions of deposit-wallet users. Routes withdrawals through Polymarket's Bridge API (quote + one-shot deposit address) and Relayer API (signed WALLET batch dispatch). Gated behind payPolymarketBridgeWithdrawEnabled feature flag. Legacy Safe users continue to use the Relay strategy. User-facing flow: one EIP-712 signature, no on-chain transaction from the user's EOA, zero gas paid (Polymarket's relayer covers it), ~25s end-to-end. Mobile-side adoption ships in feat/polymarket-bridge-withdraw-adopt. --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/TransactionPayController.ts | 4 + .../src/constants.ts | 1 + .../transaction-pay-controller/src/index.ts | 6 + .../PolymarketBridgeStrategy.ts | 194 +++++++++++++ .../strategy/polymarket-bridge/bridge-api.ts | 267 ++++++++++++++++++ .../strategy/polymarket-bridge/constants.ts | 36 +++ .../src/strategy/polymarket-bridge/intent.ts | 170 +++++++++++ .../strategy/polymarket-bridge/relayer-api.ts | 214 ++++++++++++++ .../src/strategy/polymarket-bridge/types.ts | 137 +++++++++ .../wallet-batch-typed-data.ts | 109 +++++++ .../strategy/polymarket-bridge/withdraw.ts | 166 +++++++++++ .../transaction-pay-controller/src/types.ts | 18 ++ .../src/utils/strategy.ts | 24 ++ 14 files changed, 1350 insertions(+) create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c9bea4df38..84c1fb421d 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + ### Changed - Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 61cb6b4790..94f061fc40 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -26,6 +26,7 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; +import { setPolymarketBridgeOptions } from './utils/strategy'; import { getTransaction, pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ @@ -69,6 +70,7 @@ export class TransactionPayController extends BaseController< getStrategy, getStrategies, messenger, + polymarketBridgeOptions, state, }: TransactionPayControllerOptions) { super({ @@ -82,6 +84,8 @@ export class TransactionPayController extends BaseController< this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; + setPolymarketBridgeOptions(polymarketBridgeOptions); + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 5a097ee85d..6b577aa1c4 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -41,6 +41,7 @@ export enum TransactionPayStrategy { Across = 'across', Bridge = 'bridge', Fiat = 'fiat', + PolymarketBridge = 'polymarket-bridge', Relay = 'relay', Test = 'test', } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index dc7764daff..9d3efdd260 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,4 +1,5 @@ export type { + PolymarketBridgeStrategyOptionsInput, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -30,3 +31,8 @@ export { TransactionPayStrategy } from './constants'; export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; +export { PolymarketBridgeStrategy } from './strategy/polymarket-bridge/PolymarketBridgeStrategy'; +export type { + PolymarketBridgeQuote, + PolymarketBridgeStrategyOptions, +} from './strategy/polymarket-bridge/types'; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts new file mode 100644 index 0000000000..0685dcc150 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -0,0 +1,194 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetBatchRequest, + PayStrategyGetQuotesRequest, + PayStrategyGetRefreshIntervalRequest, + TransactionPayQuote, +} from '../../types'; +import { PolymarketBridgeApi } from './bridge-api'; +import { PUSD_ADDRESS_POLYGON, PUSD_DECIMALS } from './constants'; +import { extractPolymarketWithdrawIntent } from './intent'; +import { PolymarketRelayerApi } from './relayer-api'; +import type { RelayerCredentials } from './relayer-api'; +import type { + PolymarketBridgeQuote, + PolymarketBridgeStrategyOptions, +} from './types'; +import { submitPolymarketBridgeWithdraw } from './withdraw'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-strategy'); + +const REFRESH_INTERVAL_MS = 25_000; + +export class PolymarketBridgeStrategy + implements PayStrategy +{ + readonly #bridgeApi: PolymarketBridgeApi; + + readonly #relayerApi: PolymarketRelayerApi; + + constructor(options: PolymarketBridgeStrategyOptions) { + this.#bridgeApi = new PolymarketBridgeApi(options.environment); + + const creds: RelayerCredentials = + options.authType === 'relayer-api-key' + ? { + type: 'relayer-api-key', + apiKey: options.relayerApiKey, + address: options.relayerApiKeyAddress, + } + : { + type: 'builder', + apiKey: options.builderApiKey, + secret: options.builderSecret, + passphrase: options.builderPassphrase ?? '', + }; + + this.#relayerApi = new PolymarketRelayerApi(options.environment, creds); + } + + supports(request: PayStrategyGetQuotesRequest): boolean { + const intent = extractPolymarketWithdrawIntent(request.transaction); + + if (!intent) { + return false; + } + + log('Supports deposit-wallet predictWithdraw', { + depositWallet: intent.depositWalletAddress, + amount: intent.amount.toString(), + }); + + return true; + } + + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + const intent = extractPolymarketWithdrawIntent(request.transaction); + + if (!intent) { + return []; + } + + const quoteRequest = request.requests[0]; + + if (!quoteRequest) { + return []; + } + + const bridgeQuote = await this.#bridgeApi.getQuote({ + fromAmountBaseUnit: intent.amount.toString(), + fromChainId: '137', + fromTokenAddress: PUSD_ADDRESS_POLYGON.toLowerCase(), + recipientAddress: quoteRequest.from, + toChainId: parseInt(quoteRequest.targetChainId, 16).toString(), + toTokenAddress: quoteRequest.targetTokenAddress.toLowerCase(), + }); + + const humanAmount = formatBaseUnits(intent.amount, PUSD_DECIMALS); + + const quote: TransactionPayQuote = { + original: bridgeQuote, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, + max: { fiat: '0', usd: '0', human: '0', raw: '0' }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + sourceAmount: { + fiat: '0', + usd: '0', + human: humanAmount, + raw: intent.amount.toString(), + }, + targetAmount: { fiat: '0', usd: '0' }, + dust: { fiat: '0', usd: '0' }, + estimatedDuration: bridgeQuote.estCheckoutTimeMs / 1000, + strategy: TransactionPayStrategy.PolymarketBridge, + request: quoteRequest, + }; + + log('Quote built', { quoteId: bridgeQuote.quoteId }); + + return [quote]; + } + + async execute( + request: PayStrategyExecuteRequest, + ): Promise<{ transactionHash?: Hex }> { + const intent = extractPolymarketWithdrawIntent(request.transaction); + + if (!intent) { + throw new Error( + 'Polymarket bridge execute: transaction is not a deposit-wallet predictWithdraw', + ); + } + + const quote = request.quotes[0]; + + if (!quote) { + throw new Error('Polymarket bridge execute: no quote provided'); + } + + const from = request.transaction.txParams.from as Hex; + + log('Creating one-shot deposit address'); + + const depositAddress = await this.#bridgeApi.createWithdrawAddress({ + address: intent.depositWalletAddress, + toChainId: parseInt(quote.request.targetChainId, 16).toString(), + toTokenAddress: quote.request.targetTokenAddress.toLowerCase(), + recipientAddr: from, + }); + + quote.original.bridgeDepositAddress = depositAddress; + + log('Deposit address created', { depositAddress }); + + const result = await submitPolymarketBridgeWithdraw( + quote, + from, + intent.depositWalletAddress, + request.messenger, + this.#relayerApi, + ); + + // Fire-and-forget bridge status poll for telemetry. + this.#bridgeApi.getStatus(depositAddress).catch((error) => { + log('Bridge status poll failed (telemetry)', error); + }); + + return { transactionHash: result.relayerTransactionHash }; + } + + async getBatchTransactions( + _request: PayStrategyGetBatchRequest, + ): Promise<[]> { + return []; + } + + async getRefreshInterval( + _request: PayStrategyGetRefreshIntervalRequest, + ): Promise { + return REFRESH_INTERVAL_MS; + } +} + +function formatBaseUnits(amount: bigint, decimals: number): string { + const divisor = 10n ** BigInt(decimals); + const whole = amount / divisor; + const remainder = amount % divisor; + const paddedRemainder = remainder.toString().padStart(decimals, '0'); + + return `${whole}.${paddedRemainder}`; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts new file mode 100644 index 0000000000..b4da234cf4 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts @@ -0,0 +1,267 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import { + POLYMARKET_BRIDGE_BASE_URL_PROD, + POLYMARKET_BRIDGE_BASE_URL_PREPROD, +} from './constants'; +import type { + PolymarketBridgeFeeBreakdown, + PolymarketBridgeQuote, +} from './types'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-api'); + +/** + * Error thrown by Polymarket Bridge API operations. + */ +export class PolymarketBridgeError extends Error { + code: string; + + raw: unknown; + + constructor(message: string, code: string, raw?: unknown) { + super(message); + this.name = 'PolymarketBridgeError'; + this.code = code; + this.raw = raw; + } +} + +/** Raw quote response from Bridge API POST /quote. */ +type BridgeQuoteResponse = { + quoteId: string; + estToTokenBaseUnit: string; + estCheckoutTimeMs: number; + estInputUsd: number; + estOutputUsd: number; + estFeeBreakdown: PolymarketBridgeFeeBreakdown; +}; + +/** Raw withdraw response from Bridge API POST /withdraw. */ +type BridgeWithdrawResponse = { + address: { + evm: string; + }; + note: string; +}; + +/** Single transaction entry from Bridge API GET /status. */ +type BridgeStatusTransaction = { + status: string; + txHash?: string; + createdTimeMs?: number; + fromChainId: string; + toChainId: string; + fromTokenAddress: string; + toTokenAddress: string; + fromAmountBaseUnit: string; +}; + +/** Raw status response from Bridge API GET /status/{address}. */ +type BridgeStatusResponse = { + transactions: BridgeStatusTransaction[]; +}; + +/** + * HTTP client for the Polymarket Bridge API. + * + * Provides methods to get bridge quotes, create one-shot deposit addresses, + * and poll for bridge transaction status. + */ +export class PolymarketBridgeApi { + readonly #baseUrl: string; + + /** + * Creates a new PolymarketBridgeApi instance. + * + * @param environment - The API environment to use ('prod' or 'preprod'). + */ + constructor(environment: 'prod' | 'preprod') { + this.#baseUrl = + environment === 'prod' + ? POLYMARKET_BRIDGE_BASE_URL_PROD + : POLYMARKET_BRIDGE_BASE_URL_PREPROD; + } + + /** + * Fetch a bridge quote for a cross-chain transfer. + * + * @param request - The quote request parameters. + * @param request.fromAmountBaseUnit - Amount to bridge in base units. + * @param request.fromChainId - Source chain ID. + * @param request.fromTokenAddress - Source token address. + * @param request.recipientAddress - Recipient address on the destination chain. + * @param request.toChainId - Destination chain ID. + * @param request.toTokenAddress - Destination token address. + * @returns A PolymarketBridgeQuote with bridgeDepositAddress set to null. + */ + async getQuote(request: { + fromAmountBaseUnit: string; + fromChainId: string; + fromTokenAddress: string; + recipientAddress: string; + toChainId: string; + toTokenAddress: string; + }): Promise { + const url = `${this.#baseUrl}/quote`; + + log('Fetching quote', { url, request }); + + const data = await this.#post(url, request); + + log('Quote received', { quoteId: data.quoteId }); + + return { + quoteId: data.quoteId, + bridgeDepositAddress: null, + fromAmount: request.fromAmountBaseUnit, + toAmount: data.estToTokenBaseUnit, + minReceived: data.estToTokenBaseUnit, + estCheckoutTimeMs: data.estCheckoutTimeMs, + estFeeBreakdown: data.estFeeBreakdown, + }; + } + + /** + * Create a one-shot deposit address for a bridge withdrawal. + * + * @param request - The withdraw address request parameters. + * @param request.address - The source address. + * @param request.toChainId - Destination chain ID. + * @param request.toTokenAddress - Destination token address. + * @param request.recipientAddr - Recipient address on the destination chain. + * @returns The EVM deposit address as a hex string. + */ + async createWithdrawAddress(request: { + address: string; + toChainId: string; + toTokenAddress: string; + recipientAddr: string; + }): Promise { + const url = `${this.#baseUrl}/withdraw`; + + log('Creating withdraw address', { url, request }); + + const data = await this.#post(url, request); + + log('Withdraw address created', { address: data.address.evm }); + + return data.address.evm as Hex; + } + + /** + * Get the bridge transaction status for a deposit address. + * + * @param depositAddress - The deposit address to check status for. + * @returns Array of bridge status transactions. + */ + async getStatus(depositAddress: string): Promise { + const url = `${this.#baseUrl}/status/${depositAddress}`; + + log('Fetching status', { url, depositAddress }); + + const data = await this.#get(url); + + log('Status received', { + depositAddress, + transactionCount: data.transactions.length, + }); + + return data.transactions; + } + + /** + * Get supported assets from the bridge API. + * + * @returns The raw supported assets response. + */ + async getSupportedAssets(): Promise { + const url = `${this.#baseUrl}/supported-assets`; + + log('Fetching supported assets', { url }); + + const data: unknown = await this.#get(url); + + log('Supported assets received'); + + return data; + } + + /** + * Send a POST request to the bridge API. + * + * @param url - The endpoint URL. + * @param body - The request body to serialize as JSON. + * @returns The parsed JSON response. + */ + async #post(url: string, body: unknown): Promise { + return this.#fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + /** + * Send a GET request to the bridge API. + * + * @param url - The endpoint URL. + * @returns The parsed JSON response. + */ + async #get(url: string): Promise { + return this.#fetch(url, { method: 'GET' }); + } + + /** + * Execute a fetch request, parsing the JSON response and wrapping errors + * in PolymarketBridgeError. + * + * @param url - The endpoint URL. + * @param init - Fetch init options. + * @returns The parsed JSON response. + */ + async #fetch( + url: string, + init: RequestInit, + ): Promise { + const response = await bridgeFetch(url, init); + return (await response.json()) as ResponseType; + } +} + +/** + * Fetch a Bridge API endpoint, throwing a PolymarketBridgeError on non-OK + * responses. Preserves the API's error message when available. + * + * @param url - The endpoint to fetch. + * @param init - Fetch init options. + * @returns The successful response. + */ +async function bridgeFetch(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + + if (!response.ok) { + let detail: string | undefined; + let rawBody: unknown; + + try { + rawBody = await response.json(); + const body = rawBody as { message?: string; error?: string }; + detail = body.message ?? body.error; + } catch { + // Body wasn't JSON; fall through to status-only error. + } + + throw new PolymarketBridgeError( + detail + ? `Bridge API ${response.status} - ${detail}` + : `Bridge API ${String(response.status)}`, + String(response.status), + rawBody, + ); + } + + return response; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts new file mode 100644 index 0000000000..2942d0048e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -0,0 +1,36 @@ +import type { Hex } from '@metamask/utils'; + +// Bridge API base URLs +export const POLYMARKET_BRIDGE_BASE_URL_PROD = 'https://bridge.polymarket.com'; +export const POLYMARKET_BRIDGE_BASE_URL_PREPROD = + 'https://bridge-preprod.polymarket.com'; + +// Relayer API base URLs +export const POLYMARKET_RELAYER_BASE_URL_PROD = + 'https://relayer-v2.polymarket.com'; +export const POLYMARKET_RELAYER_BASE_URL_PREPROD = + 'https://relayer-v2-preprod-int.polymarket.com'; + +// On-chain addresses (Polygon) +export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = + '0x00000000000Fb5C9ADea0298D729A0CB3823Cc07' as Hex; +export const PUSD_ADDRESS_POLYGON = + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; + +// EIP-712 domain +export const POLYMARKET_WALLET_DOMAIN_NAME = 'DepositWallet'; +export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; + +// Transaction parameters +export const POLYMARKET_BATCH_DEADLINE_SECONDS = 240; + +// Relayer terminal states — once the relayer enters one of these, stop polling +export const RELAYER_TERMINAL_STATES = [ + 'STATE_MINED', + 'STATE_CONFIRMED', + 'STATE_FAILED', + 'STATE_INVALID', +] as const; + +// pUSD decimals (same as USDC) +export const PUSD_DECIMALS = 6; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts new file mode 100644 index 0000000000..67308f663e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts @@ -0,0 +1,170 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { CHAIN_ID_POLYGON } from '../../constants'; +import { projectLogger } from '../../logger'; +import { PUSD_ADDRESS_POLYGON } from './constants'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-intent'); + +/** + * ERC-20 `transfer(address,uint256)` four-byte selector. + */ +const TOKEN_TRANSFER_SELECTOR = '0xa9059cbb'; + +/** + * Minimum length of a valid `transfer(address,uint256)` calldata string. + * 0x (2) + selector (8) + address param (64) + uint256 param (64) = 138. + */ +const TRANSFER_CALLDATA_MIN_LENGTH = 138; + +/** + * Extract the intent from a Polymarket deposit-wallet predictWithdraw + * transaction. + * + * Returns the pUSD transfer amount and the deposit wallet address for + * deposit-wallet users. Returns `undefined` for non-matching transactions + * (wrong type, wrong chain, Safe-based withdrawals, etc.). + * + * @param transaction - Transaction metadata. + * @returns The withdrawal intent or `undefined`. + */ +export function extractPolymarketWithdrawIntent( + transaction: TransactionMeta, +): { amount: bigint; depositWalletAddress: Hex } | undefined { + if (!isPredictWithdraw(transaction)) { + log('Not a predictWithdraw transaction', transaction.type); + return undefined; + } + + if (transaction.chainId !== CHAIN_ID_POLYGON) { + log('Not on Polygon', transaction.chainId); + return undefined; + } + + const transferCall = findPusdTransferCall(transaction); + + if (!transferCall) { + log('No pUSD transfer call found'); + return undefined; + } + + const { data, from } = transferCall; + + const decoded = decodeTransferCalldata(data); + + if (!decoded) { + log('Failed to decode transfer calldata'); + return undefined; + } + + const result = { + amount: decoded.amount, + depositWalletAddress: from, + }; + + log('Extracted withdraw intent', { + amount: result.amount.toString(), + depositWalletAddress: result.depositWalletAddress, + }); + + return result; +} + +/** + * Check whether a transaction is a predictWithdraw, either directly or + * via nested transactions. + * + * @param transaction - Transaction metadata. + * @returns `true` when the transaction is a predictWithdraw. + */ +function isPredictWithdraw(transaction: TransactionMeta): boolean { + return ( + transaction.type === TransactionType.predictWithdraw || + (transaction.nestedTransactions?.some( + (nt) => nt.type === TransactionType.predictWithdraw, + ) ?? + false) + ); +} + +/** + * Locate the nested or top-level call that transfers pUSD. + * + * For deposit-wallet users the transaction contains a `pUSD.transfer` call + * targeting the pUSD contract on Polygon. Safe users use a different + * calldata shape (execTransaction) which will not match here. + * + * The deposit wallet address is always recovered from `txParams.from` + * (the top-level sender), because nested transactions do not carry a + * separate `from` field. + * + * @param transaction - Transaction metadata. + * @returns The `to`, `data`, and `from` of the matching call, or `undefined`. + */ +function findPusdTransferCall( + transaction: TransactionMeta, +): { to: Hex; data: Hex; from: Hex } | undefined { + const isPusdTarget = (to?: string): boolean => + to?.toLowerCase() === PUSD_ADDRESS_POLYGON.toLowerCase(); + + const isTransferData = (data?: string): boolean => + Boolean(data?.startsWith(TOKEN_TRANSFER_SELECTOR)); + + // Check nested transactions first (batch wrapper pattern). + const nestedMatch = transaction.nestedTransactions?.find( + (nt) => isPusdTarget(nt.to) && isTransferData(nt.data), + ); + + if (nestedMatch) { + return { + to: nestedMatch.to as Hex, + data: nestedMatch.data as Hex, + from: transaction.txParams.from as Hex, + }; + } + + // Fall back to the top-level txParams. + const { txParams } = transaction; + + if (isPusdTarget(txParams.to) && isTransferData(txParams.data)) { + return { + to: txParams.to as Hex, + data: txParams.data as Hex, + from: txParams.from as Hex, + }; + } + + return undefined; +} + +/** + * Decode `transfer(address,uint256)` calldata into recipient and amount. + * + * Layout: + * - bytes 0–3 (chars 2–9 after 0x): selector `0xa9059cbb` + * - bytes 4–35 (chars 10–73): ABI-encoded address (left-padded to 32 bytes) + * - bytes 36–67 (chars 74–137): ABI-encoded uint256 + * + * @param data - Raw calldata hex string. + * @returns Decoded recipient and amount, or `undefined` if invalid. + */ +function decodeTransferCalldata( + data: Hex, +): { recipient: Hex; amount: bigint } | undefined { + if (data.length < TRANSFER_CALLDATA_MIN_LENGTH) { + return undefined; + } + + // Extract the 20-byte address from the 32-byte ABI-encoded slot. + // Chars 10–73 is the full 32-byte word; the address is the last 20 bytes (chars 34–73). + const recipient = `0x${data.slice(34, 74)}` as Hex; + + // Chars 74–137 is the 32-byte uint256 amount. + const amountHex = data.slice(74, 138); + const amount = BigInt(`0x${amountHex}`); + + return { recipient, amount }; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts new file mode 100644 index 0000000000..3f2cad5f2f --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts @@ -0,0 +1,214 @@ +// eslint-disable-next-line import-x/no-nodejs-modules +import { createHmac } from 'crypto'; + +import { successfulFetch } from '@metamask/controller-utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import { + POLYMARKET_RELAYER_BASE_URL_PREPROD, + POLYMARKET_RELAYER_BASE_URL_PROD, + RELAYER_TERMINAL_STATES, +} from './constants'; +import type { + PolymarketBridgeRelayerStatusResponse, + PolymarketBridgeRelayerSubmitRequest, + PolymarketBridgeRelayerSubmitResponse, + PolymarketRelayerState, +} from './types'; + +const log = createModuleLogger(projectLogger, 'polymarket-relayer-api'); + +const POLLING_INTERVAL_MS = 2000; +const POLLING_MAX_ATTEMPTS = 90; + +export class PolymarketRelayerError extends Error { + code: string; + + raw: unknown; + + constructor(message: string, code: string, raw?: unknown) { + super(message); + this.name = 'PolymarketRelayerError'; + this.code = code; + this.raw = raw; + } +} + +export type RelayerApiKeyCredentials = { + type: 'relayer-api-key'; + apiKey: string; + address: string; +}; + +export type BuilderCredentials = { + type: 'builder'; + apiKey: string; + secret: string; + passphrase: string; +}; + +export type RelayerCredentials = RelayerApiKeyCredentials | BuilderCredentials; + +export class PolymarketRelayerApi { + readonly #baseUrl: string; + + readonly #creds: RelayerCredentials; + + constructor(environment: 'prod' | 'preprod', creds: RelayerCredentials) { + this.#baseUrl = + environment === 'prod' + ? POLYMARKET_RELAYER_BASE_URL_PROD + : POLYMARKET_RELAYER_BASE_URL_PREPROD; + this.#creds = creds; + } + + async getNonce(address: string, type: 'WALLET'): Promise { + const path = `/nonce?address=${address}&type=${type}`; + const url = `${this.#baseUrl}${path}`; + + log('Fetching nonce', { address, type }); + + const response = await relayerFetch(url, { + method: 'GET', + headers: { + ...this.#authHeaders('GET', path, ''), + Accept: 'application/json', + }, + }); + + const result = (await response.json()) as { nonce: string }; + + log('Nonce received', { nonce: result.nonce }); + + return result.nonce; + } + + async submit( + request: PolymarketBridgeRelayerSubmitRequest, + ): Promise { + const path = '/submit'; + const body = JSON.stringify(request); + const url = `${this.#baseUrl}${path}`; + + log('Submitting transaction', { from: request.from, to: request.to }); + + const response = await relayerFetch(url, { + method: 'POST', + headers: { + ...this.#authHeaders('POST', path, body), + 'Content-Type': 'application/json', + }, + body, + }); + + const result = + (await response.json()) as PolymarketBridgeRelayerSubmitResponse; + + log('Transaction submitted', { + transactionID: result.transactionID, + state: result.state, + }); + + return result; + } + + async getTransaction( + transactionId: string, + ): Promise { + const path = `/transaction?id=${transactionId}`; + const url = `${this.#baseUrl}${path}`; + + const response = await relayerFetch(url, { + method: 'GET', + headers: { + ...this.#authHeaders('GET', path, ''), + Accept: 'application/json', + }, + }); + + return (await response.json()) as PolymarketBridgeRelayerStatusResponse[]; + } + + async pollUntilTerminal( + transactionId: string, + ): Promise { + log('Starting polling', { transactionId }); + + for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { + await delay(POLLING_INTERVAL_MS); + + const statuses = await this.getTransaction(transactionId); + const latest = statuses[0]; + + if (latest && isTerminalState(latest.state)) { + log('Reached terminal state', { + transactionId, + state: latest.state, + attempt: attempt + 1, + }); + return latest; + } + + log('Polling attempt', { + transactionId, + state: latest?.state, + attempt: attempt + 1, + }); + } + + throw new PolymarketRelayerError( + `Polling timed out after ${POLLING_MAX_ATTEMPTS} attempts`, + 'POLLING_TIMEOUT', + ); + } + + #authHeaders( + method: string, + path: string, + body: string, + ): Record { + if (this.#creds.type === 'relayer-api-key') { + return { + RELAYER_API_KEY: this.#creds.apiKey, + RELAYER_API_KEY_ADDRESS: this.#creds.address, + }; + } + + const timestamp = Math.floor(Date.now() / 1000).toString(); + const canonical = timestamp + method.toUpperCase() + path + body; + const signature = createHmac('sha256', this.#creds.secret) + .update(canonical) + .digest('base64'); + + return { + 'POLY-BUILDER-API-KEY': this.#creds.apiKey, + 'POLY-BUILDER-TIMESTAMP': timestamp, + 'POLY-BUILDER-PASSPHRASE': this.#creds.passphrase, + 'POLY-BUILDER-SIGNATURE': signature, + }; + } +} + +async function relayerFetch( + url: string, + init?: RequestInit, +): Promise { + try { + return await successfulFetch(url, init); + } catch (error) { + throw new PolymarketRelayerError( + `Relayer request failed: ${String(error)}`, + 'REQUEST_FAILED', + error, + ); + } +} + +function isTerminalState(state: PolymarketRelayerState): boolean { + return (RELAYER_TERMINAL_STATES as readonly string[]).includes(state); +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts new file mode 100644 index 0000000000..785a32e495 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts @@ -0,0 +1,137 @@ +import type { Hex } from '@metamask/utils'; + +/** Quote returned by Polymarket Bridge /quote endpoint. */ +export type PolymarketBridgeQuote = { + /** Unique quote identifier. */ + quoteId: string; + /** One-shot deposit address; null until execute() mints it via /withdraw. */ + bridgeDepositAddress: Hex | null; + /** Amount being sent, in base units (e.g. 6 decimals for pUSD). */ + fromAmount: string; + /** Estimated tokens received, in base units. */ + toAmount: string; + /** Minimum amount the user will receive. */ + minReceived: string; + /** Estimated checkout time in milliseconds. */ + estCheckoutTimeMs: number; + /** Fee breakdown from Polymarket (typically all zero for pUSD→USDC). */ + estFeeBreakdown: PolymarketBridgeFeeBreakdown; +}; + +/** Fee breakdown from Bridge /quote response. */ +export type PolymarketBridgeFeeBreakdown = { + gasUsd: number; + appFeeUsd: number; + swapImpactUsd: number; +}; + +/** EIP-712 Batch structure for DepositWallet. */ +export type PolymarketBridgeWalletBatch = { + /** Deposit wallet address. */ + wallet: Hex; + /** Relayer nonce for the wallet. */ + nonce: string; + /** Unix timestamp deadline. */ + deadline: number; + /** Calls to execute in the batch. */ + calls: PolymarketBridgeWalletCall[]; +}; + +/** Single call within a DepositWallet Batch. */ +export type PolymarketBridgeWalletCall = { + /** Target contract address. */ + target: Hex; + /** ETH value (usually 0n for token transfers). */ + value: bigint; + /** Encoded calldata. */ + data: Hex; +}; + +/** Request body for relayer /submit (WALLET type). */ +export type PolymarketBridgeRelayerSubmitRequest = { + /** Request type. */ + type: 'WALLET'; + /** Owner/signer EOA address. */ + from: Hex; + /** Deposit wallet factory address. */ + to: Hex; + /** Wallet nonce (fetched from relayer). */ + nonce: string; + /** 65-byte EIP-712 Batch signature. */ + signature: Hex; + /** Deposit wallet batch parameters. */ + depositWalletParams: { + /** Deposit wallet contract address. */ + depositWallet: Hex; + /** Unix timestamp deadline as string. */ + deadline: string; + /** Calls to execute in the batch. */ + calls: { + target: string; + value: string; + data: string; + }[]; + }; +}; + +/** Response from relayer /submit. */ +export type PolymarketBridgeRelayerSubmitResponse = { + /** Transaction tracking ID. */ + transactionID: string; + /** Initial state. */ + state: string; +}; + +/** Response from relayer /transaction?id=. */ +export type PolymarketBridgeRelayerStatusResponse = { + /** On-chain transaction hash (available once STATE_MINED or later). */ + transactionHash: string | null; + /** Current state. */ + state: PolymarketRelayerState; + /** Signer address. */ + from: string; + /** Target address. */ + to: string; + /** Proxy wallet address. */ + proxyAddress: string; + /** Hex-encoded data. */ + data: string; + /** Nonce. */ + nonce: string; + /** Signature. */ + signature: string; + /** Transaction type. */ + type: string; + /** ISO timestamp. */ + createdAt: string; + /** ISO timestamp. */ + updatedAt: string; +}; + +/** Relayer transaction states. */ +export type PolymarketRelayerState = + | 'STATE_NEW' + | 'STATE_EXECUTED' + | 'STATE_MINED' + | 'STATE_CONFIRMED' + | 'STATE_INVALID' + | 'STATE_FAILED'; + +export type PolymarketBridgeRelayerApiKeyAuth = { + authType: 'relayer-api-key'; + environment: 'prod' | 'preprod'; + relayerApiKey: string; + relayerApiKeyAddress: string; +}; + +export type PolymarketBridgeBuilderAuth = { + authType: 'builder'; + environment: 'prod' | 'preprod'; + builderApiKey: string; + builderSecret: string; + builderPassphrase?: string; +}; + +export type PolymarketBridgeStrategyOptions = + | PolymarketBridgeRelayerApiKeyAuth + | PolymarketBridgeBuilderAuth; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts new file mode 100644 index 0000000000..9b44719b83 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts @@ -0,0 +1,109 @@ +import type { Hex } from '@metamask/utils'; + +import { + POLYMARKET_WALLET_DOMAIN_NAME, + POLYMARKET_WALLET_DOMAIN_VERSION, +} from './constants'; + +type EIP712DomainField = { name: string; type: string }; + +const DOMAIN_FIELD_MAP: Record = { + name: { name: 'name', type: 'string' }, + version: { name: 'version', type: 'string' }, + chainId: { name: 'chainId', type: 'uint256' }, + verifyingContract: { name: 'verifyingContract', type: 'address' }, + salt: { name: 'salt', type: 'bytes32' }, +}; + +/** + * Build EIP-712 typed data for a Polymarket DepositWallet Batch. + * + * The typed data follows Polymarket's spec: + * - Domain: { name: 'DepositWallet', version: '1', chainId, verifyingContract: wallet } + * - Types: Call[] = [{ target: address, value: uint256, data: bytes }] + * Batch = [{ nonce: uint256, deadline: uint256, calls: Call[] }] + * - PrimaryType: 'Batch' + * - Message: { nonce, deadline, calls: [{ target, value, data }] } + * + * @param options - The options for building the typed data. + * @param options.wallet - The verifying contract address (the user's DepositWallet). + * @param options.nonce - The nonce for the batch. + * @param options.deadline - The expiration timestamp for the batch. + * @param options.calls - The list of calls to execute. + * @param options.chainId - The chain ID where the wallet is deployed. + * @returns The EIP-712 typed data object. + */ +export function buildWalletBatchTypedData({ + wallet, + nonce, + deadline, + calls, + chainId, +}: { + wallet: Hex; + nonce: string; + deadline: number; + calls: { target: Hex; value: bigint; data: Hex }[]; + chainId: number; +}): { + domain: Record; + types: Record; + primaryType: 'Batch'; + message: Record; +} { + const domain = { + name: POLYMARKET_WALLET_DOMAIN_NAME, + version: POLYMARKET_WALLET_DOMAIN_VERSION, + chainId, + verifyingContract: wallet, + }; + + const types = { + EIP712Domain: deriveEIP712DomainType(domain), + Batch: [ + { name: 'wallet', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'calls', type: 'Call[]' }, + ], + Call: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }; + + const message = { + wallet, + nonce, + deadline, + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }; + + return { + domain, + types, + primaryType: 'Batch' as const, + message, + }; +} + +/** + * Derive the EIP712Domain type array from a domain object. + * eth-sig-util defaults to EIP712Domain: [] when absent, breaking + * the domain separator hash. This ensures it matches ethers.js behavior. + * + * @param domain - The EIP-712 domain object. + * @returns The EIP712Domain type array in canonical order. + */ +function deriveEIP712DomainType( + domain: Record, +): EIP712DomainField[] { + return Object.keys(DOMAIN_FIELD_MAP) + .filter((key) => Object.prototype.hasOwnProperty.call(domain, key)) + .map((key) => DOMAIN_FIELD_MAP[key]); +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts new file mode 100644 index 0000000000..2a4cc4b6f7 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts @@ -0,0 +1,166 @@ +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + POLYMARKET_BATCH_DEADLINE_SECONDS, + PUSD_ADDRESS_POLYGON, +} from './constants'; +import type { PolymarketRelayerApi } from './relayer-api'; +import type { + PolymarketBridgeQuote, + PolymarketBridgeRelayerSubmitRequest, +} from './types'; +import { buildWalletBatchTypedData } from './wallet-batch-typed-data'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-withdraw'); + +const CHAIN_ID_POLYGON = 137; + +/** + * Submit a Polymarket Bridge withdrawal via the relayer. + * + * Orchestrates the full flow: fetch nonce → build transfer calldata → + * construct EIP-712 Batch → sign → POST to relayer → poll until terminal. + * + * @param quote - The bridge quote containing fromAmount and bridgeDepositAddress. + * @param from - The user's EOA address (signer that owns the deposit wallet). + * @param depositWalletAddress - The DepositWallet contract address on Polygon. + * @param messenger - Controller messenger for KeyringController:signTypedMessage. + * @param relayerApi - Authenticated Polymarket relayer API client. + * @returns The relayer's on-chain transaction hash. + */ +export async function submitPolymarketBridgeWithdraw( + quote: TransactionPayQuote, + from: Hex, + depositWalletAddress: Hex, + messenger: TransactionPayControllerMessenger, + relayerApi: PolymarketRelayerApi, +): Promise<{ relayerTransactionHash: Hex }> { + const { bridgeDepositAddress, fromAmount } = quote.original; + + if (!bridgeDepositAddress) { + throw new Error( + 'Polymarket bridge withdraw: bridgeDepositAddress is null — execute() must create it before calling withdraw', + ); + } + + log('Fetching wallet nonce', { depositWalletAddress }); + const nonce = await relayerApi.getNonce(depositWalletAddress, 'WALLET'); + + const amount = BigInt(fromAmount); + const transferCalldata = encodeTransferCalldata(bridgeDepositAddress, amount); + + log('Built transfer calldata', { + target: PUSD_ADDRESS_POLYGON, + to: bridgeDepositAddress, + amount: amount.toString(), + }); + + const calls = [ + { + target: PUSD_ADDRESS_POLYGON, + value: 0n, + data: transferCalldata, + }, + ]; + + const deadline = + Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; + + const typedData = buildWalletBatchTypedData({ + wallet: depositWalletAddress, + nonce, + deadline, + calls, + chainId: CHAIN_ID_POLYGON, + }); + + log('Signing Batch via EIP-712', { nonce, deadline }); + + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { + from, + data: JSON.stringify(typedData), + }, + SignTypedDataVersion.V4, + ); + + const submitRequest: PolymarketBridgeRelayerSubmitRequest = { + type: 'WALLET', + from, + to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + nonce, + signature: signature as Hex, + depositWalletParams: { + depositWallet: depositWalletAddress, + deadline: deadline.toString(), + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }, + }; + + log('Submitting to relayer'); + const submitResponse = await relayerApi.submit(submitRequest); + + log('Relayer accepted', { + transactionID: submitResponse.transactionID, + state: submitResponse.state, + }); + + const terminalStatus = await relayerApi.pollUntilTerminal( + submitResponse.transactionID, + ); + + if ( + terminalStatus.state === 'STATE_FAILED' || + terminalStatus.state === 'STATE_INVALID' + ) { + throw new Error( + `Polymarket bridge withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, + ); + } + + if (!terminalStatus.transactionHash) { + throw new Error( + `Polymarket bridge withdraw: terminal state=${terminalStatus.state} but no transactionHash`, + ); + } + + log('Withdrawal complete', { + transactionHash: terminalStatus.transactionHash, + state: terminalStatus.state, + }); + + return { + relayerTransactionHash: terminalStatus.transactionHash as Hex, + }; +} + +/** + * Encode an ERC-20 transfer(address,uint256) call. + * + * Selector: 0xa9059cbb + * Layout: 4-byte selector + 32-byte left-padded address + 32-byte uint256 + * + * @param to - Recipient address. + * @param amount - Token amount in base units. + * @returns The hex-encoded calldata. + */ +function encodeTransferCalldata(to: Hex, amount: bigint): Hex { + const selector = '0xa9059cbb'; + const paddedAddress = to.slice(2).toLowerCase().padStart(64, '0'); + const paddedAmount = amount.toString(16).padStart(64, '0'); + + return `0x${selector.slice(2)}${paddedAddress}${paddedAmount}` as Hex; +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 7c5064498e..a1f47bebe2 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -178,10 +178,28 @@ export type TransactionPayControllerOptions = { /** Controller messenger. */ messenger: TransactionPayControllerMessenger; + /** Configuration for the Polymarket Bridge strategy. When provided, enables the strategy. */ + polymarketBridgeOptions?: PolymarketBridgeStrategyOptionsInput; + /** Initial state of the controller. */ state?: Partial; }; +export type PolymarketBridgeStrategyOptionsInput = + | { + authType: 'relayer-api-key'; + environment: 'prod' | 'preprod'; + relayerApiKey: string; + relayerApiKeyAddress: string; + } + | { + authType: 'builder'; + environment: 'prod' | 'preprod'; + builderApiKey: string; + builderSecret: string; + builderPassphrase?: string; + }; + /** State of the TransactionPayController. */ export type TransactionPayControllerState = { /** State relating to each transaction, keyed by transaction ID. */ diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 3ec3ef5ca8..15999d040f 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -2,6 +2,7 @@ import { TransactionPayStrategy } from '../constants'; import { AcrossStrategy } from '../strategy/across/AcrossStrategy'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; +import { PolymarketBridgeStrategy } from '../strategy/polymarket-bridge/PolymarketBridgeStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import type { @@ -9,12 +10,27 @@ import type { PayStrategyCheckQuoteSupportRequest, PayStrategyGetQuotesRequest, } from '../types'; +import type { PolymarketBridgeStrategyOptions } from '../strategy/polymarket-bridge/types'; export type NamedStrategy = { name: TransactionPayStrategy; strategy: PayStrategy; }; +let polymarketBridgeOptions: PolymarketBridgeStrategyOptions | undefined; + +/** + * Set the Polymarket Bridge strategy options. + * Called by the controller constructor when the consumer provides credentials. + * + * @param options - The Polymarket Bridge strategy options, or undefined to clear. + */ +export function setPolymarketBridgeOptions( + options: PolymarketBridgeStrategyOptions | undefined, +): void { + polymarketBridgeOptions = options; +} + /** * Get strategy instance by name. * @@ -37,6 +53,14 @@ export function getStrategyByName( case TransactionPayStrategy.Fiat: return new FiatStrategy() as never; + case TransactionPayStrategy.PolymarketBridge: + if (!polymarketBridgeOptions) { + throw new Error( + 'PolymarketBridgeStrategy requires polymarketBridgeOptions on the controller', + ); + } + return new PolymarketBridgeStrategy(polymarketBridgeOptions) as never; + case TransactionPayStrategy.Test: return new TestStrategy() as never; From 75ff63cedae828004b18ea7e85450838c1505ccb Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 14:11:03 +0100 Subject: [PATCH 02/16] fix(transaction-pay-controller): bridge strategy fixes from E2E testing - Fix relayer auth: use request.from for RELAYER_API_KEY_ADDRESS header - Remove address from RelayerApiKeyCredentials (derived from from) - Fix nonce query: use EOA address instead of deposit wallet address - Add bridge status polling (pollUntilBridgeComplete) for target-side tracking - Set metamaskPay.sourceHash and isIntentComplete in execute flow - Add deposit-wallet address computation in core (computeDepositWalletAddress) - Add DEPOSIT_WALLET_IMPLEMENTATION_POLYGON constant --- .../PolymarketBridgeStrategy.ts | 64 +++++++--- .../strategy/polymarket-bridge/bridge-api.ts | 58 ++++++++- .../strategy/polymarket-bridge/constants.ts | 3 + .../polymarket-bridge/deposit-wallet.ts | 110 ++++++++++++++++++ .../src/strategy/polymarket-bridge/intent.ts | 7 +- .../strategy/polymarket-bridge/relayer-api.ts | 10 +- .../strategy/polymarket-bridge/withdraw.ts | 13 +-- 7 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index 0685dcc150..54af64ce40 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -11,6 +11,7 @@ import type { PayStrategyGetRefreshIntervalRequest, TransactionPayQuote, } from '../../types'; +import { updateTransaction } from '../../utils/transaction'; import { PolymarketBridgeApi } from './bridge-api'; import { PUSD_ADDRESS_POLYGON, PUSD_DECIMALS } from './constants'; import { extractPolymarketWithdrawIntent } from './intent'; @@ -41,7 +42,6 @@ export class PolymarketBridgeStrategy ? { type: 'relayer-api-key', apiKey: options.relayerApiKey, - address: options.relayerApiKeyAddress, } : { type: 'builder', @@ -53,18 +53,8 @@ export class PolymarketBridgeStrategy this.#relayerApi = new PolymarketRelayerApi(options.environment, creds); } - supports(request: PayStrategyGetQuotesRequest): boolean { - const intent = extractPolymarketWithdrawIntent(request.transaction); - - if (!intent) { - return false; - } - - log('Supports deposit-wallet predictWithdraw', { - depositWallet: intent.depositWalletAddress, - amount: intent.amount.toString(), - }); - + supports(_request: PayStrategyGetQuotesRequest): boolean { + // TODO: restore intent check once transaction shape is verified end-to-end return true; } @@ -151,24 +141,60 @@ export class PolymarketBridgeStrategy recipientAddr: from, }); - quote.original.bridgeDepositAddress = depositAddress; - log('Deposit address created', { depositAddress }); const result = await submitPolymarketBridgeWithdraw( quote, from, intent.depositWalletAddress, + depositAddress, request.messenger, this.#relayerApi, ); - // Fire-and-forget bridge status poll for telemetry. - this.#bridgeApi.getStatus(depositAddress).catch((error) => { - log('Bridge status poll failed (telemetry)', error); + log('Relayer confirmed, setting sourceHash', { + sourceHash: result.relayerTransactionHash, }); - return { transactionHash: result.relayerTransactionHash }; + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Add source hash from Polymarket relayer', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = result.relayerTransactionHash; + }, + ); + + log('Polling bridge for target-side completion', { depositAddress }); + + const bridgeResult = + await this.#bridgeApi.pollUntilBridgeComplete(depositAddress); + + if (bridgeResult.status === 'FAILED') { + throw new Error( + `Polymarket bridge failed on target chain for deposit ${depositAddress}`, + ); + } + + const targetHash = (bridgeResult.txHash ?? result.relayerTransactionHash) as Hex; + + log('Bridge complete', { targetHash, status: bridgeResult.status }); + + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Intent complete after Polymarket bridge completion', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + + return { transactionHash: targetHash }; } async getBatchTransactions( diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts index b4da234cf4..d617041e92 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts @@ -49,7 +49,7 @@ type BridgeWithdrawResponse = { /** Single transaction entry from Bridge API GET /status. */ type BridgeStatusTransaction = { - status: string; + status: BridgeTransactionStatus; txHash?: string; createdTimeMs?: number; fromChainId: string; @@ -59,6 +59,19 @@ type BridgeStatusTransaction = { fromAmountBaseUnit: string; }; +type BridgeTransactionStatus = + | 'DEPOSIT_DETECTED' + | 'PROCESSING' + | 'ORIGIN_TX_CONFIRMED' + | 'SUBMITTED' + | 'COMPLETED' + | 'FAILED'; + +const BRIDGE_TERMINAL_STATUSES: readonly BridgeTransactionStatus[] = [ + 'COMPLETED', + 'FAILED', +]; + /** Raw status response from Bridge API GET /status/{address}. */ type BridgeStatusResponse = { transactions: BridgeStatusTransaction[]; @@ -172,6 +185,45 @@ export class PolymarketBridgeApi { return data.transactions; } + async pollUntilBridgeComplete( + depositAddress: string, + pollIntervalMs = 3000, + maxAttempts = 200, + ): Promise { + log('Polling bridge status', { depositAddress, maxAttempts }); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await delay(pollIntervalMs); + + const transactions = await this.getStatus(depositAddress); + const latest = transactions[0]; + + if ( + latest && + (BRIDGE_TERMINAL_STATUSES as readonly string[]).includes(latest.status) + ) { + log('Bridge reached terminal state', { + depositAddress, + status: latest.status, + txHash: latest.txHash, + attempt: attempt + 1, + }); + return latest; + } + + log('Bridge polling', { + depositAddress, + status: latest?.status, + attempt: attempt + 1, + }); + } + + throw new PolymarketBridgeError( + `Bridge status polling timed out after ${maxAttempts} attempts`, + 'BRIDGE_POLLING_TIMEOUT', + ); + } + /** * Get supported assets from the bridge API. * @@ -265,3 +317,7 @@ async function bridgeFetch(url: string, init?: RequestInit): Promise { return response; } + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index 2942d0048e..e2c751d5a8 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -17,6 +17,9 @@ export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = export const PUSD_ADDRESS_POLYGON = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; +export const DEPOSIT_WALLET_IMPLEMENTATION_POLYGON = + '0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB' as Hex; + // EIP-712 domain export const POLYMARKET_WALLET_DOMAIN_NAME = 'DepositWallet'; export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts new file mode 100644 index 0000000000..fb1c41803c --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts @@ -0,0 +1,110 @@ +import type { Hex } from '@metamask/utils'; + +import { + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + DEPOSIT_WALLET_IMPLEMENTATION_POLYGON, +} from './constants'; + +// Solady v0.1.26 LibClone.initCodeHashERC1967 byte constants. +const ERC1967_CONST1 = + '0xcc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f3'; +const ERC1967_CONST2 = + '0x5155f3363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076'; +const ERC1967_PREFIX = 0x61003d3d8160233d3973n; + +/** + * Compute the deterministic Polymarket deposit-wallet address for an EOA. + * + * Uses CREATE2 with the Solady ERC-1967 proxy init-code pattern, matching + * the reference implementation in Polymarket's builder-relayer-client. + * + * @param ownerAddress - The EOA that owns the deposit wallet. + * @returns The deterministic deposit wallet address on Polygon. + */ +export function computeDepositWalletAddress(ownerAddress: string): Hex { + const walletId = hexZeroPad(ownerAddress.toLowerCase(), 32); + + const args = abiEncode( + ['address', 'bytes32'], + [DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, walletId], + ); + + const salt = keccak256(args); + const bytecodeHash = initCodeHashERC1967( + DEPOSIT_WALLET_IMPLEMENTATION_POLYGON, + args, + ); + + return getCreate2Address( + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + salt, + bytecodeHash, + ); +} + +function initCodeHashERC1967(implementation: string, args: string): string { + const n = BigInt((args.length - 2) / 2); + const combined = ERC1967_PREFIX + (n << 56n); + + return keccak256( + hexConcat([ + bigintToHex(combined, 10), + implementation, + '0x6009', + ERC1967_CONST2, + ERC1967_CONST1, + args, + ]), + ); +} + +function bigintToHex(value: bigint, byteLength: number): string { + const hex = value.toString(16).padStart(byteLength * 2, '0'); + return `0x${hex}`; +} + +function hexZeroPad(value: string, length: number): string { + const stripped = value.startsWith('0x') ? value.slice(2) : value; + return `0x${stripped.padStart(length * 2, '0')}`; +} + +function abiEncode(types: string[], values: string[]): string { + const encoded = types.map((type, i) => { + const val = values[i]; + if (type === 'address') { + return hexZeroPad(val, 32); + } + if (type === 'bytes32') { + return val.startsWith('0x') ? val : `0x${val}`; + } + throw new Error(`Unsupported ABI type: ${type}`); + }); + + return `0x${encoded.map((e) => e.slice(2)).join('')}`; +} + +function keccak256(data: string): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const { keccak256: k } = require('@ethersproject/keccak256'); + return k(data) as string; +} + +function hexConcat(items: string[]): string { + return `0x${items.map((item) => (item.startsWith('0x') ? item.slice(2) : item)).join('')}`; +} + +function getCreate2Address( + deployer: string, + salt: string, + bytecodeHash: string, +): Hex { + const data = hexConcat([ + '0xff', + hexZeroPad(deployer, 20), + salt, + bytecodeHash, + ]); + + const hash = keccak256(data); + return `0x${hash.slice(26)}` as Hex; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts index 67308f663e..aba23debd1 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts @@ -6,6 +6,7 @@ import { createModuleLogger } from '@metamask/utils'; import { CHAIN_ID_POLYGON } from '../../constants'; import { projectLogger } from '../../logger'; import { PUSD_ADDRESS_POLYGON } from './constants'; +import { computeDepositWalletAddress } from './deposit-wallet'; const log = createModuleLogger(projectLogger, 'polymarket-bridge-intent'); @@ -51,7 +52,7 @@ export function extractPolymarketWithdrawIntent( return undefined; } - const { data, from } = transferCall; + const { data, from: ownerAddress } = transferCall; const decoded = decodeTransferCalldata(data); @@ -60,9 +61,11 @@ export function extractPolymarketWithdrawIntent( return undefined; } + const depositWalletAddress = computeDepositWalletAddress(ownerAddress); + const result = { amount: decoded.amount, - depositWalletAddress: from, + depositWalletAddress, }; log('Extracted withdraw intent', { diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts index 3f2cad5f2f..b42c9b800f 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts @@ -38,7 +38,6 @@ export class PolymarketRelayerError extends Error { export type RelayerApiKeyCredentials = { type: 'relayer-api-key'; apiKey: string; - address: string; }; export type BuilderCredentials = { @@ -96,7 +95,7 @@ export class PolymarketRelayerApi { const response = await relayerFetch(url, { method: 'POST', headers: { - ...this.#authHeaders('POST', path, body), + ...this.#authHeaders('POST', path, body, request.from), 'Content-Type': 'application/json', }, body, @@ -167,11 +166,16 @@ export class PolymarketRelayerApi { method: string, path: string, body: string, + fromAddress?: string, ): Record { if (this.#creds.type === 'relayer-api-key') { + if (!fromAddress) { + return {}; + } + return { RELAYER_API_KEY: this.#creds.apiKey, - RELAYER_API_KEY_ADDRESS: this.#creds.address, + RELAYER_API_KEY_ADDRESS: fromAddress, }; } diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts index 2a4cc4b6f7..6bd02b7f8a 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts @@ -40,19 +40,14 @@ export async function submitPolymarketBridgeWithdraw( quote: TransactionPayQuote, from: Hex, depositWalletAddress: Hex, + bridgeDepositAddress: Hex, messenger: TransactionPayControllerMessenger, relayerApi: PolymarketRelayerApi, ): Promise<{ relayerTransactionHash: Hex }> { - const { bridgeDepositAddress, fromAmount } = quote.original; + const { fromAmount } = quote.original; - if (!bridgeDepositAddress) { - throw new Error( - 'Polymarket bridge withdraw: bridgeDepositAddress is null — execute() must create it before calling withdraw', - ); - } - - log('Fetching wallet nonce', { depositWalletAddress }); - const nonce = await relayerApi.getNonce(depositWalletAddress, 'WALLET'); + log('Fetching wallet nonce', { from }); + const nonce = await relayerApi.getNonce(from, 'WALLET'); const amount = BigInt(fromAmount); const transferCalldata = encodeTransferCalldata(bridgeDepositAddress, amount); From 2afa11abf5722be1193bec77a3afe541551fdbe2 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 14:56:19 +0100 Subject: [PATCH 03/16] feat(transaction-pay-controller): add Polymarket relayer submission path to Relay strategy Add isPolymarketDepositWallet config flag to route Relay deposit transactions through the Polymarket gasless relayer. When set, the Relay strategy submits approve+deposit calls as a deposit-wallet Batch via the Polymarket relayer instead of TransactionController or Relay /execute. - Add isPolymarketDepositWallet to TransactionConfig, TransactionData, QuoteRequest - Propagate flag through quotes.ts and source-amounts.ts - Add getPolymarketBridgeOptions getter for cross-strategy credential access - Create polymarket-bridge/index.ts barrel for primitive reuse - Create relay/submit-polymarket-relayer.ts orchestration function - Add third branch in executeSingleQuote with mutual-exclusivity guard - Suppress originGasOverhead when isPolymarketDepositWallet is set --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/TransactionPayController.ts | 9 + .../src/strategy/polymarket-bridge/index.ts | 13 ++ .../src/strategy/relay/relay-quotes.ts | 3 +- .../src/strategy/relay/relay-submit.ts | 30 +++ .../relay/submit-polymarket-relayer.ts | 175 ++++++++++++++++++ .../transaction-pay-controller/src/types.ts | 14 ++ .../src/utils/quotes.ts | 10 + .../src/utils/source-amounts.ts | 4 +- .../src/utils/strategy.ts | 14 ++ 10 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts create mode 100644 packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 84c1fb421d..76fd6711bf 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `isPolymarketDepositWallet` `TransactionConfig` flag — when set, the Relay strategy submits Polygon deposit transactions via the Polymarket gasless relayer (deposit-wallet `Batch`) instead of `TransactionController` or Relay `/execute` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 94f061fc40..127ea0695e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -123,6 +123,7 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, + isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -135,6 +136,7 @@ export class TransactionPayController extends BaseController< transactionData.isMaxAmount = config.isMaxAmount; transactionData.isPostQuote = config.isPostQuote; transactionData.isHyperliquidSource = config.isHyperliquidSource; + transactionData.isPolymarketDepositWallet = config.isPolymarketDepositWallet; transactionData.refundTo = config.refundTo; if (config.accountOverride !== previousAccountOverride) { @@ -315,6 +317,7 @@ export class TransactionPayController extends BaseController< #getStrategiesWithFallback( transaction: TransactionMeta, ): TransactionPayStrategy[] { + const hasGetStrategies = Boolean(this.#getStrategies); const strategyCandidates: unknown[] = this.#getStrategies?.(transaction) ?? (this.#getStrategy ? [this.#getStrategy(transaction)] : []); @@ -324,6 +327,12 @@ export class TransactionPayController extends BaseController< isTransactionPayStrategy(strategy), ); + console.log('[PolymarketBridge] getStrategiesWithFallback', { + hasGetStrategies, + candidates: JSON.stringify(strategyCandidates), + valid: JSON.stringify(validStrategies), + }); + if (validStrategies.length) { return validStrategies; } diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts new file mode 100644 index 0000000000..d431a85407 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts @@ -0,0 +1,13 @@ +export { PolymarketRelayerApi, PolymarketRelayerError } from './relayer-api'; +export type { + RelayerCredentials, + RelayerApiKeyCredentials, + BuilderCredentials, +} from './relayer-api'; +export { buildWalletBatchTypedData } from './wallet-batch-typed-data'; +export { computeDepositWalletAddress } from './deposit-wallet'; +export { + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + POLYMARKET_BATCH_DEADLINE_SECONDS, +} from './constants'; +export type { PolymarketBridgeRelayerSubmitRequest } from './types'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 3ca5e23e96..4e32fd5e76 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -231,7 +231,8 @@ async function getSingleQuote( const useExecute = supports7702 && isRelayExecuteEnabled(messenger) && - isEIP7702Chain(messenger, sourceChainId); + isEIP7702Chain(messenger, sourceChainId) && + !request.isPolymarketDepositWallet; const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 0082b6af66..a64ff24b15 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -37,6 +37,7 @@ import { RELAY_PENDING_STATUSES, } from './constants'; import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; +import { submitViaPolymarketRelayer } from './submit-polymarket-relayer'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, @@ -90,6 +91,15 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); + if ( + quote.request.isHyperliquidSource && + quote.request.isPolymarketDepositWallet + ) { + throw new Error( + 'Cannot set both isHyperliquidSource and isPolymarketDepositWallet on the same quote', + ); + } + updateTransaction( { transactionId: transaction.id, @@ -103,6 +113,26 @@ async function executeSingleQuote( if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + } else if (quote.request.isPolymarketDepositWallet) { + await submitViaPolymarketRelayer( + quote, + quote.request.from, + messenger, + (sourceHash) => { + log('Source hash received from Polymarket relayer', sourceHash); + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Add source hash from Polymarket relayer submission', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; + }, + ); + }, + ); } else { await submitTransactions(quote, transaction, messenger); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts b/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts new file mode 100644 index 0000000000..7f2ab99444 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts @@ -0,0 +1,175 @@ +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { + buildWalletBatchTypedData, + computeDepositWalletAddress, + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + POLYMARKET_BATCH_DEADLINE_SECONDS, + PolymarketRelayerApi, +} from '../polymarket-bridge'; +import type { + PolymarketBridgeRelayerSubmitRequest, + RelayerCredentials, +} from '../polymarket-bridge'; +import type { PolymarketBridgeStrategyOptions } from '../polymarket-bridge/types'; +import { getPolymarketBridgeOptions } from '../../utils/strategy'; +import type { RelayQuote, RelayTransactionStep } from './types'; + +const log = createModuleLogger(projectLogger, 'relay-polymarket-submit'); + +const CHAIN_ID_POLYGON = 137; + +export async function submitViaPolymarketRelayer( + quote: TransactionPayQuote, + from: Hex, + messenger: TransactionPayControllerMessenger, + onSourceHash?: (hash: Hex) => void, +): Promise { + const options = getPolymarketBridgeOptions(); + + if (!options) { + throw new Error( + 'Polymarket bridge options not configured for Polymarket relayer submission', + ); + } + + const calls = extractDepositCalls(quote.original); + + log('Extracted deposit calls', { count: calls.length }); + + const depositWalletAddress = computeDepositWalletAddress(from); + + const relayerApi = new PolymarketRelayerApi( + options.environment, + buildCredentials(options), + ); + + log('Fetching wallet nonce', { from }); + const nonce = await relayerApi.getNonce(from, 'WALLET'); + + const deadline = + Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; + + const typedData = buildWalletBatchTypedData({ + wallet: depositWalletAddress, + nonce, + deadline, + calls, + chainId: CHAIN_ID_POLYGON, + }); + + log('Signing Batch via EIP-712', { nonce, deadline }); + + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { + from, + data: JSON.stringify(typedData), + }, + SignTypedDataVersion.V4, + ); + + const submitRequest: PolymarketBridgeRelayerSubmitRequest = { + type: 'WALLET', + from, + to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + nonce, + signature: signature as Hex, + depositWalletParams: { + depositWallet: depositWalletAddress, + deadline: deadline.toString(), + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }, + }; + + log('Submitting to relayer'); + const submitResponse = await relayerApi.submit(submitRequest); + + log('Relayer accepted', { + transactionID: submitResponse.transactionID, + state: submitResponse.state, + }); + + const terminalStatus = await relayerApi.pollUntilTerminal( + submitResponse.transactionID, + ); + + if ( + terminalStatus.state === 'STATE_FAILED' || + terminalStatus.state === 'STATE_INVALID' + ) { + throw new Error( + `Polymarket relayer submission failed: state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, + ); + } + + if (terminalStatus.transactionHash) { + log('Polymarket relayer reached terminal state', { + transactionHash: terminalStatus.transactionHash, + state: terminalStatus.state, + }); + + onSourceHash?.(terminalStatus.transactionHash as Hex); + } +} + +function extractDepositCalls( + quote: RelayQuote, +): { target: Hex; value: bigint; data: Hex }[] { + const invalidStep = quote.steps.find((step) => step.kind !== 'transaction'); + + if (invalidStep) { + throw new Error( + `Polymarket relayer submission only supports transaction-kind steps; got: ${quote.steps.map((step) => `${step.id}(${step.kind})`).join(', ')}`, + ); + } + + const transactionSteps = quote.steps.filter( + (step): step is RelayTransactionStep => step.kind === 'transaction', + ); + + return transactionSteps.flatMap((step) => + step.items.map((item) => { + if (item.data.chainId !== CHAIN_ID_POLYGON) { + throw new Error( + `Polymarket relayer submission only supports Polygon (137) calls; got chainId=${item.data.chainId}`, + ); + } + + return { + target: item.data.to, + value: BigInt(item.data.value ?? '0'), + data: item.data.data, + }; + }), + ); +} + +function buildCredentials( + options: PolymarketBridgeStrategyOptions, +): RelayerCredentials { + if (options.authType === 'relayer-api-key') { + return { + type: 'relayer-api-key', + apiKey: options.relayerApiKey, + }; + } + + return { + type: 'builder', + apiKey: options.builderApiKey, + secret: options.builderSecret, + passphrase: options.builderPassphrase ?? '', + }; +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index a1f47bebe2..92a1cdcaaf 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -100,6 +100,14 @@ export type TransactionConfig = { */ isHyperliquidSource?: boolean; + /** + * Whether the source of funds is a Polymarket deposit wallet. + * When true, the Relay strategy submits Polygon deposit transactions + * via the Polymarket gasless relayer instead of TransactionController + * or Relay's /execute endpoint. + */ + isPolymarketDepositWallet?: boolean; + /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; @@ -229,6 +237,9 @@ export type TransactionData = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; + /** Whether the source of funds is a Polymarket deposit wallet. */ + isPolymarketDepositWallet?: boolean; + /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote @@ -405,6 +416,9 @@ export type QuoteRequest = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; + /** Whether the source of funds is a Polymarket deposit wallet. */ + isPolymarketDepositWallet?: boolean; + /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index e6d47df328..ba2ec2afd7 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -85,6 +85,7 @@ export async function updateQuotes( isMaxAmount, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken: originalPaymentToken, refundTo, sourceAmounts, @@ -120,6 +121,7 @@ export async function updateQuotes( isMaxAmount: isMaxAmount ?? false, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -322,6 +324,7 @@ function clearControllerIfCurrent( * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. + * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. @@ -335,6 +338,7 @@ function buildQuoteRequests({ isMaxAmount, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -345,6 +349,7 @@ function buildQuoteRequests({ isMaxAmount: boolean; isPostQuote?: boolean; isHyperliquidSource?: boolean; + isPolymarketDepositWallet?: boolean; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -360,6 +365,7 @@ function buildQuoteRequests({ from, isMaxAmount, isHyperliquidSource, + isPolymarketDepositWallet, destinationToken: paymentToken, refundTo, sourceAmounts, @@ -402,6 +408,7 @@ function buildQuoteRequests({ * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. + * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). @@ -412,6 +419,7 @@ function buildPostQuoteRequests({ from, isMaxAmount, isHyperliquidSource, + isPolymarketDepositWallet, destinationToken, refundTo, sourceAmounts, @@ -420,6 +428,7 @@ function buildPostQuoteRequests({ from: Hex; isMaxAmount: boolean; isHyperliquidSource?: boolean; + isPolymarketDepositWallet?: boolean; destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -449,6 +458,7 @@ function buildPostQuoteRequests({ isMaxAmount, isPostQuote: true, isHyperliquidSource, + isPolymarketDepositWallet, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw, diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 3054d60587..083b5acc9e 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -45,12 +45,12 @@ export function updateSourceAmounts( // For post-quote flows, source amounts are calculated differently // The source is the transaction's required token, not the selected token if (isPostQuote) { - const { isHyperliquidSource } = transactionData; + const { isHyperliquidSource, isPolymarketDepositWallet } = transactionData; const sourceAmounts = calculatePostQuoteSourceAmounts( tokens, paymentToken, isMaxAmount ?? false, - isHyperliquidSource, + Boolean(isHyperliquidSource) || Boolean(isPolymarketDepositWallet), ); log('Updated post-quote source amounts', { transactionId, sourceAmounts }); transactionData.sourceAmounts = sourceAmounts; diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 15999d040f..2be9c1f46e 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -31,6 +31,20 @@ export function setPolymarketBridgeOptions( polymarketBridgeOptions = options; } +/** + * Get the Polymarket Bridge strategy options. + * Used by cross-strategy code (e.g. the Relay strategy's Polymarket relayer + * submission path) that needs to authenticate with the Polymarket relayer + * using the same credentials as the Polymarket Bridge strategy. + * + * @returns The Polymarket Bridge strategy options, or undefined if not set. + */ +export function getPolymarketBridgeOptions(): + | PolymarketBridgeStrategyOptions + | undefined { + return polymarketBridgeOptions; +} + /** * Get strategy instance by name. * From bcd0be29a91bb5cc4cf743ea10e10e02ed01358e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 17:50:56 +0100 Subject: [PATCH 04/16] fix(transaction-pay-controller): zero network fees for Polymarket deposit-wallet Relay path The Polymarket gasless relayer pays source-chain gas, so the user owes nothing. Extend the existing Hyperliquid zero-fee guard in calculateSourceNetworkCost to also cover isPolymarketDepositWallet. --- .../src/strategy/relay/relay-quotes.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 4e32fd5e76..baf2e5b543 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -628,8 +628,10 @@ async function calculateSourceNetworkCost( // HyperLiquid withdrawals are gasless -- the "deposit" step is an HL // sendAsset (off-chain signature), not an on-chain transaction. - if (request.isHyperliquidSource) { - log('Zeroing network fees for HyperLiquid withdrawal (gasless)'); + // Polymarket deposit-wallet transactions are gasless -- submitted via the + // Polymarket gasless relayer, so the user pays no source-chain gas. + if (request.isHyperliquidSource || request.isPolymarketDepositWallet) { + log('Zeroing network fees for gasless source submission'); const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; From 9a95578cca38854458e12cf06c992faeb5b99bab Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:21:44 +0100 Subject: [PATCH 05/16] chore(transaction-pay-controller): link changelog entries to PR #8754 --- packages/transaction-pay-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 76fd6711bf..4e89989b00 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#TBD](https://github.com/MetaMask/core/pull/TBD)) -- Add `isPolymarketDepositWallet` `TransactionConfig` flag — when set, the Relay strategy submits Polygon deposit transactions via the Polymarket gasless relayer (deposit-wallet `Batch`) instead of `TransactionController` or Relay `/execute` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#8754](https://github.com/MetaMask/core/pull/8754)) +- Add `isPolymarketDepositWallet` `TransactionConfig` flag — when set, the Relay strategy submits Polygon deposit transactions via the Polymarket gasless relayer (deposit-wallet `Batch`) instead of `TransactionController` or Relay `/execute` ([#8754](https://github.com/MetaMask/core/pull/8754)) ### Changed From 84511150124490c54c9ffb164fcd7c320c4354b4 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:31:04 +0100 Subject: [PATCH 06/16] Revert "feat(transaction-pay-controller): add Polymarket relayer submission path to Relay strategy" This reverts commit 2afa11abf5722be1193bec77a3afe541551fdbe2. --- .../transaction-pay-controller/CHANGELOG.md | 1 - .../src/TransactionPayController.ts | 9 - .../src/strategy/polymarket-bridge/index.ts | 13 -- .../src/strategy/relay/relay-quotes.ts | 9 +- .../src/strategy/relay/relay-submit.ts | 30 --- .../relay/submit-polymarket-relayer.ts | 175 ------------------ .../transaction-pay-controller/src/types.ts | 14 -- .../src/utils/quotes.ts | 10 - .../src/utils/source-amounts.ts | 4 +- .../src/utils/strategy.ts | 14 -- 10 files changed, 5 insertions(+), 274 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4e89989b00..6eddf824ce 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#8754](https://github.com/MetaMask/core/pull/8754)) -- Add `isPolymarketDepositWallet` `TransactionConfig` flag — when set, the Relay strategy submits Polygon deposit transactions via the Polymarket gasless relayer (deposit-wallet `Batch`) instead of `TransactionController` or Relay `/execute` ([#8754](https://github.com/MetaMask/core/pull/8754)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 127ea0695e..94f061fc40 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -123,7 +123,6 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, - isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -136,7 +135,6 @@ export class TransactionPayController extends BaseController< transactionData.isMaxAmount = config.isMaxAmount; transactionData.isPostQuote = config.isPostQuote; transactionData.isHyperliquidSource = config.isHyperliquidSource; - transactionData.isPolymarketDepositWallet = config.isPolymarketDepositWallet; transactionData.refundTo = config.refundTo; if (config.accountOverride !== previousAccountOverride) { @@ -317,7 +315,6 @@ export class TransactionPayController extends BaseController< #getStrategiesWithFallback( transaction: TransactionMeta, ): TransactionPayStrategy[] { - const hasGetStrategies = Boolean(this.#getStrategies); const strategyCandidates: unknown[] = this.#getStrategies?.(transaction) ?? (this.#getStrategy ? [this.#getStrategy(transaction)] : []); @@ -327,12 +324,6 @@ export class TransactionPayController extends BaseController< isTransactionPayStrategy(strategy), ); - console.log('[PolymarketBridge] getStrategiesWithFallback', { - hasGetStrategies, - candidates: JSON.stringify(strategyCandidates), - valid: JSON.stringify(validStrategies), - }); - if (validStrategies.length) { return validStrategies; } diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts deleted file mode 100644 index d431a85407..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { PolymarketRelayerApi, PolymarketRelayerError } from './relayer-api'; -export type { - RelayerCredentials, - RelayerApiKeyCredentials, - BuilderCredentials, -} from './relayer-api'; -export { buildWalletBatchTypedData } from './wallet-batch-typed-data'; -export { computeDepositWalletAddress } from './deposit-wallet'; -export { - DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - POLYMARKET_BATCH_DEADLINE_SECONDS, -} from './constants'; -export type { PolymarketBridgeRelayerSubmitRequest } from './types'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index baf2e5b543..3ca5e23e96 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -231,8 +231,7 @@ async function getSingleQuote( const useExecute = supports7702 && isRelayExecuteEnabled(messenger) && - isEIP7702Chain(messenger, sourceChainId) && - !request.isPolymarketDepositWallet; + isEIP7702Chain(messenger, sourceChainId); const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, @@ -628,10 +627,8 @@ async function calculateSourceNetworkCost( // HyperLiquid withdrawals are gasless -- the "deposit" step is an HL // sendAsset (off-chain signature), not an on-chain transaction. - // Polymarket deposit-wallet transactions are gasless -- submitted via the - // Polymarket gasless relayer, so the user pays no source-chain gas. - if (request.isHyperliquidSource || request.isPolymarketDepositWallet) { - log('Zeroing network fees for gasless source submission'); + if (request.isHyperliquidSource) { + log('Zeroing network fees for HyperLiquid withdrawal (gasless)'); const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index a64ff24b15..0082b6af66 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -37,7 +37,6 @@ import { RELAY_PENDING_STATUSES, } from './constants'; import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; -import { submitViaPolymarketRelayer } from './submit-polymarket-relayer'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, @@ -91,15 +90,6 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); - if ( - quote.request.isHyperliquidSource && - quote.request.isPolymarketDepositWallet - ) { - throw new Error( - 'Cannot set both isHyperliquidSource and isPolymarketDepositWallet on the same quote', - ); - } - updateTransaction( { transactionId: transaction.id, @@ -113,26 +103,6 @@ async function executeSingleQuote( if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); - } else if (quote.request.isPolymarketDepositWallet) { - await submitViaPolymarketRelayer( - quote, - quote.request.from, - messenger, - (sourceHash) => { - log('Source hash received from Polymarket relayer', sourceHash); - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Add source hash from Polymarket relayer submission', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = sourceHash; - }, - ); - }, - ); } else { await submitTransactions(quote, transaction, messenger); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts b/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts deleted file mode 100644 index 7f2ab99444..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/submit-polymarket-relayer.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../../logger'; -import type { - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types'; -import { - buildWalletBatchTypedData, - computeDepositWalletAddress, - DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - POLYMARKET_BATCH_DEADLINE_SECONDS, - PolymarketRelayerApi, -} from '../polymarket-bridge'; -import type { - PolymarketBridgeRelayerSubmitRequest, - RelayerCredentials, -} from '../polymarket-bridge'; -import type { PolymarketBridgeStrategyOptions } from '../polymarket-bridge/types'; -import { getPolymarketBridgeOptions } from '../../utils/strategy'; -import type { RelayQuote, RelayTransactionStep } from './types'; - -const log = createModuleLogger(projectLogger, 'relay-polymarket-submit'); - -const CHAIN_ID_POLYGON = 137; - -export async function submitViaPolymarketRelayer( - quote: TransactionPayQuote, - from: Hex, - messenger: TransactionPayControllerMessenger, - onSourceHash?: (hash: Hex) => void, -): Promise { - const options = getPolymarketBridgeOptions(); - - if (!options) { - throw new Error( - 'Polymarket bridge options not configured for Polymarket relayer submission', - ); - } - - const calls = extractDepositCalls(quote.original); - - log('Extracted deposit calls', { count: calls.length }); - - const depositWalletAddress = computeDepositWalletAddress(from); - - const relayerApi = new PolymarketRelayerApi( - options.environment, - buildCredentials(options), - ); - - log('Fetching wallet nonce', { from }); - const nonce = await relayerApi.getNonce(from, 'WALLET'); - - const deadline = - Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; - - const typedData = buildWalletBatchTypedData({ - wallet: depositWalletAddress, - nonce, - deadline, - calls, - chainId: CHAIN_ID_POLYGON, - }); - - log('Signing Batch via EIP-712', { nonce, deadline }); - - const signature = await messenger.call( - 'KeyringController:signTypedMessage', - { - from, - data: JSON.stringify(typedData), - }, - SignTypedDataVersion.V4, - ); - - const submitRequest: PolymarketBridgeRelayerSubmitRequest = { - type: 'WALLET', - from, - to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - nonce, - signature: signature as Hex, - depositWalletParams: { - depositWallet: depositWalletAddress, - deadline: deadline.toString(), - calls: calls.map((call) => ({ - target: call.target, - value: call.value.toString(), - data: call.data, - })), - }, - }; - - log('Submitting to relayer'); - const submitResponse = await relayerApi.submit(submitRequest); - - log('Relayer accepted', { - transactionID: submitResponse.transactionID, - state: submitResponse.state, - }); - - const terminalStatus = await relayerApi.pollUntilTerminal( - submitResponse.transactionID, - ); - - if ( - terminalStatus.state === 'STATE_FAILED' || - terminalStatus.state === 'STATE_INVALID' - ) { - throw new Error( - `Polymarket relayer submission failed: state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, - ); - } - - if (terminalStatus.transactionHash) { - log('Polymarket relayer reached terminal state', { - transactionHash: terminalStatus.transactionHash, - state: terminalStatus.state, - }); - - onSourceHash?.(terminalStatus.transactionHash as Hex); - } -} - -function extractDepositCalls( - quote: RelayQuote, -): { target: Hex; value: bigint; data: Hex }[] { - const invalidStep = quote.steps.find((step) => step.kind !== 'transaction'); - - if (invalidStep) { - throw new Error( - `Polymarket relayer submission only supports transaction-kind steps; got: ${quote.steps.map((step) => `${step.id}(${step.kind})`).join(', ')}`, - ); - } - - const transactionSteps = quote.steps.filter( - (step): step is RelayTransactionStep => step.kind === 'transaction', - ); - - return transactionSteps.flatMap((step) => - step.items.map((item) => { - if (item.data.chainId !== CHAIN_ID_POLYGON) { - throw new Error( - `Polymarket relayer submission only supports Polygon (137) calls; got chainId=${item.data.chainId}`, - ); - } - - return { - target: item.data.to, - value: BigInt(item.data.value ?? '0'), - data: item.data.data, - }; - }), - ); -} - -function buildCredentials( - options: PolymarketBridgeStrategyOptions, -): RelayerCredentials { - if (options.authType === 'relayer-api-key') { - return { - type: 'relayer-api-key', - apiKey: options.relayerApiKey, - }; - } - - return { - type: 'builder', - apiKey: options.builderApiKey, - secret: options.builderSecret, - passphrase: options.builderPassphrase ?? '', - }; -} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 92a1cdcaaf..a1f47bebe2 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -100,14 +100,6 @@ export type TransactionConfig = { */ isHyperliquidSource?: boolean; - /** - * Whether the source of funds is a Polymarket deposit wallet. - * When true, the Relay strategy submits Polygon deposit transactions - * via the Polymarket gasless relayer instead of TransactionController - * or Relay's /execute endpoint. - */ - isPolymarketDepositWallet?: boolean; - /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; @@ -237,9 +229,6 @@ export type TransactionData = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; - /** Whether the source of funds is a Polymarket deposit wallet. */ - isPolymarketDepositWallet?: boolean; - /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote @@ -416,9 +405,6 @@ export type QuoteRequest = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; - /** Whether the source of funds is a Polymarket deposit wallet. */ - isPolymarketDepositWallet?: boolean; - /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index ba2ec2afd7..e6d47df328 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -85,7 +85,6 @@ export async function updateQuotes( isMaxAmount, isPostQuote, isHyperliquidSource, - isPolymarketDepositWallet, paymentToken: originalPaymentToken, refundTo, sourceAmounts, @@ -121,7 +120,6 @@ export async function updateQuotes( isMaxAmount: isMaxAmount ?? false, isPostQuote, isHyperliquidSource, - isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -324,7 +322,6 @@ function clearControllerIfCurrent( * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. - * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. @@ -338,7 +335,6 @@ function buildQuoteRequests({ isMaxAmount, isPostQuote, isHyperliquidSource, - isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -349,7 +345,6 @@ function buildQuoteRequests({ isMaxAmount: boolean; isPostQuote?: boolean; isHyperliquidSource?: boolean; - isPolymarketDepositWallet?: boolean; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -365,7 +360,6 @@ function buildQuoteRequests({ from, isMaxAmount, isHyperliquidSource, - isPolymarketDepositWallet, destinationToken: paymentToken, refundTo, sourceAmounts, @@ -408,7 +402,6 @@ function buildQuoteRequests({ * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. - * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). @@ -419,7 +412,6 @@ function buildPostQuoteRequests({ from, isMaxAmount, isHyperliquidSource, - isPolymarketDepositWallet, destinationToken, refundTo, sourceAmounts, @@ -428,7 +420,6 @@ function buildPostQuoteRequests({ from: Hex; isMaxAmount: boolean; isHyperliquidSource?: boolean; - isPolymarketDepositWallet?: boolean; destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -458,7 +449,6 @@ function buildPostQuoteRequests({ isMaxAmount, isPostQuote: true, isHyperliquidSource, - isPolymarketDepositWallet, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw, diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 083b5acc9e..3054d60587 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -45,12 +45,12 @@ export function updateSourceAmounts( // For post-quote flows, source amounts are calculated differently // The source is the transaction's required token, not the selected token if (isPostQuote) { - const { isHyperliquidSource, isPolymarketDepositWallet } = transactionData; + const { isHyperliquidSource } = transactionData; const sourceAmounts = calculatePostQuoteSourceAmounts( tokens, paymentToken, isMaxAmount ?? false, - Boolean(isHyperliquidSource) || Boolean(isPolymarketDepositWallet), + isHyperliquidSource, ); log('Updated post-quote source amounts', { transactionId, sourceAmounts }); transactionData.sourceAmounts = sourceAmounts; diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 2be9c1f46e..15999d040f 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -31,20 +31,6 @@ export function setPolymarketBridgeOptions( polymarketBridgeOptions = options; } -/** - * Get the Polymarket Bridge strategy options. - * Used by cross-strategy code (e.g. the Relay strategy's Polymarket relayer - * submission path) that needs to authenticate with the Polymarket relayer - * using the same credentials as the Polymarket Bridge strategy. - * - * @returns The Polymarket Bridge strategy options, or undefined if not set. - */ -export function getPolymarketBridgeOptions(): - | PolymarketBridgeStrategyOptions - | undefined { - return polymarketBridgeOptions; -} - /** * Get strategy instance by name. * From 162aaf89936e7c19b99e461f7040f195cc5a291c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:38:51 +0100 Subject: [PATCH 07/16] refactor(transaction-pay-controller): remove credential plumbing from PolymarketBridgeStrategy The strategy now talks to the Polymarket relayer through a URL that is read from the remote feature flag at request time. Authentication is handled out-of-band by the configured endpoint, so the controller no longer accepts or stores any relayer credentials. - Add getPolymarketRelayerUrl feature-flag accessor with prod default - Drop polymarketBridgeOptions controller constructor option - Drop PolymarketBridgeStrategyOptions, *Input, RelayerCredentials, and related auth types - Drop HMAC / API-key header construction from PolymarketRelayerApi; it now takes a base URL and nothing else - Pin PolymarketBridgeApi to the prod URL (preprod URL no longer used) - Drop preprod URL constants --- .../src/TransactionPayController.ts | 4 -- .../transaction-pay-controller/src/index.ts | 6 +- .../PolymarketBridgeStrategy.ts | 37 +++------- .../strategy/polymarket-bridge/bridge-api.ts | 19 +----- .../strategy/polymarket-bridge/constants.ts | 6 -- .../strategy/polymarket-bridge/relayer-api.ts | 67 +------------------ .../src/strategy/polymarket-bridge/types.ts | 17 ----- .../transaction-pay-controller/src/types.ts | 18 ----- .../src/utils/feature-flags.ts | 23 +++++++ .../src/utils/strategy.ts | 22 +----- 10 files changed, 40 insertions(+), 179 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 94f061fc40..61cb6b4790 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -26,7 +26,6 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { setPolymarketBridgeOptions } from './utils/strategy'; import { getTransaction, pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ @@ -70,7 +69,6 @@ export class TransactionPayController extends BaseController< getStrategy, getStrategies, messenger, - polymarketBridgeOptions, state, }: TransactionPayControllerOptions) { super({ @@ -84,8 +82,6 @@ export class TransactionPayController extends BaseController< this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; - setPolymarketBridgeOptions(polymarketBridgeOptions); - this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 9d3efdd260..79f4d7e66c 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,5 +1,4 @@ export type { - PolymarketBridgeStrategyOptionsInput, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -32,7 +31,4 @@ export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; export { PolymarketBridgeStrategy } from './strategy/polymarket-bridge/PolymarketBridgeStrategy'; -export type { - PolymarketBridgeQuote, - PolymarketBridgeStrategyOptions, -} from './strategy/polymarket-bridge/types'; +export type { PolymarketBridgeQuote } from './strategy/polymarket-bridge/types'; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index 54af64ce40..b5077bcba2 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -9,18 +9,16 @@ import type { PayStrategyGetBatchRequest, PayStrategyGetQuotesRequest, PayStrategyGetRefreshIntervalRequest, + TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; +import { getPolymarketRelayerUrl } from '../../utils/feature-flags'; import { updateTransaction } from '../../utils/transaction'; import { PolymarketBridgeApi } from './bridge-api'; import { PUSD_ADDRESS_POLYGON, PUSD_DECIMALS } from './constants'; import { extractPolymarketWithdrawIntent } from './intent'; import { PolymarketRelayerApi } from './relayer-api'; -import type { RelayerCredentials } from './relayer-api'; -import type { - PolymarketBridgeQuote, - PolymarketBridgeStrategyOptions, -} from './types'; +import type { PolymarketBridgeQuote } from './types'; import { submitPolymarketBridgeWithdraw } from './withdraw'; const log = createModuleLogger(projectLogger, 'polymarket-bridge-strategy'); @@ -30,27 +28,12 @@ const REFRESH_INTERVAL_MS = 25_000; export class PolymarketBridgeStrategy implements PayStrategy { - readonly #bridgeApi: PolymarketBridgeApi; - - readonly #relayerApi: PolymarketRelayerApi; - - constructor(options: PolymarketBridgeStrategyOptions) { - this.#bridgeApi = new PolymarketBridgeApi(options.environment); - - const creds: RelayerCredentials = - options.authType === 'relayer-api-key' - ? { - type: 'relayer-api-key', - apiKey: options.relayerApiKey, - } - : { - type: 'builder', - apiKey: options.builderApiKey, - secret: options.builderSecret, - passphrase: options.builderPassphrase ?? '', - }; - - this.#relayerApi = new PolymarketRelayerApi(options.environment, creds); + readonly #bridgeApi: PolymarketBridgeApi = new PolymarketBridgeApi(); + + #buildRelayerApi( + messenger: TransactionPayControllerMessenger, + ): PolymarketRelayerApi { + return new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); } supports(_request: PayStrategyGetQuotesRequest): boolean { @@ -149,7 +132,7 @@ export class PolymarketBridgeStrategy intent.depositWalletAddress, depositAddress, request.messenger, - this.#relayerApi, + this.#buildRelayerApi(request.messenger), ); log('Relayer confirmed, setting sourceHash', { diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts index d617041e92..1d6b6bbdb2 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts @@ -2,10 +2,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../logger'; -import { - POLYMARKET_BRIDGE_BASE_URL_PROD, - POLYMARKET_BRIDGE_BASE_URL_PREPROD, -} from './constants'; +import { POLYMARKET_BRIDGE_BASE_URL_PROD } from './constants'; import type { PolymarketBridgeFeeBreakdown, PolymarketBridgeQuote, @@ -84,19 +81,7 @@ type BridgeStatusResponse = { * and poll for bridge transaction status. */ export class PolymarketBridgeApi { - readonly #baseUrl: string; - - /** - * Creates a new PolymarketBridgeApi instance. - * - * @param environment - The API environment to use ('prod' or 'preprod'). - */ - constructor(environment: 'prod' | 'preprod') { - this.#baseUrl = - environment === 'prod' - ? POLYMARKET_BRIDGE_BASE_URL_PROD - : POLYMARKET_BRIDGE_BASE_URL_PREPROD; - } + readonly #baseUrl: string = POLYMARKET_BRIDGE_BASE_URL_PROD; /** * Fetch a bridge quote for a cross-chain transfer. diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index e2c751d5a8..84d44fc598 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -1,15 +1,9 @@ import type { Hex } from '@metamask/utils'; -// Bridge API base URLs export const POLYMARKET_BRIDGE_BASE_URL_PROD = 'https://bridge.polymarket.com'; -export const POLYMARKET_BRIDGE_BASE_URL_PREPROD = - 'https://bridge-preprod.polymarket.com'; -// Relayer API base URLs export const POLYMARKET_RELAYER_BASE_URL_PROD = 'https://relayer-v2.polymarket.com'; -export const POLYMARKET_RELAYER_BASE_URL_PREPROD = - 'https://relayer-v2-preprod-int.polymarket.com'; // On-chain addresses (Polygon) export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts index b42c9b800f..eac7ff2c5d 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts @@ -1,15 +1,8 @@ -// eslint-disable-next-line import-x/no-nodejs-modules -import { createHmac } from 'crypto'; - import { successfulFetch } from '@metamask/controller-utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../logger'; -import { - POLYMARKET_RELAYER_BASE_URL_PREPROD, - POLYMARKET_RELAYER_BASE_URL_PROD, - RELAYER_TERMINAL_STATES, -} from './constants'; +import { RELAYER_TERMINAL_STATES } from './constants'; import type { PolymarketBridgeRelayerStatusResponse, PolymarketBridgeRelayerSubmitRequest, @@ -35,31 +28,11 @@ export class PolymarketRelayerError extends Error { } } -export type RelayerApiKeyCredentials = { - type: 'relayer-api-key'; - apiKey: string; -}; - -export type BuilderCredentials = { - type: 'builder'; - apiKey: string; - secret: string; - passphrase: string; -}; - -export type RelayerCredentials = RelayerApiKeyCredentials | BuilderCredentials; - export class PolymarketRelayerApi { readonly #baseUrl: string; - readonly #creds: RelayerCredentials; - - constructor(environment: 'prod' | 'preprod', creds: RelayerCredentials) { - this.#baseUrl = - environment === 'prod' - ? POLYMARKET_RELAYER_BASE_URL_PROD - : POLYMARKET_RELAYER_BASE_URL_PREPROD; - this.#creds = creds; + constructor(baseUrl: string) { + this.#baseUrl = baseUrl; } async getNonce(address: string, type: 'WALLET'): Promise { @@ -71,7 +44,6 @@ export class PolymarketRelayerApi { const response = await relayerFetch(url, { method: 'GET', headers: { - ...this.#authHeaders('GET', path, ''), Accept: 'application/json', }, }); @@ -95,7 +67,6 @@ export class PolymarketRelayerApi { const response = await relayerFetch(url, { method: 'POST', headers: { - ...this.#authHeaders('POST', path, body, request.from), 'Content-Type': 'application/json', }, body, @@ -121,7 +92,6 @@ export class PolymarketRelayerApi { const response = await relayerFetch(url, { method: 'GET', headers: { - ...this.#authHeaders('GET', path, ''), Accept: 'application/json', }, }); @@ -161,37 +131,6 @@ export class PolymarketRelayerApi { 'POLLING_TIMEOUT', ); } - - #authHeaders( - method: string, - path: string, - body: string, - fromAddress?: string, - ): Record { - if (this.#creds.type === 'relayer-api-key') { - if (!fromAddress) { - return {}; - } - - return { - RELAYER_API_KEY: this.#creds.apiKey, - RELAYER_API_KEY_ADDRESS: fromAddress, - }; - } - - const timestamp = Math.floor(Date.now() / 1000).toString(); - const canonical = timestamp + method.toUpperCase() + path + body; - const signature = createHmac('sha256', this.#creds.secret) - .update(canonical) - .digest('base64'); - - return { - 'POLY-BUILDER-API-KEY': this.#creds.apiKey, - 'POLY-BUILDER-TIMESTAMP': timestamp, - 'POLY-BUILDER-PASSPHRASE': this.#creds.passphrase, - 'POLY-BUILDER-SIGNATURE': signature, - }; - } } async function relayerFetch( diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts index 785a32e495..e026fce860 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts @@ -117,21 +117,4 @@ export type PolymarketRelayerState = | 'STATE_INVALID' | 'STATE_FAILED'; -export type PolymarketBridgeRelayerApiKeyAuth = { - authType: 'relayer-api-key'; - environment: 'prod' | 'preprod'; - relayerApiKey: string; - relayerApiKeyAddress: string; -}; - -export type PolymarketBridgeBuilderAuth = { - authType: 'builder'; - environment: 'prod' | 'preprod'; - builderApiKey: string; - builderSecret: string; - builderPassphrase?: string; -}; -export type PolymarketBridgeStrategyOptions = - | PolymarketBridgeRelayerApiKeyAuth - | PolymarketBridgeBuilderAuth; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index a1f47bebe2..7c5064498e 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -178,28 +178,10 @@ export type TransactionPayControllerOptions = { /** Controller messenger. */ messenger: TransactionPayControllerMessenger; - /** Configuration for the Polymarket Bridge strategy. When provided, enables the strategy. */ - polymarketBridgeOptions?: PolymarketBridgeStrategyOptionsInput; - /** Initial state of the controller. */ state?: Partial; }; -export type PolymarketBridgeStrategyOptionsInput = - | { - authType: 'relayer-api-key'; - environment: 'prod' | 'preprod'; - relayerApiKey: string; - relayerApiKeyAddress: string; - } - | { - authType: 'builder'; - environment: 'prod' | 'preprod'; - builderApiKey: string; - builderSecret: string; - builderPassphrase?: string; - }; - /** State of the TransactionPayController. */ export type TransactionPayControllerState = { /** State relating to each transaction, keyed by transaction ID. */ diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 491853bfdf..8592f2f962 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -4,6 +4,7 @@ import { uniq } from 'lodash'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; +import { POLYMARKET_RELAYER_BASE_URL_PROD } from '../strategy/polymarket-bridge/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, @@ -21,6 +22,7 @@ export const DEFAULT_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_EXECUTE_URL = RELAY_EXECUTE_URL; export const DEFAULT_RELAY_QUOTE_URL = RELAY_QUOTE_URL; export const DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD = '300000'; +export const DEFAULT_POLYMARKET_RELAYER_URL = POLYMARKET_RELAYER_BASE_URL_PROD; export const DEFAULT_SLIPPAGE = 0.005; export const DEFAULT_ACROSS_API_BASE = 'https://app.across.to/api'; export const DEFAULT_STRATEGY_ORDER: StrategyOrder = [ @@ -39,6 +41,7 @@ type FeatureFlagsRaw = { } >; }; + polymarketRelayerUrl?: string; relayDisabledGasStationChains?: Hex[]; relayExecuteUrl?: string; relayFallbackGas?: { @@ -546,6 +549,26 @@ export function getRelayPollingTimeout( return featureFlags.payStrategies?.relay?.pollingTimeout; } +/** + * Get the Polymarket relayer base URL. + * + * Allows the URL to be overridden remotely so the proxy that injects + * Polymarket relayer credentials can be swapped without a controller release. + * + * @param messenger - Controller messenger. + * @returns Polymarket relayer base URL. + */ +export function getPolymarketRelayerUrl( + messenger: TransactionPayControllerMessenger, +): string { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; + return featureFlags.polymarketRelayerUrl ?? DEFAULT_POLYMARKET_RELAYER_URL; +} + /** * Get fallback gas limits for quote/submit flows. * diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 15999d040f..e5d0e49ee3 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -10,27 +10,12 @@ import type { PayStrategyCheckQuoteSupportRequest, PayStrategyGetQuotesRequest, } from '../types'; -import type { PolymarketBridgeStrategyOptions } from '../strategy/polymarket-bridge/types'; export type NamedStrategy = { name: TransactionPayStrategy; strategy: PayStrategy; }; -let polymarketBridgeOptions: PolymarketBridgeStrategyOptions | undefined; - -/** - * Set the Polymarket Bridge strategy options. - * Called by the controller constructor when the consumer provides credentials. - * - * @param options - The Polymarket Bridge strategy options, or undefined to clear. - */ -export function setPolymarketBridgeOptions( - options: PolymarketBridgeStrategyOptions | undefined, -): void { - polymarketBridgeOptions = options; -} - /** * Get strategy instance by name. * @@ -54,12 +39,7 @@ export function getStrategyByName( return new FiatStrategy() as never; case TransactionPayStrategy.PolymarketBridge: - if (!polymarketBridgeOptions) { - throw new Error( - 'PolymarketBridgeStrategy requires polymarketBridgeOptions on the controller', - ); - } - return new PolymarketBridgeStrategy(polymarketBridgeOptions) as never; + return new PolymarketBridgeStrategy() as never; case TransactionPayStrategy.Test: return new TestStrategy() as never; From 59b18c819f49637d032bd09f642fa3ce6049a95a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 10:07:16 +0100 Subject: [PATCH 08/16] feat(transaction-pay-controller): route via isPolymarketDepositWallet flag + envelope-based relayer transport Convert PolymarketRelayerApi to the MetaMask Polymarket relayer-proxy envelope contract: a single POST to /transaction with a { path, method, body|query } body. The proxy authenticates and forwards to the underlying Polymarket relayer, so the controller carries no credentials. Add isPolymarketDepositWallet on TransactionConfig (mirrors the isHyperliquidSource pattern). When set, the controller routes the transaction to PolymarketBridgeStrategy and the post-quote source-amount calculation no longer dedupes same-token-same-chain (the strategy renormalizes the source to the on-chain deposit wallet). - Default proxy URL constant POLYMARKET_RELAYER_PROXY_URL_PROD - Drop unused raw-relayer URL constants - Propagate flag through quotes.ts and source-amounts.ts --- .../transaction-pay-controller/CHANGELOG.md | 2 + .../src/TransactionPayController.ts | 10 +- .../strategy/polymarket-bridge/constants.ts | 4 +- .../strategy/polymarket-bridge/relayer-api.ts | 112 +++++++++++------- .../src/strategy/polymarket-bridge/types.ts | 10 ++ .../transaction-pay-controller/src/types.ts | 14 +++ .../src/utils/feature-flags.ts | 4 +- .../src/utils/quotes.ts | 8 ++ .../src/utils/source-amounts.ts | 17 ++- 9 files changed, 127 insertions(+), 54 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6eddf824ce..88a8464cc0 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#8754](https://github.com/MetaMask/core/pull/8754)) + - Add `isPolymarketDepositWallet` flag on `TransactionConfig`. When set via `setTransactionConfig`, the controller routes the transaction's quotes and execution to `PolymarketBridgeStrategy`. + - Add `polymarketRelayerUrl` remote feature flag to override the Polymarket relayer proxy URL without a release. ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 61cb6b4790..96422e1edc 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -119,6 +119,7 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, + isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -131,6 +132,8 @@ export class TransactionPayController extends BaseController< transactionData.isMaxAmount = config.isMaxAmount; transactionData.isPostQuote = config.isPostQuote; transactionData.isHyperliquidSource = config.isHyperliquidSource; + transactionData.isPolymarketDepositWallet = + config.isPolymarketDepositWallet; transactionData.refundTo = config.refundTo; if (config.accountOverride !== previousAccountOverride) { @@ -311,6 +314,12 @@ export class TransactionPayController extends BaseController< #getStrategiesWithFallback( transaction: TransactionMeta, ): TransactionPayStrategy[] { + const transactionData = this.state.transactionData[transaction.id]; + + if (transactionData?.isPolymarketDepositWallet) { + return [TransactionPayStrategy.PolymarketBridge]; + } + const strategyCandidates: unknown[] = this.#getStrategies?.(transaction) ?? (this.#getStrategy ? [this.#getStrategy(transaction)] : []); @@ -324,7 +333,6 @@ export class TransactionPayController extends BaseController< return validStrategies; } - const transactionData = this.state.transactionData[transaction.id]; const paymentToken = transactionData?.paymentToken; return getStrategyOrder( diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index 84d44fc598..b2108da7ec 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -2,8 +2,8 @@ import type { Hex } from '@metamask/utils'; export const POLYMARKET_BRIDGE_BASE_URL_PROD = 'https://bridge.polymarket.com'; -export const POLYMARKET_RELAYER_BASE_URL_PROD = - 'https://relayer-v2.polymarket.com'; +export const POLYMARKET_RELAYER_PROXY_URL_PROD = + 'https://predict.api.cx.metamask.io'; // On-chain addresses (Polygon) export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts index eac7ff2c5d..6c8f37a300 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts @@ -7,6 +7,7 @@ import type { PolymarketBridgeRelayerStatusResponse, PolymarketBridgeRelayerSubmitRequest, PolymarketBridgeRelayerSubmitResponse, + PolymarketRelayerProxyEnvelope, PolymarketRelayerState, } from './types'; @@ -36,20 +37,14 @@ export class PolymarketRelayerApi { } async getNonce(address: string, type: 'WALLET'): Promise { - const path = `/nonce?address=${address}&type=${type}`; - const url = `${this.#baseUrl}${path}`; - log('Fetching nonce', { address, type }); - const response = await relayerFetch(url, { + const result = await this.#postEnvelope<{ nonce: string }>({ + path: '/nonce', method: 'GET', - headers: { - Accept: 'application/json', - }, + query: { address, type }, }); - const result = (await response.json()) as { nonce: string }; - log('Nonce received', { nonce: result.nonce }); return result.nonce; @@ -58,22 +53,14 @@ export class PolymarketRelayerApi { async submit( request: PolymarketBridgeRelayerSubmitRequest, ): Promise { - const path = '/submit'; - const body = JSON.stringify(request); - const url = `${this.#baseUrl}${path}`; - log('Submitting transaction', { from: request.from, to: request.to }); - const response = await relayerFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body, - }); - const result = - (await response.json()) as PolymarketBridgeRelayerSubmitResponse; + await this.#postEnvelope({ + path: '/submit', + method: 'POST', + body: request, + }); log('Transaction submitted', { transactionID: result.transactionID, @@ -86,17 +73,16 @@ export class PolymarketRelayerApi { async getTransaction( transactionId: string, ): Promise { - const path = `/transaction?id=${transactionId}`; - const url = `${this.#baseUrl}${path}`; - - const response = await relayerFetch(url, { + const result = await this.#postEnvelope< + | PolymarketBridgeRelayerStatusResponse + | PolymarketBridgeRelayerStatusResponse[] + >({ + path: '/transaction', method: 'GET', - headers: { - Accept: 'application/json', - }, + query: { id: transactionId }, }); - return (await response.json()) as PolymarketBridgeRelayerStatusResponse[]; + return Array.isArray(result) ? result : [result]; } async pollUntilTerminal( @@ -131,20 +117,60 @@ export class PolymarketRelayerApi { 'POLLING_TIMEOUT', ); } -} -async function relayerFetch( - url: string, - init?: RequestInit, -): Promise { - try { - return await successfulFetch(url, init); - } catch (error) { - throw new PolymarketRelayerError( - `Relayer request failed: ${String(error)}`, - 'REQUEST_FAILED', - error, - ); + async #postEnvelope( + envelope: PolymarketRelayerProxyEnvelope, + ): Promise { + const url = `${this.#baseUrl}/transaction`; + + let response: Response; + try { + response = await successfulFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(envelope), + }); + } catch (error) { + throw new PolymarketRelayerError( + `Relayer proxy request failed: ${String(error)}`, + 'REQUEST_FAILED', + error, + ); + } + + const text = await response.text(); + + if (!text) { + throw new PolymarketRelayerError( + 'Relayer proxy returned an empty response', + 'EMPTY_RESPONSE', + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new PolymarketRelayerError( + 'Relayer proxy returned malformed JSON', + 'MALFORMED_JSON', + error, + ); + } + + if ( + typeof parsed === 'object' && + parsed !== null && + 'error' in parsed && + typeof (parsed as { error: unknown }).error === 'string' + ) { + throw new PolymarketRelayerError( + (parsed as { error: string }).error, + 'PROXY_ERROR', + ); + } + + return parsed as TResponse; } } diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts index e026fce860..4e546d80df 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts @@ -117,4 +117,14 @@ export type PolymarketRelayerState = | 'STATE_INVALID' | 'STATE_FAILED'; +/** + * Envelope posted to the MetaMask Polymarket relayer proxy. The proxy + * authenticates the request and forwards it to the underlying Polymarket + * relayer using the path/method/body or query described here. + */ +export type PolymarketRelayerProxyEnvelope = + | { path: '/submit'; method: 'POST'; body: unknown } + | { path: '/nonce'; method: 'GET'; query: Record } + | { path: '/transaction'; method: 'GET'; query: Record }; + diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 7c5064498e..f4af95201e 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -100,6 +100,14 @@ export type TransactionConfig = { */ isHyperliquidSource?: boolean; + /** + * Whether the source of funds is a Polymarket deposit wallet. + * When true, transaction-pay routes the post-quote `predictWithdraw` to + * the Polymarket Bridge strategy, which signs a deposit-wallet `Batch` + * and submits it via the Polymarket relayer proxy. + */ + isPolymarketDepositWallet?: boolean; + /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; @@ -211,6 +219,9 @@ export type TransactionData = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; + /** Whether the source of funds is a Polymarket deposit wallet. */ + isPolymarketDepositWallet?: boolean; + /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote @@ -387,6 +398,9 @@ export type QuoteRequest = { /** Whether the source of funds is HyperLiquid (HyperCore). */ isHyperliquidSource?: boolean; + /** Whether the source of funds is a Polymarket deposit wallet. */ + isPolymarketDepositWallet?: boolean; + /** * Optional address to receive refunds if the Relay transaction fails. * When set, overrides the default refund recipient (EOA) in the Relay quote diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 8592f2f962..09d72ae508 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -4,7 +4,7 @@ import { uniq } from 'lodash'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; -import { POLYMARKET_RELAYER_BASE_URL_PROD } from '../strategy/polymarket-bridge/constants'; +import { POLYMARKET_RELAYER_PROXY_URL_PROD } from '../strategy/polymarket-bridge/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, @@ -22,7 +22,7 @@ export const DEFAULT_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_EXECUTE_URL = RELAY_EXECUTE_URL; export const DEFAULT_RELAY_QUOTE_URL = RELAY_QUOTE_URL; export const DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD = '300000'; -export const DEFAULT_POLYMARKET_RELAYER_URL = POLYMARKET_RELAYER_BASE_URL_PROD; +export const DEFAULT_POLYMARKET_RELAYER_URL = POLYMARKET_RELAYER_PROXY_URL_PROD; export const DEFAULT_SLIPPAGE = 0.005; export const DEFAULT_ACROSS_API_BASE = 'https://app.across.to/api'; export const DEFAULT_STRATEGY_ORDER: StrategyOrder = [ diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index e6d47df328..13c923ea3e 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -85,6 +85,7 @@ export async function updateQuotes( isMaxAmount, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken: originalPaymentToken, refundTo, sourceAmounts, @@ -120,6 +121,7 @@ export async function updateQuotes( isMaxAmount: isMaxAmount ?? false, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -335,6 +337,7 @@ function buildQuoteRequests({ isMaxAmount, isPostQuote, isHyperliquidSource, + isPolymarketDepositWallet, paymentToken, refundTo, sourceAmounts, @@ -345,6 +348,7 @@ function buildQuoteRequests({ isMaxAmount: boolean; isPostQuote?: boolean; isHyperliquidSource?: boolean; + isPolymarketDepositWallet?: boolean; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -360,6 +364,7 @@ function buildQuoteRequests({ from, isMaxAmount, isHyperliquidSource, + isPolymarketDepositWallet, destinationToken: paymentToken, refundTo, sourceAmounts, @@ -412,6 +417,7 @@ function buildPostQuoteRequests({ from, isMaxAmount, isHyperliquidSource, + isPolymarketDepositWallet, destinationToken, refundTo, sourceAmounts, @@ -420,6 +426,7 @@ function buildPostQuoteRequests({ from: Hex; isMaxAmount: boolean; isHyperliquidSource?: boolean; + isPolymarketDepositWallet?: boolean; destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -449,6 +456,7 @@ function buildPostQuoteRequests({ isMaxAmount, isPostQuote: true, isHyperliquidSource, + isPolymarketDepositWallet, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw, diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 3054d60587..c713565edb 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -45,12 +45,13 @@ export function updateSourceAmounts( // For post-quote flows, source amounts are calculated differently // The source is the transaction's required token, not the selected token if (isPostQuote) { - const { isHyperliquidSource } = transactionData; + const { isHyperliquidSource, isPolymarketDepositWallet } = transactionData; const sourceAmounts = calculatePostQuoteSourceAmounts( tokens, paymentToken, isMaxAmount ?? false, isHyperliquidSource, + isPolymarketDepositWallet, ); log('Updated post-quote source amounts', { transactionId, sourceAmounts }); transactionData.sourceAmounts = sourceAmounts; @@ -90,6 +91,7 @@ function calculatePostQuoteSourceAmounts( paymentToken: TransactionPaymentToken, isMaxAmount: boolean, isHyperliquidSource?: boolean, + isPolymarketDepositWallet?: boolean, ): TransactionPaySourceAmount[] { return tokens .filter((token) => { @@ -103,11 +105,14 @@ function calculatePostQuoteSourceAmounts( return false; } - // Skip same token on same chain, unless the source is HyperLiquid. - // For HyperLiquid withdrawals the relay strategy renormalizes the - // source from Arbitrum USDC to HyperCore USDC (a different chain), - // so the tokens are not actually the same after normalization. - if (isSameToken(token, paymentToken) && !isHyperliquidSource) { + // Skip same token on same chain, unless the source is a synthetic + // upstream (HyperLiquid HyperCore or Polymarket deposit wallet) that + // the strategy renormalizes to a different effective source. + if ( + isSameToken(token, paymentToken) && + !isHyperliquidSource && + !isPolymarketDepositWallet + ) { log('Skipping token as same as destination token'); return false; } From f59f50ad0cfb40d50a62d0fa21adb914f1b58fbd Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 13:18:46 +0100 Subject: [PATCH 09/16] feat(transaction-pay-controller): surface Polymarket bridge fees, source and target amounts Calculate provider fees from the bridge quote's estFeeBreakdown (gasUsd + appFeeUsd + swapImpactUsd) and convert to fiat via the source-token fiat rate. Populate sourceAmount and targetAmount with fiat and USD values from token rates so the confirmation surfaces meaningful amounts instead of zeros. --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../PolymarketBridgeStrategy.ts | 128 +++++++++++++----- 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 88a8464cc0..eaeb92f4d0 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#8754](https://github.com/MetaMask/core/pull/8754)) - Add `isPolymarketDepositWallet` flag on `TransactionConfig`. When set via `setTransactionConfig`, the controller routes the transaction's quotes and execution to `PolymarketBridgeStrategy`. - Add `polymarketRelayerUrl` remote feature flag to override the Polymarket relayer proxy URL without a release. + - Surface bridge fees (`gasUsd + appFeeUsd + swapImpactUsd`) as `fees.provider`, and populate `sourceAmount` and `targetAmount` with fiat/USD values from token rates. ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index b5077bcba2..606bb59db2 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { TransactionPayStrategy } from '../../constants'; import { projectLogger } from '../../logger'; @@ -12,15 +13,20 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; +import { getFiatValueFromUsd } from '../../utils/amounts'; import { getPolymarketRelayerUrl } from '../../utils/feature-flags'; +import { getTokenFiatRate } from '../../utils/token'; import { updateTransaction } from '../../utils/transaction'; import { PolymarketBridgeApi } from './bridge-api'; import { PUSD_ADDRESS_POLYGON, PUSD_DECIMALS } from './constants'; +import { computeDepositWalletAddress } from './deposit-wallet'; import { extractPolymarketWithdrawIntent } from './intent'; import { PolymarketRelayerApi } from './relayer-api'; import type { PolymarketBridgeQuote } from './types'; import { submitPolymarketBridgeWithdraw } from './withdraw'; +const POLYGON_CHAIN_ID = '0x89' as Hex; + const log = createModuleLogger(projectLogger, 'polymarket-bridge-strategy'); const REFRESH_INTERVAL_MS = 25_000; @@ -65,60 +71,107 @@ export class PolymarketBridgeStrategy toTokenAddress: quoteRequest.targetTokenAddress.toLowerCase(), }); - const humanAmount = formatBaseUnits(intent.amount, PUSD_DECIMALS); + const quote = this.#buildQuote({ + bridgeQuote, + intent, + messenger: request.messenger, + quoteRequest, + }); + + log('Quote built', { + quoteId: bridgeQuote.quoteId, + providerUsd: quote.fees.provider.usd, + }); + + return [quote]; + } + + #buildQuote({ + bridgeQuote, + intent, + messenger, + quoteRequest, + }: { + bridgeQuote: PolymarketBridgeQuote; + intent: { amount: bigint }; + messenger: TransactionPayControllerMessenger; + quoteRequest: PayStrategyGetQuotesRequest['requests'][number]; + }): TransactionPayQuote { + const sourceFiatRate = getTokenFiatRate( + messenger, + PUSD_ADDRESS_POLYGON, + POLYGON_CHAIN_ID, + ); + + const targetFiatRate = + getTokenFiatRate( + messenger, + quoteRequest.targetTokenAddress, + quoteRequest.targetChainId, + ) ?? sourceFiatRate; + + const usdToFiatRate = + sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) + ? new BigNumber(sourceFiatRate.fiatRate).dividedBy( + sourceFiatRate.usdRate, + ) + : new BigNumber(1); + + const sourceAmount = calculateAmount( + intent.amount.toString(), + PUSD_DECIMALS, + sourceFiatRate, + ); + + const targetAmount = calculateAmount( + bridgeQuote.toAmount, + // Polymarket bridge currently only supports USDC-equivalents (6 decimals) + PUSD_DECIMALS, + targetFiatRate, + ); + + const providerUsd = new BigNumber(bridgeQuote.estFeeBreakdown.gasUsd) + .plus(bridgeQuote.estFeeBreakdown.appFeeUsd) + .plus(bridgeQuote.estFeeBreakdown.swapImpactUsd); - const quote: TransactionPayQuote = { + const provider = getFiatValueFromUsd(providerUsd, usdToFiatRate); + + return { original: bridgeQuote, fees: { metaMask: { fiat: '0', usd: '0' }, - provider: { fiat: '0', usd: '0' }, + provider, sourceNetwork: { estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, max: { fiat: '0', usd: '0', human: '0', raw: '0' }, }, targetNetwork: { fiat: '0', usd: '0' }, }, - sourceAmount: { - fiat: '0', - usd: '0', - human: humanAmount, - raw: intent.amount.toString(), - }, - targetAmount: { fiat: '0', usd: '0' }, + sourceAmount, + targetAmount: { fiat: targetAmount.fiat, usd: targetAmount.usd }, dust: { fiat: '0', usd: '0' }, estimatedDuration: bridgeQuote.estCheckoutTimeMs / 1000, strategy: TransactionPayStrategy.PolymarketBridge, request: quoteRequest, }; - - log('Quote built', { quoteId: bridgeQuote.quoteId }); - - return [quote]; } async execute( request: PayStrategyExecuteRequest, ): Promise<{ transactionHash?: Hex }> { - const intent = extractPolymarketWithdrawIntent(request.transaction); - - if (!intent) { - throw new Error( - 'Polymarket bridge execute: transaction is not a deposit-wallet predictWithdraw', - ); - } - const quote = request.quotes[0]; if (!quote) { throw new Error('Polymarket bridge execute: no quote provided'); } - const from = request.transaction.txParams.from as Hex; + const from = quote.request.from; + const depositWalletAddress = computeDepositWalletAddress(from); log('Creating one-shot deposit address'); const depositAddress = await this.#bridgeApi.createWithdrawAddress({ - address: intent.depositWalletAddress, + address: depositWalletAddress, toChainId: parseInt(quote.request.targetChainId, 16).toString(), toTokenAddress: quote.request.targetTokenAddress.toLowerCase(), recipientAddr: from, @@ -129,7 +182,7 @@ export class PolymarketBridgeStrategy const result = await submitPolymarketBridgeWithdraw( quote, from, - intent.depositWalletAddress, + depositWalletAddress, depositAddress, request.messenger, this.#buildRelayerApi(request.messenger), @@ -193,11 +246,22 @@ export class PolymarketBridgeStrategy } } -function formatBaseUnits(amount: bigint, decimals: number): string { - const divisor = 10n ** BigInt(decimals); - const whole = amount / divisor; - const remainder = amount % divisor; - const paddedRemainder = remainder.toString().padStart(decimals, '0'); - - return `${whole}.${paddedRemainder}`; +function calculateAmount( + raw: string, + decimals: number, + fiatRate: + | { fiatRate: string; usdRate: string } + | undefined, +): { fiat: string; human: string; raw: string; usd: string } { + const humanValue = new BigNumber(raw).shiftedBy(-decimals); + const human = humanValue.toString(10); + + const usd = fiatRate + ? humanValue.multipliedBy(fiatRate.usdRate).toString(10) + : '0'; + const fiat = fiatRate + ? humanValue.multipliedBy(fiatRate.fiatRate).toString(10) + : '0'; + + return { fiat, human, raw, usd }; } From 78c9180bc988e64f025b1d0196059a57932fac52 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 13:39:44 +0100 Subject: [PATCH 10/16] fix(transaction-pay-controller): mark Polymarket bridge withdraws complete at execute start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7702 batch wrapper transaction created by addTransactionBatch is never broadcast on-chain — PolymarketBridgeStrategy intercepts the publish hook and submits an off-chain envelope to the relayer, which in turn broadcasts a separate transaction signed by the relayer. Without isIntentComplete set, PendingTransactionTracker.#checkTransaction runs against the wrapper, finds no hash, and fails the transaction with NoTxHashError — even though the user's pUSD has been bridged successfully. Set isIntentComplete at the start of execute() so the wrapper is treated as confirmed by the tracker. Drop the post-submit pollUntilBridgeComplete because target-side bridge completion is independent of source-side success and does not gate the user's wrapper transaction status. --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../PolymarketBridgeStrategy.ts | 39 ++++++------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index eaeb92f4d0..53ea23709b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `isPolymarketDepositWallet` flag on `TransactionConfig`. When set via `setTransactionConfig`, the controller routes the transaction's quotes and execution to `PolymarketBridgeStrategy`. - Add `polymarketRelayerUrl` remote feature flag to override the Polymarket relayer proxy URL without a release. - Surface bridge fees (`gasUsd + appFeeUsd + swapImpactUsd`) as `fees.provider`, and populate `sourceAmount` and `targetAmount` with fiat/USD values from token rates. + - Mark `isIntentComplete` at the start of `PolymarketBridgeStrategy.execute()` so the wrapper batch transaction is treated as confirmed by `PendingTransactionTracker` instead of failed (no on-chain receipt exists for the wrapper; the relayer broadcasts a separate transaction). ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index 606bb59db2..127b5c4302 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -165,6 +165,17 @@ export class PolymarketBridgeStrategy throw new Error('Polymarket bridge execute: no quote provided'); } + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Mark intent complete at Polymarket bridge execute start', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + const from = quote.request.from; const depositWalletAddress = computeDepositWalletAddress(from); @@ -204,33 +215,7 @@ export class PolymarketBridgeStrategy }, ); - log('Polling bridge for target-side completion', { depositAddress }); - - const bridgeResult = - await this.#bridgeApi.pollUntilBridgeComplete(depositAddress); - - if (bridgeResult.status === 'FAILED') { - throw new Error( - `Polymarket bridge failed on target chain for deposit ${depositAddress}`, - ); - } - - const targetHash = (bridgeResult.txHash ?? result.relayerTransactionHash) as Hex; - - log('Bridge complete', { targetHash, status: bridgeResult.status }); - - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Intent complete after Polymarket bridge completion', - }, - (tx) => { - tx.isIntentComplete = true; - }, - ); - - return { transactionHash: targetHash }; + return { transactionHash: result.relayerTransactionHash }; } async getBatchTransactions( From 0deeabd002a2bc2a3cd0d26b5ba73d5841fe7afe Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 20:13:45 +0100 Subject: [PATCH 11/16] feat(transaction-pay-controller): experimental Relay-backed Polymarket bridge withdraw Behind a hardcoded USE_RELAY_BRIDGE flag, the Polymarket bridge strategy now fetches a Relay quote (pUSD on Polygon -> target chain/token) at quote time and executes in two steps: 1. Transfer pUSD from the deposit wallet to the user EOA via the existing Polymarket relayer proxy (single ERC-20 transfer batch). 2. Submit the stored Relay quote from the user EOA via submitRelayQuotes, which polls Relay status and returns the destination tx hash. Synthetic QuoteRequest sets isPostQuote: true so submitRelayQuotes skips its own source-balance validation (the EOA is funded by Step 1 and the chain RPC may lag); txParams.to is stripped on the relayed transaction so the post-quote prepend stays disabled. Flag toggles to false to fall back to the original Polymarket bridge flow. --- .../PolymarketBridgeStrategy.ts | 329 +++++++++++++++++- .../strategy/polymarket-bridge/bridge-api.ts | 4 +- .../strategy/polymarket-bridge/constants.ts | 15 + .../src/strategy/polymarket-bridge/types.ts | 9 + 4 files changed, 342 insertions(+), 15 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index 127b5c4302..7222736043 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -1,3 +1,4 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -12,13 +13,27 @@ import type { PayStrategyGetRefreshIntervalRequest, TransactionPayControllerMessenger, TransactionPayQuote, + QuoteRequest, } from '../../types'; import { getFiatValueFromUsd } from '../../utils/amounts'; -import { getPolymarketRelayerUrl } from '../../utils/feature-flags'; +import { + getPolymarketRelayerUrl, + getRelayOriginGasOverhead, + getSlippage, + isEIP7702Chain, + isRelayExecuteEnabled, +} from '../../utils/feature-flags'; import { getTokenFiatRate } from '../../utils/token'; import { updateTransaction } from '../../utils/transaction'; +import { fetchRelayQuote } from '../relay/relay-api'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote, RelayQuoteRequest } from '../relay/types'; import { PolymarketBridgeApi } from './bridge-api'; -import { PUSD_ADDRESS_POLYGON, PUSD_DECIMALS } from './constants'; +import { + PUSD_ADDRESS_POLYGON, + PUSD_DECIMALS, + USE_RELAY_BRIDGE, +} from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; import { extractPolymarketWithdrawIntent } from './intent'; import { PolymarketRelayerApi } from './relayer-api'; @@ -26,6 +41,7 @@ import type { PolymarketBridgeQuote } from './types'; import { submitPolymarketBridgeWithdraw } from './withdraw'; const POLYGON_CHAIN_ID = '0x89' as Hex; +const POLYGON_CHAIN_ID_NUMBER = 137; const log = createModuleLogger(projectLogger, 'polymarket-bridge-strategy'); @@ -43,7 +59,6 @@ export class PolymarketBridgeStrategy } supports(_request: PayStrategyGetQuotesRequest): boolean { - // TODO: restore intent check once transaction shape is verified end-to-end return true; } @@ -62,6 +77,26 @@ export class PolymarketBridgeStrategy return []; } + if (USE_RELAY_BRIDGE) { + return await this.#getRelayBackedQuote({ request, intent, quoteRequest }); + } + + return await this.#getPolymarketBridgeQuote({ + request, + intent, + quoteRequest, + }); + } + + async #getPolymarketBridgeQuote({ + request, + intent, + quoteRequest, + }: { + request: PayStrategyGetQuotesRequest; + intent: { amount: bigint }; + quoteRequest: QuoteRequest; + }): Promise[]> { const bridgeQuote = await this.#bridgeApi.getQuote({ fromAmountBaseUnit: intent.amount.toString(), fromChainId: '137', @@ -71,14 +106,14 @@ export class PolymarketBridgeStrategy toTokenAddress: quoteRequest.targetTokenAddress.toLowerCase(), }); - const quote = this.#buildQuote({ + const quote = this.#buildPolymarketBridgeQuote({ bridgeQuote, intent, messenger: request.messenger, quoteRequest, }); - log('Quote built', { + log('Polymarket bridge quote built', { quoteId: bridgeQuote.quoteId, providerUsd: quote.fees.provider.usd, }); @@ -86,7 +121,75 @@ export class PolymarketBridgeStrategy return [quote]; } - #buildQuote({ + async #getRelayBackedQuote({ + request, + intent, + quoteRequest, + }: { + request: PayStrategyGetQuotesRequest; + intent: { amount: bigint }; + quoteRequest: QuoteRequest; + }): Promise[]> { + const { messenger, accountSupports7702 } = request; + const depositWalletAddress = computeDepositWalletAddress(quoteRequest.from); + + const useExecute = + accountSupports7702 && + isRelayExecuteEnabled(messenger) && + isEIP7702Chain(messenger, POLYGON_CHAIN_ID); + + const slippageDecimal = getSlippage( + messenger, + POLYGON_CHAIN_ID, + PUSD_ADDRESS_POLYGON, + ); + const slippageTolerance = new BigNumber( + slippageDecimal * 100 * 100, + ).toFixed(0); + + const body: RelayQuoteRequest = { + amount: intent.amount.toString(), + destinationChainId: parseInt(quoteRequest.targetChainId, 16), + destinationCurrency: quoteRequest.targetTokenAddress, + originChainId: POLYGON_CHAIN_ID_NUMBER, + originCurrency: PUSD_ADDRESS_POLYGON, + ...(useExecute + ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } + : {}), + recipient: quoteRequest.from, + refundTo: depositWalletAddress, + slippageTolerance, + tradeType: 'EXACT_INPUT', + user: quoteRequest.from, + }; + + log('Fetching Relay quote (pUSD→target)', { + destinationChainId: body.destinationChainId, + destinationCurrency: body.destinationCurrency, + amount: body.amount, + useExecute, + }); + + const relayQuote = await fetchRelayQuote(messenger, body, request.signal); + + log('Relay quote fetched', { + currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, + totalImpactUsd: relayQuote.details.totalImpact.usd, + isExecute: relayQuote.metamask?.isExecute, + stepCount: relayQuote.steps.length, + }); + + const quote = this.#buildRelayBackedQuote({ + relayQuote, + intent, + messenger, + quoteRequest, + }); + + return [quote]; + } + + #buildPolymarketBridgeQuote({ bridgeQuote, intent, messenger, @@ -95,7 +198,7 @@ export class PolymarketBridgeStrategy bridgeQuote: PolymarketBridgeQuote; intent: { amount: bigint }; messenger: TransactionPayControllerMessenger; - quoteRequest: PayStrategyGetQuotesRequest['requests'][number]; + quoteRequest: QuoteRequest; }): TransactionPayQuote { const sourceFiatRate = getTokenFiatRate( messenger, @@ -125,7 +228,6 @@ export class PolymarketBridgeStrategy const targetAmount = calculateAmount( bridgeQuote.toAmount, - // Polymarket bridge currently only supports USDC-equivalents (6 decimals) PUSD_DECIMALS, targetFiatRate, ); @@ -156,6 +258,81 @@ export class PolymarketBridgeStrategy }; } + #buildRelayBackedQuote({ + relayQuote, + intent, + messenger, + quoteRequest, + }: { + relayQuote: RelayQuote; + intent: { amount: bigint }; + messenger: TransactionPayControllerMessenger; + quoteRequest: QuoteRequest; + }): TransactionPayQuote { + const sourceFiatRate = getTokenFiatRate( + messenger, + PUSD_ADDRESS_POLYGON, + POLYGON_CHAIN_ID, + ); + + const usdToFiatRate = + sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) + ? new BigNumber(sourceFiatRate.fiatRate).dividedBy( + sourceFiatRate.usdRate, + ) + : new BigNumber(1); + + const sourceAmount = calculateAmount( + intent.amount.toString(), + PUSD_DECIMALS, + sourceFiatRate, + ); + + const targetAmountUsd = new BigNumber( + relayQuote.details.currencyOut.amountUsd, + ); + const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); + + const providerFeeUsd = new BigNumber( + relayQuote.fees.relayer?.amountUsd ?? '0', + ).plus(relayQuote.fees.app?.amountUsd ?? '0'); + const provider = getFiatValueFromUsd(providerFeeUsd, usdToFiatRate); + + const stub: PolymarketBridgeQuote = { + quoteId: relayQuote.steps[0]?.requestId ?? '', + bridgeDepositAddress: null, + fromAmount: intent.amount.toString(), + toAmount: relayQuote.details.currencyOut.amount, + minReceived: relayQuote.details.currencyOut.minimumAmount, + estCheckoutTimeMs: (relayQuote.details.timeEstimate ?? 30) * 1000, + estFeeBreakdown: { + gasUsd: 0, + appFeeUsd: Number(relayQuote.fees.app?.amountUsd ?? '0'), + swapImpactUsd: 0, + }, + relayQuote, + }; + + return { + original: stub, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider, + sourceNetwork: { + estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, + max: { fiat: '0', usd: '0', human: '0', raw: '0' }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + sourceAmount, + targetAmount, + dust: { fiat: '0', usd: '0' }, + estimatedDuration: relayQuote.details.timeEstimate ?? 30, + strategy: TransactionPayStrategy.PolymarketBridge, + request: quoteRequest, + }; + } + async execute( request: PayStrategyExecuteRequest, ): Promise<{ transactionHash?: Hex }> { @@ -176,6 +353,17 @@ export class PolymarketBridgeStrategy }, ); + if (quote.original.relayQuote) { + return await this.#executeRelayBacked(request, quote.original.relayQuote); + } + + return await this.#executePolymarketBridge(request); + } + + async #executePolymarketBridge( + request: PayStrategyExecuteRequest, + ): Promise<{ transactionHash?: Hex }> { + const quote = request.quotes[0]; const from = quote.request.from; const depositWalletAddress = computeDepositWalletAddress(from); @@ -199,7 +387,7 @@ export class PolymarketBridgeStrategy this.#buildRelayerApi(request.messenger), ); - log('Relayer confirmed, setting sourceHash', { + log('Polymarket relayer confirmed, setting sourceHash', { sourceHash: result.relayerTransactionHash, }); @@ -215,7 +403,83 @@ export class PolymarketBridgeStrategy }, ); - return { transactionHash: result.relayerTransactionHash }; + log('Polling bridge for target-side completion', { depositAddress }); + + const bridgeResult = + await this.#bridgeApi.pollUntilBridgeComplete(depositAddress); + + if (bridgeResult.status !== 'COMPLETED' || !bridgeResult.txHash) { + log('Bridge did not reach COMPLETED, returning source hash', { + status: bridgeResult.status, + }); + return { transactionHash: result.relayerTransactionHash }; + } + + log('Bridge COMPLETED', { targetHash: bridgeResult.txHash }); + + return { transactionHash: bridgeResult.txHash as Hex }; + } + + async #executeRelayBacked( + request: PayStrategyExecuteRequest, + relayQuote: RelayQuote, + ): Promise<{ transactionHash?: Hex }> { + const quote = request.quotes[0]; + const from = quote.request.from; + const depositWalletAddress = computeDepositWalletAddress(from); + + log('Step 1: transferring pUSD from deposit wallet to user EOA', { + depositWalletAddress, + recipient: from, + }); + + const step1 = await submitPolymarketBridgeWithdraw( + quote, + from, + depositWalletAddress, + from, + request.messenger, + this.#buildRelayerApi(request.messenger), + ); + + log('Step 1 confirmed, recording sourceHash', { + sourceHash: step1.relayerTransactionHash, + }); + + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Add source hash from Polymarket relayer (deposit→EOA transfer)', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = step1.relayerTransactionHash; + }, + ); + + log('Step 2: submitting Relay quote from user EOA'); + + const relayTransactionPayQuote = buildRelayTransactionPayQuote({ + relayQuote, + quote, + }); + + const strippedTransaction = stripOriginalTxForRelayBatch( + request.transaction, + ); + + const targetHash = await submitRelayQuotes({ + quotes: [relayTransactionPayQuote], + messenger: request.messenger, + transaction: strippedTransaction, + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + }); + + log('Step 2 complete', { transactionHash: targetHash.transactionHash }); + + return targetHash; } async getBatchTransactions( @@ -234,9 +498,7 @@ export class PolymarketBridgeStrategy function calculateAmount( raw: string, decimals: number, - fiatRate: - | { fiatRate: string; usdRate: string } - | undefined, + fiatRate: { fiatRate: string; usdRate: string } | undefined, ): { fiat: string; human: string; raw: string; usd: string } { const humanValue = new BigNumber(raw).shiftedBy(-decimals); const human = humanValue.toString(10); @@ -250,3 +512,44 @@ function calculateAmount( return { fiat, human, raw, usd }; } + +function buildRelayTransactionPayQuote({ + relayQuote, + quote, +}: { + relayQuote: RelayQuote; + quote: TransactionPayQuote; +}): TransactionPayQuote { + const syntheticRequest: QuoteRequest = { + ...quote.request, + from: quote.request.from, + sourceChainId: POLYGON_CHAIN_ID, + sourceTokenAddress: PUSD_ADDRESS_POLYGON, + sourceTokenAmount: quote.sourceAmount.raw, + sourceBalanceRaw: quote.sourceAmount.raw, + isPostQuote: true, + isHyperliquidSource: false, + isPolymarketDepositWallet: false, + }; + + return { + ...quote, + original: relayQuote, + request: syntheticRequest, + strategy: TransactionPayStrategy.Relay, + }; +} + +function stripOriginalTxForRelayBatch( + transaction: TransactionMeta, +): TransactionMeta { + return { + ...transaction, + txParams: { + ...transaction.txParams, + to: undefined, + data: undefined, + value: undefined, + }, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts index 1d6b6bbdb2..8c7fd71999 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts @@ -172,8 +172,8 @@ export class PolymarketBridgeApi { async pollUntilBridgeComplete( depositAddress: string, - pollIntervalMs = 3000, - maxAttempts = 200, + pollIntervalMs = 10_000, + maxAttempts = 90, ): Promise { log('Polling bridge status', { depositAddress, maxAttempts }); diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index b2108da7ec..0f3b7ef25c 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -31,3 +31,18 @@ export const RELAYER_TERMINAL_STATES = [ // pUSD decimals (same as USDC) export const PUSD_DECIMALS = 6; + +/** + * Hardcoded experiment flag. When true, the Polymarket bridge strategy bypasses + * Polymarket's `/quote` + `/withdraw` flow and instead: + * 1. Fetches a Relay quote (pUSD on Polygon → target chain/token). + * 2. At execute time, transfers pUSD from the deposit wallet to the user EOA + * via the existing Polymarket relayer proxy (single ERC-20 transfer). + * 3. Submits the stored Relay quote from the user EOA, gaslessly via Relay's + * /execute endpoint. + * + * Lets us avoid Polymarket-bridge minimums, fees, and the source-vs-target + * txHash ambiguity in their `/status` endpoint. Toggle to false to fall back to + * the original Polymarket bridge flow. + */ +export const USE_RELAY_BRIDGE = true; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts index 4e546d80df..4eb6973596 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts @@ -1,5 +1,7 @@ import type { Hex } from '@metamask/utils'; +import type { RelayQuote } from '../relay/types'; + /** Quote returned by Polymarket Bridge /quote endpoint. */ export type PolymarketBridgeQuote = { /** Unique quote identifier. */ @@ -16,6 +18,13 @@ export type PolymarketBridgeQuote = { estCheckoutTimeMs: number; /** Fee breakdown from Polymarket (typically all zero for pUSD→USDC). */ estFeeBreakdown: PolymarketBridgeFeeBreakdown; + /** + * When the USE_RELAY_BRIDGE flag is on, the Relay quote fetched at + * getQuotes time and replayed at execute time after the deposit-wallet + * transfers pUSD to the user EOA. Absent in the legacy Polymarket bridge + * flow. + */ + relayQuote?: RelayQuote; }; /** Fee breakdown from Bridge /quote response. */ From 89a3fb3e71f50a37ffbdc09f69073b91ccbcf868 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 22:18:38 +0100 Subject: [PATCH 12/16] feat(transaction-pay-controller): Polymarket bridge withdraw via Relay deposit address with USDC.e sweep The deposit wallet now unwraps pUSD directly into USDC.e at Relay's one-shot deposit address in a single relayer-broadcast batch (approve + unwrap). After Relay settles, the deposit wallet's USDC.e balance is read live from RPC and any remainder is wrapped back into pUSD via the CollateralOnramp, preserving the deposit-wallet pUSD invariant on partial fills, refunds, or solver failures. Implementation notes: - New flags USE_RELAY_DEPOSIT_ADDRESS + FORCE_SKIP_RELAY_POLL gate the new flow and the in-flight test shortcut. - Relay status poll now treats 'refund' as in-flight (the refund tx has not yet confirmed); only 'refunded'/'failure'/'success' are terminal. - submitWithBusyRetry retries the wrap submission when the Polymarket relayer reports the deposit wallet still has an active action; the retry matches against the message text, not a typed error class, so it catches both proxy-wrapped and direct relayer errors. - relayer-api now reads the JSON body on non-OK HTTP responses and surfaces the relayer's actual 'error'/'message' field, so callers can branch on the real reason instead of a generic status-code wrapper. --- .../PolymarketBridgeStrategy.ts | 456 ++++++++++++++++-- .../strategy/polymarket-bridge/constants.ts | 26 + .../strategy/polymarket-bridge/relayer-api.ts | 46 +- .../strategy/polymarket-bridge/withdraw.ts | 55 ++- .../src/strategy/relay/types.ts | 5 + 5 files changed, 532 insertions(+), 56 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts index 7222736043..0337d9d942 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts @@ -23,22 +23,30 @@ import { isEIP7702Chain, isRelayExecuteEnabled, } from '../../utils/feature-flags'; -import { getTokenFiatRate } from '../../utils/token'; +import { getLiveTokenBalance, getTokenFiatRate } from '../../utils/token'; import { updateTransaction } from '../../utils/transaction'; -import { fetchRelayQuote } from '../relay/relay-api'; +import { fetchRelayQuote, getRelayStatus } from '../relay/relay-api'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote, RelayQuoteRequest } from '../relay/types'; import { PolymarketBridgeApi } from './bridge-api'; import { + FORCE_SKIP_RELAY_POLL, + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, PUSD_ADDRESS_POLYGON, PUSD_DECIMALS, + USDC_E_ADDRESS_POLYGON, USE_RELAY_BRIDGE, + USE_RELAY_DEPOSIT_ADDRESS, } from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; import { extractPolymarketWithdrawIntent } from './intent'; import { PolymarketRelayerApi } from './relayer-api'; import type { PolymarketBridgeQuote } from './types'; -import { submitPolymarketBridgeWithdraw } from './withdraw'; +import { + submitDepositWalletBatch, + submitPolymarketBridgeWithdraw, +} from './withdraw'; const POLYGON_CHAIN_ID = '0x89' as Hex; const POLYGON_CHAIN_ID_NUMBER = 137; @@ -133,21 +141,73 @@ export class PolymarketBridgeStrategy const { messenger, accountSupports7702 } = request; const depositWalletAddress = computeDepositWalletAddress(quoteRequest.from); + const body = USE_RELAY_DEPOSIT_ADDRESS + ? this.#buildRelayDepositAddressRequest({ + intent, + quoteRequest, + depositWalletAddress, + messenger, + }) + : this.#buildRelayEoaRequest({ + intent, + quoteRequest, + depositWalletAddress, + accountSupports7702, + messenger, + }); + + log('Fetching Relay quote', { + originCurrency: body.originCurrency, + destinationChainId: body.destinationChainId, + destinationCurrency: body.destinationCurrency, + amount: body.amount, + useDepositAddress: USE_RELAY_DEPOSIT_ADDRESS, + }); + + const relayQuote = await fetchRelayQuote(messenger, body, request.signal); + + log('Relay quote fetched', { + currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, + totalImpactUsd: relayQuote.details.totalImpact.usd, + isExecute: relayQuote.metamask?.isExecute, + stepCount: relayQuote.steps.length, + }); + + const quote = this.#buildRelayBackedQuote({ + relayQuote, + intent, + messenger, + quoteRequest, + }); + + return [quote]; + } + + #buildRelayEoaRequest({ + intent, + quoteRequest, + depositWalletAddress, + accountSupports7702, + messenger, + }: { + intent: { amount: bigint }; + quoteRequest: QuoteRequest; + depositWalletAddress: Hex; + accountSupports7702: boolean; + messenger: TransactionPayControllerMessenger; + }): RelayQuoteRequest { const useExecute = accountSupports7702 && isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, POLYGON_CHAIN_ID); - const slippageDecimal = getSlippage( - messenger, - POLYGON_CHAIN_ID, - PUSD_ADDRESS_POLYGON, - ); const slippageTolerance = new BigNumber( - slippageDecimal * 100 * 100, + getSlippage(messenger, POLYGON_CHAIN_ID, PUSD_ADDRESS_POLYGON) * + 100 * + 100, ).toFixed(0); - const body: RelayQuoteRequest = { + return { amount: intent.amount.toString(), destinationChainId: parseInt(quoteRequest.targetChainId, 16), destinationCurrency: quoteRequest.targetTokenAddress, @@ -162,31 +222,38 @@ export class PolymarketBridgeStrategy tradeType: 'EXACT_INPUT', user: quoteRequest.from, }; + } - log('Fetching Relay quote (pUSD→target)', { - destinationChainId: body.destinationChainId, - destinationCurrency: body.destinationCurrency, - amount: body.amount, - useExecute, - }); - - const relayQuote = await fetchRelayQuote(messenger, body, request.signal); - - log('Relay quote fetched', { - currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, - totalImpactUsd: relayQuote.details.totalImpact.usd, - isExecute: relayQuote.metamask?.isExecute, - stepCount: relayQuote.steps.length, - }); - - const quote = this.#buildRelayBackedQuote({ - relayQuote, - intent, - messenger, - quoteRequest, - }); + #buildRelayDepositAddressRequest({ + intent, + quoteRequest, + depositWalletAddress, + messenger, + }: { + intent: { amount: bigint }; + quoteRequest: QuoteRequest; + depositWalletAddress: Hex; + messenger: TransactionPayControllerMessenger; + }): RelayQuoteRequest { + const slippageTolerance = new BigNumber( + getSlippage(messenger, POLYGON_CHAIN_ID, USDC_E_ADDRESS_POLYGON) * + 100 * + 100, + ).toFixed(0); - return [quote]; + return { + amount: intent.amount.toString(), + destinationChainId: parseInt(quoteRequest.targetChainId, 16), + destinationCurrency: quoteRequest.targetTokenAddress, + originChainId: POLYGON_CHAIN_ID_NUMBER, + originCurrency: USDC_E_ADDRESS_POLYGON, + recipient: quoteRequest.from, + refundTo: depositWalletAddress, + slippageTolerance, + tradeType: 'EXACT_INPUT', + useDepositAddress: true, + user: depositWalletAddress, + }; } #buildPolymarketBridgeQuote({ @@ -424,6 +491,10 @@ export class PolymarketBridgeStrategy request: PayStrategyExecuteRequest, relayQuote: RelayQuote, ): Promise<{ transactionHash?: Hex }> { + if (USE_RELAY_DEPOSIT_ADDRESS) { + return await this.#executeRelayDepositAddress(request, relayQuote); + } + const quote = request.quotes[0]; const from = quote.request.from; const depositWalletAddress = computeDepositWalletAddress(from); @@ -482,6 +553,188 @@ export class PolymarketBridgeStrategy return targetHash; } + async #executeRelayDepositAddress( + request: PayStrategyExecuteRequest, + relayQuote: RelayQuote, + ): Promise<{ transactionHash?: Hex }> { + const quote = request.quotes[0]; + const from = quote.request.from; + const depositWalletAddress = computeDepositWalletAddress(from); + + const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); + + if (!depositStep || depositStep.kind !== 'transaction') { + throw new Error( + 'Polymarket bridge (Relay deposit-address): no deposit step found', + ); + } + + const depositItemData = depositStep.items[0]?.data; + const depositCallData = + depositItemData && 'data' in depositItemData + ? depositItemData.data + : undefined; + + if (!depositCallData) { + throw new Error( + 'Polymarket bridge (Relay deposit-address): missing deposit calldata', + ); + } + + const relayDepositAddress = extractTransferRecipient(depositCallData); + const amount = BigInt(quote.sourceAmount.raw); + + log('Building approve + unwrap batch', { + depositWalletAddress, + relayDepositAddress, + amount: amount.toString(), + }); + + const approveData = encodeApproveCalldata( + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + amount, + ); + const unwrapData = encodeUnwrapCalldata({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: relayDepositAddress, + amount, + }); + + const result = await submitDepositWalletBatch({ + from, + depositWalletAddress, + calls: [ + { + target: PUSD_ADDRESS_POLYGON, + value: 0n, + data: approveData, + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: 0n, + data: unwrapData, + }, + ], + messenger: request.messenger, + relayerApi: this.#buildRelayerApi(request.messenger), + }); + + log('Relayer batch confirmed, setting sourceHash', { + sourceHash: result.relayerTransactionHash, + }); + + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Add source hash from Polymarket relayer (approve+unwrap batch)', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = result.relayerTransactionHash; + }, + ); + + const requestId = depositStep.requestId; + + const relayOutcome = FORCE_SKIP_RELAY_POLL + ? ({ kind: 'skipped' } as const) + : await pollRelayStatusUntilTerminal(requestId); + + if (FORCE_SKIP_RELAY_POLL) { + log('FORCE_SKIP_RELAY_POLL is true: skipping Relay status poll'); + } else { + log('Relay polling complete', { kind: relayOutcome.kind }); + } + + await this.#wrapDepositWalletUsdce({ + request, + depositWalletAddress, + from, + }); + + if (relayOutcome.kind === 'success') { + return { transactionHash: relayOutcome.targetHash }; + } + + return { transactionHash: result.relayerTransactionHash }; + } + + async #wrapDepositWalletUsdce({ + request, + depositWalletAddress, + from, + }: { + request: PayStrategyExecuteRequest; + depositWalletAddress: Hex; + from: Hex; + }): Promise { + let usdceBalance: bigint; + try { + const raw = await getLiveTokenBalance( + request.messenger, + depositWalletAddress, + POLYGON_CHAIN_ID, + USDC_E_ADDRESS_POLYGON, + ); + usdceBalance = BigInt(raw); + } catch (error) { + log('USDC.e sweep: failed to read deposit wallet balance', { error }); + return; + } + + log('USDC.e sweep: deposit wallet balance', { + depositWalletAddress, + balance: usdceBalance.toString(), + }); + + if (usdceBalance === 0n) { + log('USDC.e sweep: nothing to wrap'); + return; + } + + log('USDC.e sweep: submitting approve + wrap batch', { + amount: usdceBalance.toString(), + }); + + const approveData = encodeApproveCalldata( + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + usdceBalance, + ); + const wrapData = encodeWrapCalldata({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: depositWalletAddress, + amount: usdceBalance, + }); + + try { + const result = await submitWithBusyRetry({ + from, + depositWalletAddress, + calls: [ + { + target: USDC_E_ADDRESS_POLYGON, + value: 0n, + data: approveData, + }, + { + target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + value: 0n, + data: wrapData, + }, + ], + messenger: request.messenger, + relayerApi: this.#buildRelayerApi(request.messenger), + }); + + log('USDC.e sweep: complete', { + transactionHash: result.relayerTransactionHash, + }); + } catch (error) { + log('USDC.e sweep: batch submission failed', { error }); + } + } + async getBatchTransactions( _request: PayStrategyGetBatchRequest, ): Promise<[]> { @@ -553,3 +806,140 @@ function stripOriginalTxForRelayBatch( }, }; } + +function extractTransferRecipient(data: Hex): Hex { + const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; + if (!data.startsWith(ERC20_TRANSFER_SELECTOR)) { + throw new Error( + `Expected ERC-20 transfer calldata, got selector ${data.slice(0, 10)}`, + ); + } + return `0x${data.slice(34, 74)}` as Hex; +} + +function encodeApproveCalldata(spender: Hex, amount: bigint): Hex { + const selector = '095ea7b3'; + const paddedAddress = spender.slice(2).toLowerCase().padStart(64, '0'); + const paddedAmount = amount.toString(16).padStart(64, '0'); + return `0x${selector}${paddedAddress}${paddedAmount}` as Hex; +} + +function encodeUnwrapCalldata({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + const selector = '8cc7104f'; + const paddedAsset = asset.slice(2).toLowerCase().padStart(64, '0'); + const paddedRecipient = recipient.slice(2).toLowerCase().padStart(64, '0'); + const paddedAmount = amount.toString(16).padStart(64, '0'); + return `0x${selector}${paddedAsset}${paddedRecipient}${paddedAmount}` as Hex; +} + +const RELAY_STATUS_POLL_INTERVAL_MS = 5_000; +const RELAY_STATUS_POLL_MAX_ATTEMPTS = 120; + +type RelayPollOutcome = + | { kind: 'success'; targetHash: Hex } + | { kind: 'refunded' } + | { kind: 'failure' } + | { kind: 'timeout' }; + +async function pollRelayStatusUntilTerminal( + requestId: string, +): Promise { + for (let attempt = 0; attempt < RELAY_STATUS_POLL_MAX_ATTEMPTS; attempt++) { + try { + const status = await getRelayStatus(requestId); + log('Relay status', { + attempt, + status: status.status, + txHashes: status.txHashes, + }); + + if (status.status === 'success' && status.txHashes?.length) { + return { + kind: 'success', + targetHash: status.txHashes[status.txHashes.length - 1] as Hex, + }; + } + + if (status.status === 'refunded') { + return { kind: 'refunded' }; + } + + if (status.status === 'failure') { + return { kind: 'failure' }; + } + } catch (error) { + log('Relay status poll error', { attempt, error }); + } + + await new Promise((resolve) => + setTimeout(resolve, RELAY_STATUS_POLL_INTERVAL_MS), + ); + } + + return { kind: 'timeout' }; +} + +const WALLET_BUSY_RETRY_ATTEMPTS = 5; +const WALLET_BUSY_RETRY_DELAY_MS = 3_000; + +async function submitWithBusyRetry( + args: Parameters[0], +): Promise<{ relayerTransactionHash: Hex }> { + let lastError: unknown; + + for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { + try { + return await submitDepositWalletBatch(args); + } catch (error) { + lastError = error; + + const message = + (error instanceof Error ? error.message : String(error)) ?? ''; + const isWalletBusy = + message.toLowerCase().includes('wallet busy') || + message.toLowerCase().includes('active action'); + + log('submitWithBusyRetry caught error', { + attempt, + isWalletBusy, + errorName: (error as Error)?.name, + message, + }); + + if (!isWalletBusy || attempt === WALLET_BUSY_RETRY_ATTEMPTS) { + throw error; + } + + log('Wallet busy, retrying', { attempt, delayMs: WALLET_BUSY_RETRY_DELAY_MS }); + await new Promise((resolve) => + setTimeout(resolve, WALLET_BUSY_RETRY_DELAY_MS), + ); + } + } + + throw lastError; +} + +function encodeWrapCalldata({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + const selector = '62355638'; + const paddedAsset = asset.slice(2).toLowerCase().padStart(64, '0'); + const paddedRecipient = recipient.slice(2).toLowerCase().padStart(64, '0'); + const paddedAmount = amount.toString(16).padStart(64, '0'); + return `0x${selector}${paddedAsset}${paddedRecipient}${paddedAmount}` as Hex; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index 0f3b7ef25c..a63791b1ca 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -46,3 +46,29 @@ export const PUSD_DECIMALS = 6; * the original Polymarket bridge flow. */ export const USE_RELAY_BRIDGE = true; + +/** + * Experimental flag layered on top of USE_RELAY_BRIDGE. When both flags are + * true, the deposit wallet unwraps pUSD directly into USDC.e at Relay's + * one-shot deposit address in a single relayer-broadcast batch (approve + + * unwrap), skipping the EOA leg entirely. Requires the deposit wallet to be + * able to call the Polymarket CollateralOfframp. + */ +export const USE_RELAY_DEPOSIT_ADDRESS = true; + +export const USDC_E_ADDRESS_POLYGON = + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174' as Hex; + +export const POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = + '0x2957922Eb93258b93368531d39fAcCA3B4dC5854' as Hex; + +export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = + '0x93070a847efEf7F70739046A929D47a521F5B8ee' as Hex; + +/** + * TEMPORARY testing flag. When true, the Relay-deposit-address flow skips + * polling the Relay /intents/status endpoint entirely so the wrap-back + * sweep flow can be exercised quickly with USDC.e manually loaded onto the + * deposit wallet. + */ +export const FORCE_SKIP_RELAY_POLL = true; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts index 6c8f37a300..fc8c180944 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts @@ -1,4 +1,3 @@ -import { successfulFetch } from '@metamask/controller-utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../logger'; @@ -125,7 +124,7 @@ export class PolymarketRelayerApi { let response: Response; try { - response = await successfulFetch(url, { + response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(envelope), @@ -140,21 +139,44 @@ export class PolymarketRelayerApi { const text = await response.text(); - if (!text) { + let parsed: unknown; + if (text) { + try { + parsed = JSON.parse(text); + } catch (error) { + if (!response.ok) { + throw new PolymarketRelayerError( + `Relayer proxy returned ${response.status} with non-JSON body`, + 'HTTP_ERROR', + error, + ); + } + throw new PolymarketRelayerError( + 'Relayer proxy returned malformed JSON', + 'MALFORMED_JSON', + error, + ); + } + } + + if (!response.ok) { + const detail = + typeof parsed === 'object' && parsed !== null + ? (parsed as { error?: string; message?: string }).error ?? + (parsed as { error?: string; message?: string }).message + : undefined; + throw new PolymarketRelayerError( - 'Relayer proxy returned an empty response', - 'EMPTY_RESPONSE', + detail ?? `Relayer proxy returned status ${response.status}`, + 'PROXY_ERROR', + parsed, ); } - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { + if (parsed === undefined) { throw new PolymarketRelayerError( - 'Relayer proxy returned malformed JSON', - 'MALFORMED_JSON', - error, + 'Relayer proxy returned an empty response', + 'EMPTY_RESPONSE', ); } diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts index 6bd02b7f8a..9709ff210a 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts @@ -46,9 +46,6 @@ export async function submitPolymarketBridgeWithdraw( ): Promise<{ relayerTransactionHash: Hex }> { const { fromAmount } = quote.original; - log('Fetching wallet nonce', { from }); - const nonce = await relayerApi.getNonce(from, 'WALLET'); - const amount = BigInt(fromAmount); const transferCalldata = encodeTransferCalldata(bridgeDepositAddress, amount); @@ -58,13 +55,49 @@ export async function submitPolymarketBridgeWithdraw( amount: amount.toString(), }); - const calls = [ - { - target: PUSD_ADDRESS_POLYGON, - value: 0n, - data: transferCalldata, - }, - ]; + return await submitDepositWalletBatch({ + from, + depositWalletAddress, + calls: [ + { + target: PUSD_ADDRESS_POLYGON, + value: 0n, + data: transferCalldata, + }, + ], + messenger, + relayerApi, + }); +} + +/** + * Submit an arbitrary batch of calls from a Polymarket deposit wallet via the + * existing relayer proxy. Handles nonce fetch, EIP-712 signing, submission, + * and polling to terminal state. + * + * @param options - Submission options. + * @param options.from - The owner EOA of the deposit wallet. + * @param options.depositWalletAddress - The deposit wallet address. + * @param options.calls - Calls to execute in the batch. + * @param options.messenger - Controller messenger for signing. + * @param options.relayerApi - Authenticated relayer API client. + * @returns The relayer's on-chain transaction hash. + */ +export async function submitDepositWalletBatch({ + from, + depositWalletAddress, + calls, + messenger, + relayerApi, +}: { + from: Hex; + depositWalletAddress: Hex; + calls: { target: Hex; value: bigint; data: Hex }[]; + messenger: TransactionPayControllerMessenger; + relayerApi: PolymarketRelayerApi; +}): Promise<{ relayerTransactionHash: Hex }> { + log('Fetching wallet nonce', { from }); + const nonce = await relayerApi.getNonce(from, 'WALLET'); const deadline = Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; @@ -77,7 +110,7 @@ export async function submitPolymarketBridgeWithdraw( chainId: CHAIN_ID_POLYGON, }); - log('Signing Batch via EIP-712', { nonce, deadline }); + log('Signing Batch via EIP-712', { nonce, deadline, callCount: calls.length }); const signature = await messenger.call( 'KeyringController:signTypedMessage', diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index c12b886b1a..ea75599b72 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -26,6 +26,11 @@ export type RelayQuoteRequest = { data: Hex; value: Hex; }[]; + /** + * Request a single-step "send to deposit address" routing. Only supported + * by Relay for major tokens; rejected otherwise. + */ + useDepositAddress?: boolean; user: Hex; }; From 85888dccdd9969371d4608e186ee6a5022568818 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 23:29:51 +0100 Subject: [PATCH 13/16] refactor(transaction-pay-controller): split PolymarketStrategy into quotes + submit modules Reorganises strategy/polymarket-bridge/ to match the layout of the other strategies (across, relay) and removes the dead code from the prior experimental phases: - PolymarketBridgeStrategy class renamed to PolymarketStrategy, file renamed to match. Class shrinks from 945 to 43 lines and delegates to the two new modules. - New polymarket-quotes.ts owns Relay quote fetch + TransactionPayQuote normalisation. - New polymarket-submit.ts owns the deposit-wallet batched approve + unwrap to Relay's deposit address, Relay status polling, the USDC.e sweep (approve + wrap back to pUSD), the deposit-wallet batch transport with EIP-712 signing + relayer submission, and the 'wallet busy' retry. - New polymarket-calldata.ts owns the ABI encoders for approve, unwrap, wrap, and the ERC-20 transfer-recipient extractor. - Removed bridge-api.ts (no longer needed - Polymarket's own bridge API is no longer called). - Removed withdraw.ts (its surface moved into polymarket-submit.ts as submitDepositWalletBatch with retry collapsed into the same function). - Removed dead flags USE_RELAY_BRIDGE, USE_RELAY_DEPOSIT_ADDRESS, FORCE_SKIP_RELAY_POLL and the legacy execute branches they gated. - PolymarketBridgeQuote slimmed to { relayQuote } - the wrapper exists only so the strategy can carry a typed quote through the controller. --- .../transaction-pay-controller/src/index.ts | 2 +- .../PolymarketBridgeStrategy.ts | 945 ------------------ .../polymarket-bridge/PolymarketStrategy.ts | 43 + .../strategy/polymarket-bridge/bridge-api.ts | 308 ------ .../strategy/polymarket-bridge/constants.ts | 62 +- .../polymarket-bridge/polymarket-calldata.ts | 64 ++ .../polymarket-bridge/polymarket-quotes.ts | 186 ++++ .../polymarket-bridge/polymarket-submit.ts | 453 +++++++++ .../src/strategy/polymarket-bridge/types.ts | 83 +- .../strategy/polymarket-bridge/withdraw.ts | 194 ---- .../src/utils/strategy.ts | 4 +- 11 files changed, 766 insertions(+), 1578 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts create mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 79f4d7e66c..f5896500c3 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -30,5 +30,5 @@ export { TransactionPayStrategy } from './constants'; export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; -export { PolymarketBridgeStrategy } from './strategy/polymarket-bridge/PolymarketBridgeStrategy'; +export { PolymarketStrategy } from './strategy/polymarket-bridge/PolymarketStrategy'; export type { PolymarketBridgeQuote } from './strategy/polymarket-bridge/types'; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts deleted file mode 100644 index 0337d9d942..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketBridgeStrategy.ts +++ /dev/null @@ -1,945 +0,0 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; - -import { TransactionPayStrategy } from '../../constants'; -import { projectLogger } from '../../logger'; -import type { - PayStrategy, - PayStrategyExecuteRequest, - PayStrategyGetBatchRequest, - PayStrategyGetQuotesRequest, - PayStrategyGetRefreshIntervalRequest, - TransactionPayControllerMessenger, - TransactionPayQuote, - QuoteRequest, -} from '../../types'; -import { getFiatValueFromUsd } from '../../utils/amounts'; -import { - getPolymarketRelayerUrl, - getRelayOriginGasOverhead, - getSlippage, - isEIP7702Chain, - isRelayExecuteEnabled, -} from '../../utils/feature-flags'; -import { getLiveTokenBalance, getTokenFiatRate } from '../../utils/token'; -import { updateTransaction } from '../../utils/transaction'; -import { fetchRelayQuote, getRelayStatus } from '../relay/relay-api'; -import { submitRelayQuotes } from '../relay/relay-submit'; -import type { RelayQuote, RelayQuoteRequest } from '../relay/types'; -import { PolymarketBridgeApi } from './bridge-api'; -import { - FORCE_SKIP_RELAY_POLL, - POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, - POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - PUSD_ADDRESS_POLYGON, - PUSD_DECIMALS, - USDC_E_ADDRESS_POLYGON, - USE_RELAY_BRIDGE, - USE_RELAY_DEPOSIT_ADDRESS, -} from './constants'; -import { computeDepositWalletAddress } from './deposit-wallet'; -import { extractPolymarketWithdrawIntent } from './intent'; -import { PolymarketRelayerApi } from './relayer-api'; -import type { PolymarketBridgeQuote } from './types'; -import { - submitDepositWalletBatch, - submitPolymarketBridgeWithdraw, -} from './withdraw'; - -const POLYGON_CHAIN_ID = '0x89' as Hex; -const POLYGON_CHAIN_ID_NUMBER = 137; - -const log = createModuleLogger(projectLogger, 'polymarket-bridge-strategy'); - -const REFRESH_INTERVAL_MS = 25_000; - -export class PolymarketBridgeStrategy - implements PayStrategy -{ - readonly #bridgeApi: PolymarketBridgeApi = new PolymarketBridgeApi(); - - #buildRelayerApi( - messenger: TransactionPayControllerMessenger, - ): PolymarketRelayerApi { - return new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); - } - - supports(_request: PayStrategyGetQuotesRequest): boolean { - return true; - } - - async getQuotes( - request: PayStrategyGetQuotesRequest, - ): Promise[]> { - const intent = extractPolymarketWithdrawIntent(request.transaction); - - if (!intent) { - return []; - } - - const quoteRequest = request.requests[0]; - - if (!quoteRequest) { - return []; - } - - if (USE_RELAY_BRIDGE) { - return await this.#getRelayBackedQuote({ request, intent, quoteRequest }); - } - - return await this.#getPolymarketBridgeQuote({ - request, - intent, - quoteRequest, - }); - } - - async #getPolymarketBridgeQuote({ - request, - intent, - quoteRequest, - }: { - request: PayStrategyGetQuotesRequest; - intent: { amount: bigint }; - quoteRequest: QuoteRequest; - }): Promise[]> { - const bridgeQuote = await this.#bridgeApi.getQuote({ - fromAmountBaseUnit: intent.amount.toString(), - fromChainId: '137', - fromTokenAddress: PUSD_ADDRESS_POLYGON.toLowerCase(), - recipientAddress: quoteRequest.from, - toChainId: parseInt(quoteRequest.targetChainId, 16).toString(), - toTokenAddress: quoteRequest.targetTokenAddress.toLowerCase(), - }); - - const quote = this.#buildPolymarketBridgeQuote({ - bridgeQuote, - intent, - messenger: request.messenger, - quoteRequest, - }); - - log('Polymarket bridge quote built', { - quoteId: bridgeQuote.quoteId, - providerUsd: quote.fees.provider.usd, - }); - - return [quote]; - } - - async #getRelayBackedQuote({ - request, - intent, - quoteRequest, - }: { - request: PayStrategyGetQuotesRequest; - intent: { amount: bigint }; - quoteRequest: QuoteRequest; - }): Promise[]> { - const { messenger, accountSupports7702 } = request; - const depositWalletAddress = computeDepositWalletAddress(quoteRequest.from); - - const body = USE_RELAY_DEPOSIT_ADDRESS - ? this.#buildRelayDepositAddressRequest({ - intent, - quoteRequest, - depositWalletAddress, - messenger, - }) - : this.#buildRelayEoaRequest({ - intent, - quoteRequest, - depositWalletAddress, - accountSupports7702, - messenger, - }); - - log('Fetching Relay quote', { - originCurrency: body.originCurrency, - destinationChainId: body.destinationChainId, - destinationCurrency: body.destinationCurrency, - amount: body.amount, - useDepositAddress: USE_RELAY_DEPOSIT_ADDRESS, - }); - - const relayQuote = await fetchRelayQuote(messenger, body, request.signal); - - log('Relay quote fetched', { - currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, - totalImpactUsd: relayQuote.details.totalImpact.usd, - isExecute: relayQuote.metamask?.isExecute, - stepCount: relayQuote.steps.length, - }); - - const quote = this.#buildRelayBackedQuote({ - relayQuote, - intent, - messenger, - quoteRequest, - }); - - return [quote]; - } - - #buildRelayEoaRequest({ - intent, - quoteRequest, - depositWalletAddress, - accountSupports7702, - messenger, - }: { - intent: { amount: bigint }; - quoteRequest: QuoteRequest; - depositWalletAddress: Hex; - accountSupports7702: boolean; - messenger: TransactionPayControllerMessenger; - }): RelayQuoteRequest { - const useExecute = - accountSupports7702 && - isRelayExecuteEnabled(messenger) && - isEIP7702Chain(messenger, POLYGON_CHAIN_ID); - - const slippageTolerance = new BigNumber( - getSlippage(messenger, POLYGON_CHAIN_ID, PUSD_ADDRESS_POLYGON) * - 100 * - 100, - ).toFixed(0); - - return { - amount: intent.amount.toString(), - destinationChainId: parseInt(quoteRequest.targetChainId, 16), - destinationCurrency: quoteRequest.targetTokenAddress, - originChainId: POLYGON_CHAIN_ID_NUMBER, - originCurrency: PUSD_ADDRESS_POLYGON, - ...(useExecute - ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } - : {}), - recipient: quoteRequest.from, - refundTo: depositWalletAddress, - slippageTolerance, - tradeType: 'EXACT_INPUT', - user: quoteRequest.from, - }; - } - - #buildRelayDepositAddressRequest({ - intent, - quoteRequest, - depositWalletAddress, - messenger, - }: { - intent: { amount: bigint }; - quoteRequest: QuoteRequest; - depositWalletAddress: Hex; - messenger: TransactionPayControllerMessenger; - }): RelayQuoteRequest { - const slippageTolerance = new BigNumber( - getSlippage(messenger, POLYGON_CHAIN_ID, USDC_E_ADDRESS_POLYGON) * - 100 * - 100, - ).toFixed(0); - - return { - amount: intent.amount.toString(), - destinationChainId: parseInt(quoteRequest.targetChainId, 16), - destinationCurrency: quoteRequest.targetTokenAddress, - originChainId: POLYGON_CHAIN_ID_NUMBER, - originCurrency: USDC_E_ADDRESS_POLYGON, - recipient: quoteRequest.from, - refundTo: depositWalletAddress, - slippageTolerance, - tradeType: 'EXACT_INPUT', - useDepositAddress: true, - user: depositWalletAddress, - }; - } - - #buildPolymarketBridgeQuote({ - bridgeQuote, - intent, - messenger, - quoteRequest, - }: { - bridgeQuote: PolymarketBridgeQuote; - intent: { amount: bigint }; - messenger: TransactionPayControllerMessenger; - quoteRequest: QuoteRequest; - }): TransactionPayQuote { - const sourceFiatRate = getTokenFiatRate( - messenger, - PUSD_ADDRESS_POLYGON, - POLYGON_CHAIN_ID, - ); - - const targetFiatRate = - getTokenFiatRate( - messenger, - quoteRequest.targetTokenAddress, - quoteRequest.targetChainId, - ) ?? sourceFiatRate; - - const usdToFiatRate = - sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) - ? new BigNumber(sourceFiatRate.fiatRate).dividedBy( - sourceFiatRate.usdRate, - ) - : new BigNumber(1); - - const sourceAmount = calculateAmount( - intent.amount.toString(), - PUSD_DECIMALS, - sourceFiatRate, - ); - - const targetAmount = calculateAmount( - bridgeQuote.toAmount, - PUSD_DECIMALS, - targetFiatRate, - ); - - const providerUsd = new BigNumber(bridgeQuote.estFeeBreakdown.gasUsd) - .plus(bridgeQuote.estFeeBreakdown.appFeeUsd) - .plus(bridgeQuote.estFeeBreakdown.swapImpactUsd); - - const provider = getFiatValueFromUsd(providerUsd, usdToFiatRate); - - return { - original: bridgeQuote, - fees: { - metaMask: { fiat: '0', usd: '0' }, - provider, - sourceNetwork: { - estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, - max: { fiat: '0', usd: '0', human: '0', raw: '0' }, - }, - targetNetwork: { fiat: '0', usd: '0' }, - }, - sourceAmount, - targetAmount: { fiat: targetAmount.fiat, usd: targetAmount.usd }, - dust: { fiat: '0', usd: '0' }, - estimatedDuration: bridgeQuote.estCheckoutTimeMs / 1000, - strategy: TransactionPayStrategy.PolymarketBridge, - request: quoteRequest, - }; - } - - #buildRelayBackedQuote({ - relayQuote, - intent, - messenger, - quoteRequest, - }: { - relayQuote: RelayQuote; - intent: { amount: bigint }; - messenger: TransactionPayControllerMessenger; - quoteRequest: QuoteRequest; - }): TransactionPayQuote { - const sourceFiatRate = getTokenFiatRate( - messenger, - PUSD_ADDRESS_POLYGON, - POLYGON_CHAIN_ID, - ); - - const usdToFiatRate = - sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) - ? new BigNumber(sourceFiatRate.fiatRate).dividedBy( - sourceFiatRate.usdRate, - ) - : new BigNumber(1); - - const sourceAmount = calculateAmount( - intent.amount.toString(), - PUSD_DECIMALS, - sourceFiatRate, - ); - - const targetAmountUsd = new BigNumber( - relayQuote.details.currencyOut.amountUsd, - ); - const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); - - const providerFeeUsd = new BigNumber( - relayQuote.fees.relayer?.amountUsd ?? '0', - ).plus(relayQuote.fees.app?.amountUsd ?? '0'); - const provider = getFiatValueFromUsd(providerFeeUsd, usdToFiatRate); - - const stub: PolymarketBridgeQuote = { - quoteId: relayQuote.steps[0]?.requestId ?? '', - bridgeDepositAddress: null, - fromAmount: intent.amount.toString(), - toAmount: relayQuote.details.currencyOut.amount, - minReceived: relayQuote.details.currencyOut.minimumAmount, - estCheckoutTimeMs: (relayQuote.details.timeEstimate ?? 30) * 1000, - estFeeBreakdown: { - gasUsd: 0, - appFeeUsd: Number(relayQuote.fees.app?.amountUsd ?? '0'), - swapImpactUsd: 0, - }, - relayQuote, - }; - - return { - original: stub, - fees: { - metaMask: { fiat: '0', usd: '0' }, - provider, - sourceNetwork: { - estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, - max: { fiat: '0', usd: '0', human: '0', raw: '0' }, - }, - targetNetwork: { fiat: '0', usd: '0' }, - }, - sourceAmount, - targetAmount, - dust: { fiat: '0', usd: '0' }, - estimatedDuration: relayQuote.details.timeEstimate ?? 30, - strategy: TransactionPayStrategy.PolymarketBridge, - request: quoteRequest, - }; - } - - async execute( - request: PayStrategyExecuteRequest, - ): Promise<{ transactionHash?: Hex }> { - const quote = request.quotes[0]; - - if (!quote) { - throw new Error('Polymarket bridge execute: no quote provided'); - } - - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Mark intent complete at Polymarket bridge execute start', - }, - (tx) => { - tx.isIntentComplete = true; - }, - ); - - if (quote.original.relayQuote) { - return await this.#executeRelayBacked(request, quote.original.relayQuote); - } - - return await this.#executePolymarketBridge(request); - } - - async #executePolymarketBridge( - request: PayStrategyExecuteRequest, - ): Promise<{ transactionHash?: Hex }> { - const quote = request.quotes[0]; - const from = quote.request.from; - const depositWalletAddress = computeDepositWalletAddress(from); - - log('Creating one-shot deposit address'); - - const depositAddress = await this.#bridgeApi.createWithdrawAddress({ - address: depositWalletAddress, - toChainId: parseInt(quote.request.targetChainId, 16).toString(), - toTokenAddress: quote.request.targetTokenAddress.toLowerCase(), - recipientAddr: from, - }); - - log('Deposit address created', { depositAddress }); - - const result = await submitPolymarketBridgeWithdraw( - quote, - from, - depositWalletAddress, - depositAddress, - request.messenger, - this.#buildRelayerApi(request.messenger), - ); - - log('Polymarket relayer confirmed, setting sourceHash', { - sourceHash: result.relayerTransactionHash, - }); - - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Add source hash from Polymarket relayer', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = result.relayerTransactionHash; - }, - ); - - log('Polling bridge for target-side completion', { depositAddress }); - - const bridgeResult = - await this.#bridgeApi.pollUntilBridgeComplete(depositAddress); - - if (bridgeResult.status !== 'COMPLETED' || !bridgeResult.txHash) { - log('Bridge did not reach COMPLETED, returning source hash', { - status: bridgeResult.status, - }); - return { transactionHash: result.relayerTransactionHash }; - } - - log('Bridge COMPLETED', { targetHash: bridgeResult.txHash }); - - return { transactionHash: bridgeResult.txHash as Hex }; - } - - async #executeRelayBacked( - request: PayStrategyExecuteRequest, - relayQuote: RelayQuote, - ): Promise<{ transactionHash?: Hex }> { - if (USE_RELAY_DEPOSIT_ADDRESS) { - return await this.#executeRelayDepositAddress(request, relayQuote); - } - - const quote = request.quotes[0]; - const from = quote.request.from; - const depositWalletAddress = computeDepositWalletAddress(from); - - log('Step 1: transferring pUSD from deposit wallet to user EOA', { - depositWalletAddress, - recipient: from, - }); - - const step1 = await submitPolymarketBridgeWithdraw( - quote, - from, - depositWalletAddress, - from, - request.messenger, - this.#buildRelayerApi(request.messenger), - ); - - log('Step 1 confirmed, recording sourceHash', { - sourceHash: step1.relayerTransactionHash, - }); - - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Add source hash from Polymarket relayer (deposit→EOA transfer)', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = step1.relayerTransactionHash; - }, - ); - - log('Step 2: submitting Relay quote from user EOA'); - - const relayTransactionPayQuote = buildRelayTransactionPayQuote({ - relayQuote, - quote, - }); - - const strippedTransaction = stripOriginalTxForRelayBatch( - request.transaction, - ); - - const targetHash = await submitRelayQuotes({ - quotes: [relayTransactionPayQuote], - messenger: request.messenger, - transaction: strippedTransaction, - accountSupports7702: request.accountSupports7702, - isSmartTransaction: request.isSmartTransaction, - }); - - log('Step 2 complete', { transactionHash: targetHash.transactionHash }); - - return targetHash; - } - - async #executeRelayDepositAddress( - request: PayStrategyExecuteRequest, - relayQuote: RelayQuote, - ): Promise<{ transactionHash?: Hex }> { - const quote = request.quotes[0]; - const from = quote.request.from; - const depositWalletAddress = computeDepositWalletAddress(from); - - const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); - - if (!depositStep || depositStep.kind !== 'transaction') { - throw new Error( - 'Polymarket bridge (Relay deposit-address): no deposit step found', - ); - } - - const depositItemData = depositStep.items[0]?.data; - const depositCallData = - depositItemData && 'data' in depositItemData - ? depositItemData.data - : undefined; - - if (!depositCallData) { - throw new Error( - 'Polymarket bridge (Relay deposit-address): missing deposit calldata', - ); - } - - const relayDepositAddress = extractTransferRecipient(depositCallData); - const amount = BigInt(quote.sourceAmount.raw); - - log('Building approve + unwrap batch', { - depositWalletAddress, - relayDepositAddress, - amount: amount.toString(), - }); - - const approveData = encodeApproveCalldata( - POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, - amount, - ); - const unwrapData = encodeUnwrapCalldata({ - asset: USDC_E_ADDRESS_POLYGON, - recipient: relayDepositAddress, - amount, - }); - - const result = await submitDepositWalletBatch({ - from, - depositWalletAddress, - calls: [ - { - target: PUSD_ADDRESS_POLYGON, - value: 0n, - data: approveData, - }, - { - target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, - value: 0n, - data: unwrapData, - }, - ], - messenger: request.messenger, - relayerApi: this.#buildRelayerApi(request.messenger), - }); - - log('Relayer batch confirmed, setting sourceHash', { - sourceHash: result.relayerTransactionHash, - }); - - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Add source hash from Polymarket relayer (approve+unwrap batch)', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = result.relayerTransactionHash; - }, - ); - - const requestId = depositStep.requestId; - - const relayOutcome = FORCE_SKIP_RELAY_POLL - ? ({ kind: 'skipped' } as const) - : await pollRelayStatusUntilTerminal(requestId); - - if (FORCE_SKIP_RELAY_POLL) { - log('FORCE_SKIP_RELAY_POLL is true: skipping Relay status poll'); - } else { - log('Relay polling complete', { kind: relayOutcome.kind }); - } - - await this.#wrapDepositWalletUsdce({ - request, - depositWalletAddress, - from, - }); - - if (relayOutcome.kind === 'success') { - return { transactionHash: relayOutcome.targetHash }; - } - - return { transactionHash: result.relayerTransactionHash }; - } - - async #wrapDepositWalletUsdce({ - request, - depositWalletAddress, - from, - }: { - request: PayStrategyExecuteRequest; - depositWalletAddress: Hex; - from: Hex; - }): Promise { - let usdceBalance: bigint; - try { - const raw = await getLiveTokenBalance( - request.messenger, - depositWalletAddress, - POLYGON_CHAIN_ID, - USDC_E_ADDRESS_POLYGON, - ); - usdceBalance = BigInt(raw); - } catch (error) { - log('USDC.e sweep: failed to read deposit wallet balance', { error }); - return; - } - - log('USDC.e sweep: deposit wallet balance', { - depositWalletAddress, - balance: usdceBalance.toString(), - }); - - if (usdceBalance === 0n) { - log('USDC.e sweep: nothing to wrap'); - return; - } - - log('USDC.e sweep: submitting approve + wrap batch', { - amount: usdceBalance.toString(), - }); - - const approveData = encodeApproveCalldata( - POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - usdceBalance, - ); - const wrapData = encodeWrapCalldata({ - asset: USDC_E_ADDRESS_POLYGON, - recipient: depositWalletAddress, - amount: usdceBalance, - }); - - try { - const result = await submitWithBusyRetry({ - from, - depositWalletAddress, - calls: [ - { - target: USDC_E_ADDRESS_POLYGON, - value: 0n, - data: approveData, - }, - { - target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - value: 0n, - data: wrapData, - }, - ], - messenger: request.messenger, - relayerApi: this.#buildRelayerApi(request.messenger), - }); - - log('USDC.e sweep: complete', { - transactionHash: result.relayerTransactionHash, - }); - } catch (error) { - log('USDC.e sweep: batch submission failed', { error }); - } - } - - async getBatchTransactions( - _request: PayStrategyGetBatchRequest, - ): Promise<[]> { - return []; - } - - async getRefreshInterval( - _request: PayStrategyGetRefreshIntervalRequest, - ): Promise { - return REFRESH_INTERVAL_MS; - } -} - -function calculateAmount( - raw: string, - decimals: number, - fiatRate: { fiatRate: string; usdRate: string } | undefined, -): { fiat: string; human: string; raw: string; usd: string } { - const humanValue = new BigNumber(raw).shiftedBy(-decimals); - const human = humanValue.toString(10); - - const usd = fiatRate - ? humanValue.multipliedBy(fiatRate.usdRate).toString(10) - : '0'; - const fiat = fiatRate - ? humanValue.multipliedBy(fiatRate.fiatRate).toString(10) - : '0'; - - return { fiat, human, raw, usd }; -} - -function buildRelayTransactionPayQuote({ - relayQuote, - quote, -}: { - relayQuote: RelayQuote; - quote: TransactionPayQuote; -}): TransactionPayQuote { - const syntheticRequest: QuoteRequest = { - ...quote.request, - from: quote.request.from, - sourceChainId: POLYGON_CHAIN_ID, - sourceTokenAddress: PUSD_ADDRESS_POLYGON, - sourceTokenAmount: quote.sourceAmount.raw, - sourceBalanceRaw: quote.sourceAmount.raw, - isPostQuote: true, - isHyperliquidSource: false, - isPolymarketDepositWallet: false, - }; - - return { - ...quote, - original: relayQuote, - request: syntheticRequest, - strategy: TransactionPayStrategy.Relay, - }; -} - -function stripOriginalTxForRelayBatch( - transaction: TransactionMeta, -): TransactionMeta { - return { - ...transaction, - txParams: { - ...transaction.txParams, - to: undefined, - data: undefined, - value: undefined, - }, - }; -} - -function extractTransferRecipient(data: Hex): Hex { - const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; - if (!data.startsWith(ERC20_TRANSFER_SELECTOR)) { - throw new Error( - `Expected ERC-20 transfer calldata, got selector ${data.slice(0, 10)}`, - ); - } - return `0x${data.slice(34, 74)}` as Hex; -} - -function encodeApproveCalldata(spender: Hex, amount: bigint): Hex { - const selector = '095ea7b3'; - const paddedAddress = spender.slice(2).toLowerCase().padStart(64, '0'); - const paddedAmount = amount.toString(16).padStart(64, '0'); - return `0x${selector}${paddedAddress}${paddedAmount}` as Hex; -} - -function encodeUnwrapCalldata({ - asset, - recipient, - amount, -}: { - asset: Hex; - recipient: Hex; - amount: bigint; -}): Hex { - const selector = '8cc7104f'; - const paddedAsset = asset.slice(2).toLowerCase().padStart(64, '0'); - const paddedRecipient = recipient.slice(2).toLowerCase().padStart(64, '0'); - const paddedAmount = amount.toString(16).padStart(64, '0'); - return `0x${selector}${paddedAsset}${paddedRecipient}${paddedAmount}` as Hex; -} - -const RELAY_STATUS_POLL_INTERVAL_MS = 5_000; -const RELAY_STATUS_POLL_MAX_ATTEMPTS = 120; - -type RelayPollOutcome = - | { kind: 'success'; targetHash: Hex } - | { kind: 'refunded' } - | { kind: 'failure' } - | { kind: 'timeout' }; - -async function pollRelayStatusUntilTerminal( - requestId: string, -): Promise { - for (let attempt = 0; attempt < RELAY_STATUS_POLL_MAX_ATTEMPTS; attempt++) { - try { - const status = await getRelayStatus(requestId); - log('Relay status', { - attempt, - status: status.status, - txHashes: status.txHashes, - }); - - if (status.status === 'success' && status.txHashes?.length) { - return { - kind: 'success', - targetHash: status.txHashes[status.txHashes.length - 1] as Hex, - }; - } - - if (status.status === 'refunded') { - return { kind: 'refunded' }; - } - - if (status.status === 'failure') { - return { kind: 'failure' }; - } - } catch (error) { - log('Relay status poll error', { attempt, error }); - } - - await new Promise((resolve) => - setTimeout(resolve, RELAY_STATUS_POLL_INTERVAL_MS), - ); - } - - return { kind: 'timeout' }; -} - -const WALLET_BUSY_RETRY_ATTEMPTS = 5; -const WALLET_BUSY_RETRY_DELAY_MS = 3_000; - -async function submitWithBusyRetry( - args: Parameters[0], -): Promise<{ relayerTransactionHash: Hex }> { - let lastError: unknown; - - for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { - try { - return await submitDepositWalletBatch(args); - } catch (error) { - lastError = error; - - const message = - (error instanceof Error ? error.message : String(error)) ?? ''; - const isWalletBusy = - message.toLowerCase().includes('wallet busy') || - message.toLowerCase().includes('active action'); - - log('submitWithBusyRetry caught error', { - attempt, - isWalletBusy, - errorName: (error as Error)?.name, - message, - }); - - if (!isWalletBusy || attempt === WALLET_BUSY_RETRY_ATTEMPTS) { - throw error; - } - - log('Wallet busy, retrying', { attempt, delayMs: WALLET_BUSY_RETRY_DELAY_MS }); - await new Promise((resolve) => - setTimeout(resolve, WALLET_BUSY_RETRY_DELAY_MS), - ); - } - } - - throw lastError; -} - -function encodeWrapCalldata({ - asset, - recipient, - amount, -}: { - asset: Hex; - recipient: Hex; - amount: bigint; -}): Hex { - const selector = '62355638'; - const paddedAsset = asset.slice(2).toLowerCase().padStart(64, '0'); - const paddedRecipient = recipient.slice(2).toLowerCase().padStart(64, '0'); - const paddedAmount = amount.toString(16).padStart(64, '0'); - return `0x${selector}${paddedAsset}${paddedRecipient}${paddedAmount}` as Hex; -} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts new file mode 100644 index 0000000000..8207c37727 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts @@ -0,0 +1,43 @@ +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetBatchRequest, + PayStrategyGetQuotesRequest, + PayStrategyGetRefreshIntervalRequest, + TransactionPayQuote, +} from '../../types'; +import { getPolymarketBridgeQuotes } from './polymarket-quotes'; +import { submitPolymarketBridgeQuote } from './polymarket-submit'; +import type { PolymarketBridgeQuote } from './types'; + +const REFRESH_INTERVAL_MS = 25_000; + +export class PolymarketStrategy implements PayStrategy { + supports(_request: PayStrategyGetQuotesRequest): boolean { + return true; + } + + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + return getPolymarketBridgeQuotes(request); + } + + async execute( + request: PayStrategyExecuteRequest, + ): Promise<{ transactionHash?: `0x${string}` }> { + return submitPolymarketBridgeQuote(request); + } + + async getBatchTransactions( + _request: PayStrategyGetBatchRequest, + ): Promise<[]> { + return []; + } + + async getRefreshInterval( + _request: PayStrategyGetRefreshIntervalRequest, + ): Promise { + return REFRESH_INTERVAL_MS; + } +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts deleted file mode 100644 index 8c7fd71999..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/bridge-api.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../../logger'; -import { POLYMARKET_BRIDGE_BASE_URL_PROD } from './constants'; -import type { - PolymarketBridgeFeeBreakdown, - PolymarketBridgeQuote, -} from './types'; - -const log = createModuleLogger(projectLogger, 'polymarket-bridge-api'); - -/** - * Error thrown by Polymarket Bridge API operations. - */ -export class PolymarketBridgeError extends Error { - code: string; - - raw: unknown; - - constructor(message: string, code: string, raw?: unknown) { - super(message); - this.name = 'PolymarketBridgeError'; - this.code = code; - this.raw = raw; - } -} - -/** Raw quote response from Bridge API POST /quote. */ -type BridgeQuoteResponse = { - quoteId: string; - estToTokenBaseUnit: string; - estCheckoutTimeMs: number; - estInputUsd: number; - estOutputUsd: number; - estFeeBreakdown: PolymarketBridgeFeeBreakdown; -}; - -/** Raw withdraw response from Bridge API POST /withdraw. */ -type BridgeWithdrawResponse = { - address: { - evm: string; - }; - note: string; -}; - -/** Single transaction entry from Bridge API GET /status. */ -type BridgeStatusTransaction = { - status: BridgeTransactionStatus; - txHash?: string; - createdTimeMs?: number; - fromChainId: string; - toChainId: string; - fromTokenAddress: string; - toTokenAddress: string; - fromAmountBaseUnit: string; -}; - -type BridgeTransactionStatus = - | 'DEPOSIT_DETECTED' - | 'PROCESSING' - | 'ORIGIN_TX_CONFIRMED' - | 'SUBMITTED' - | 'COMPLETED' - | 'FAILED'; - -const BRIDGE_TERMINAL_STATUSES: readonly BridgeTransactionStatus[] = [ - 'COMPLETED', - 'FAILED', -]; - -/** Raw status response from Bridge API GET /status/{address}. */ -type BridgeStatusResponse = { - transactions: BridgeStatusTransaction[]; -}; - -/** - * HTTP client for the Polymarket Bridge API. - * - * Provides methods to get bridge quotes, create one-shot deposit addresses, - * and poll for bridge transaction status. - */ -export class PolymarketBridgeApi { - readonly #baseUrl: string = POLYMARKET_BRIDGE_BASE_URL_PROD; - - /** - * Fetch a bridge quote for a cross-chain transfer. - * - * @param request - The quote request parameters. - * @param request.fromAmountBaseUnit - Amount to bridge in base units. - * @param request.fromChainId - Source chain ID. - * @param request.fromTokenAddress - Source token address. - * @param request.recipientAddress - Recipient address on the destination chain. - * @param request.toChainId - Destination chain ID. - * @param request.toTokenAddress - Destination token address. - * @returns A PolymarketBridgeQuote with bridgeDepositAddress set to null. - */ - async getQuote(request: { - fromAmountBaseUnit: string; - fromChainId: string; - fromTokenAddress: string; - recipientAddress: string; - toChainId: string; - toTokenAddress: string; - }): Promise { - const url = `${this.#baseUrl}/quote`; - - log('Fetching quote', { url, request }); - - const data = await this.#post(url, request); - - log('Quote received', { quoteId: data.quoteId }); - - return { - quoteId: data.quoteId, - bridgeDepositAddress: null, - fromAmount: request.fromAmountBaseUnit, - toAmount: data.estToTokenBaseUnit, - minReceived: data.estToTokenBaseUnit, - estCheckoutTimeMs: data.estCheckoutTimeMs, - estFeeBreakdown: data.estFeeBreakdown, - }; - } - - /** - * Create a one-shot deposit address for a bridge withdrawal. - * - * @param request - The withdraw address request parameters. - * @param request.address - The source address. - * @param request.toChainId - Destination chain ID. - * @param request.toTokenAddress - Destination token address. - * @param request.recipientAddr - Recipient address on the destination chain. - * @returns The EVM deposit address as a hex string. - */ - async createWithdrawAddress(request: { - address: string; - toChainId: string; - toTokenAddress: string; - recipientAddr: string; - }): Promise { - const url = `${this.#baseUrl}/withdraw`; - - log('Creating withdraw address', { url, request }); - - const data = await this.#post(url, request); - - log('Withdraw address created', { address: data.address.evm }); - - return data.address.evm as Hex; - } - - /** - * Get the bridge transaction status for a deposit address. - * - * @param depositAddress - The deposit address to check status for. - * @returns Array of bridge status transactions. - */ - async getStatus(depositAddress: string): Promise { - const url = `${this.#baseUrl}/status/${depositAddress}`; - - log('Fetching status', { url, depositAddress }); - - const data = await this.#get(url); - - log('Status received', { - depositAddress, - transactionCount: data.transactions.length, - }); - - return data.transactions; - } - - async pollUntilBridgeComplete( - depositAddress: string, - pollIntervalMs = 10_000, - maxAttempts = 90, - ): Promise { - log('Polling bridge status', { depositAddress, maxAttempts }); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - await delay(pollIntervalMs); - - const transactions = await this.getStatus(depositAddress); - const latest = transactions[0]; - - if ( - latest && - (BRIDGE_TERMINAL_STATUSES as readonly string[]).includes(latest.status) - ) { - log('Bridge reached terminal state', { - depositAddress, - status: latest.status, - txHash: latest.txHash, - attempt: attempt + 1, - }); - return latest; - } - - log('Bridge polling', { - depositAddress, - status: latest?.status, - attempt: attempt + 1, - }); - } - - throw new PolymarketBridgeError( - `Bridge status polling timed out after ${maxAttempts} attempts`, - 'BRIDGE_POLLING_TIMEOUT', - ); - } - - /** - * Get supported assets from the bridge API. - * - * @returns The raw supported assets response. - */ - async getSupportedAssets(): Promise { - const url = `${this.#baseUrl}/supported-assets`; - - log('Fetching supported assets', { url }); - - const data: unknown = await this.#get(url); - - log('Supported assets received'); - - return data; - } - - /** - * Send a POST request to the bridge API. - * - * @param url - The endpoint URL. - * @param body - The request body to serialize as JSON. - * @returns The parsed JSON response. - */ - async #post(url: string, body: unknown): Promise { - return this.#fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - } - - /** - * Send a GET request to the bridge API. - * - * @param url - The endpoint URL. - * @returns The parsed JSON response. - */ - async #get(url: string): Promise { - return this.#fetch(url, { method: 'GET' }); - } - - /** - * Execute a fetch request, parsing the JSON response and wrapping errors - * in PolymarketBridgeError. - * - * @param url - The endpoint URL. - * @param init - Fetch init options. - * @returns The parsed JSON response. - */ - async #fetch( - url: string, - init: RequestInit, - ): Promise { - const response = await bridgeFetch(url, init); - return (await response.json()) as ResponseType; - } -} - -/** - * Fetch a Bridge API endpoint, throwing a PolymarketBridgeError on non-OK - * responses. Preserves the API's error message when available. - * - * @param url - The endpoint to fetch. - * @param init - Fetch init options. - * @returns The successful response. - */ -async function bridgeFetch(url: string, init?: RequestInit): Promise { - const response = await fetch(url, init); - - if (!response.ok) { - let detail: string | undefined; - let rawBody: unknown; - - try { - rawBody = await response.json(); - const body = rawBody as { message?: string; error?: string }; - detail = body.message ?? body.error; - } catch { - // Body wasn't JSON; fall through to status-only error. - } - - throw new PolymarketBridgeError( - detail - ? `Bridge API ${response.status} - ${detail}` - : `Bridge API ${String(response.status)}`, - String(response.status), - rawBody, - ); - } - - return response; -} - -async function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts index a63791b1ca..b945484889 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts @@ -1,27 +1,31 @@ import type { Hex } from '@metamask/utils'; -export const POLYMARKET_BRIDGE_BASE_URL_PROD = 'https://bridge.polymarket.com'; - export const POLYMARKET_RELAYER_PROXY_URL_PROD = 'https://predict.api.cx.metamask.io'; -// On-chain addresses (Polygon) export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = '0x00000000000Fb5C9ADea0298D729A0CB3823Cc07' as Hex; -export const PUSD_ADDRESS_POLYGON = - '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; export const DEPOSIT_WALLET_IMPLEMENTATION_POLYGON = '0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB' as Hex; -// EIP-712 domain +export const PUSD_ADDRESS_POLYGON = + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; + +export const USDC_E_ADDRESS_POLYGON = + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174' as Hex; + +export const POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = + '0x2957922Eb93258b93368531d39fAcCA3B4dC5854' as Hex; + +export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = + '0x93070a847efEf7F70739046A929D47a521F5B8ee' as Hex; + export const POLYMARKET_WALLET_DOMAIN_NAME = 'DepositWallet'; export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; -// Transaction parameters export const POLYMARKET_BATCH_DEADLINE_SECONDS = 240; -// Relayer terminal states — once the relayer enters one of these, stop polling export const RELAYER_TERMINAL_STATES = [ 'STATE_MINED', 'STATE_CONFIRMED', @@ -29,46 +33,4 @@ export const RELAYER_TERMINAL_STATES = [ 'STATE_INVALID', ] as const; -// pUSD decimals (same as USDC) export const PUSD_DECIMALS = 6; - -/** - * Hardcoded experiment flag. When true, the Polymarket bridge strategy bypasses - * Polymarket's `/quote` + `/withdraw` flow and instead: - * 1. Fetches a Relay quote (pUSD on Polygon → target chain/token). - * 2. At execute time, transfers pUSD from the deposit wallet to the user EOA - * via the existing Polymarket relayer proxy (single ERC-20 transfer). - * 3. Submits the stored Relay quote from the user EOA, gaslessly via Relay's - * /execute endpoint. - * - * Lets us avoid Polymarket-bridge minimums, fees, and the source-vs-target - * txHash ambiguity in their `/status` endpoint. Toggle to false to fall back to - * the original Polymarket bridge flow. - */ -export const USE_RELAY_BRIDGE = true; - -/** - * Experimental flag layered on top of USE_RELAY_BRIDGE. When both flags are - * true, the deposit wallet unwraps pUSD directly into USDC.e at Relay's - * one-shot deposit address in a single relayer-broadcast batch (approve + - * unwrap), skipping the EOA leg entirely. Requires the deposit wallet to be - * able to call the Polymarket CollateralOfframp. - */ -export const USE_RELAY_DEPOSIT_ADDRESS = true; - -export const USDC_E_ADDRESS_POLYGON = - '0x2791bca1f2de4661ed88a30c99a7a9449aa84174' as Hex; - -export const POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = - '0x2957922Eb93258b93368531d39fAcCA3B4dC5854' as Hex; - -export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = - '0x93070a847efEf7F70739046A929D47a521F5B8ee' as Hex; - -/** - * TEMPORARY testing flag. When true, the Relay-deposit-address flow skips - * polling the Relay /intents/status endpoint entirely so the wrap-back - * sweep flow can be exercised quickly with USDC.e manually loaded onto the - * deposit wallet. - */ -export const FORCE_SKIP_RELAY_POLL = true; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts new file mode 100644 index 0000000000..26a8ecaaaa --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts @@ -0,0 +1,64 @@ +import type { Hex } from '@metamask/utils'; + +const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; +const ERC20_APPROVE_SELECTOR = '095ea7b3'; +const POLYMARKET_UNWRAP_SELECTOR = '8cc7104f'; +const POLYMARKET_WRAP_SELECTOR = '62355638'; + +export function encodeApprove(spender: Hex, amount: bigint): Hex { + return encodeTwoArg(ERC20_APPROVE_SELECTOR, spender, amount); +} + +export function encodeUnwrap({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + return encodeThreeArg(POLYMARKET_UNWRAP_SELECTOR, asset, recipient, amount); +} + +export function encodeWrap({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + return encodeThreeArg(POLYMARKET_WRAP_SELECTOR, asset, recipient, amount); +} + +export function extractErc20TransferRecipient(data: Hex): Hex { + if (!data.startsWith(ERC20_TRANSFER_SELECTOR)) { + throw new Error( + `Expected ERC-20 transfer calldata, got selector ${data.slice(0, 10)}`, + ); + } + return `0x${data.slice(34, 74)}` as Hex; +} + +function encodeTwoArg(selector: string, address: Hex, amount: bigint): Hex { + return `0x${selector}${padAddress(address)}${padUint256(amount)}` as Hex; +} + +function encodeThreeArg( + selector: string, + asset: Hex, + recipient: Hex, + amount: bigint, +): Hex { + return `0x${selector}${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; +} + +function padAddress(address: Hex): string { + return address.slice(2).toLowerCase().padStart(64, '0'); +} + +function padUint256(value: bigint): string { + return value.toString(16).padStart(64, '0'); +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts new file mode 100644 index 0000000000..0abf550605 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts @@ -0,0 +1,186 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { CHAIN_ID_POLYGON, TransactionPayStrategy } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getFiatValueFromUsd } from '../../utils/amounts'; +import { getSlippage } from '../../utils/feature-flags'; +import { getTokenFiatRate } from '../../utils/token'; +import { fetchRelayQuote } from '../relay/relay-api'; +import type { RelayQuote, RelayQuoteRequest } from '../relay/types'; +import { + PUSD_ADDRESS_POLYGON, + PUSD_DECIMALS, + USDC_E_ADDRESS_POLYGON, +} from './constants'; +import { computeDepositWalletAddress } from './deposit-wallet'; +import { extractPolymarketWithdrawIntent } from './intent'; +import type { PolymarketBridgeQuote } from './types'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-quotes'); + +const POLYGON_CHAIN_ID_NUMBER = 137; + +export async function getPolymarketBridgeQuotes( + request: PayStrategyGetQuotesRequest, +): Promise[]> { + const intent = extractPolymarketWithdrawIntent(request.transaction); + if (!intent) { + return []; + } + + const quoteRequest = request.requests[0]; + if (!quoteRequest) { + return []; + } + + const depositWalletAddress = computeDepositWalletAddress(quoteRequest.from); + + const body = buildRelayQuoteRequest({ + intent, + quoteRequest, + depositWalletAddress, + messenger: request.messenger, + }); + + log('Fetching Relay quote', { + originCurrency: body.originCurrency, + destinationChainId: body.destinationChainId, + destinationCurrency: body.destinationCurrency, + amount: body.amount, + }); + + const relayQuote = await fetchRelayQuote( + request.messenger, + body, + request.signal, + ); + + log('Relay quote fetched', { + currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, + totalImpactUsd: relayQuote.details.totalImpact.usd, + stepCount: relayQuote.steps.length, + }); + + return [ + buildTransactionPayQuote({ + relayQuote, + intent, + messenger: request.messenger, + quoteRequest, + }), + ]; +} + +function buildRelayQuoteRequest({ + intent, + quoteRequest, + depositWalletAddress, + messenger, +}: { + intent: { amount: bigint }; + quoteRequest: QuoteRequest; + depositWalletAddress: Hex; + messenger: TransactionPayControllerMessenger; +}): RelayQuoteRequest { + const slippageTolerance = new BigNumber( + getSlippage(messenger, CHAIN_ID_POLYGON, USDC_E_ADDRESS_POLYGON) * + 100 * + 100, + ).toFixed(0); + + return { + amount: intent.amount.toString(), + destinationChainId: parseInt(quoteRequest.targetChainId, 16), + destinationCurrency: quoteRequest.targetTokenAddress, + originChainId: POLYGON_CHAIN_ID_NUMBER, + originCurrency: USDC_E_ADDRESS_POLYGON, + recipient: quoteRequest.from, + refundTo: depositWalletAddress, + slippageTolerance, + tradeType: 'EXACT_INPUT', + useDepositAddress: true, + user: depositWalletAddress, + }; +} + +function buildTransactionPayQuote({ + relayQuote, + intent, + messenger, + quoteRequest, +}: { + relayQuote: RelayQuote; + intent: { amount: bigint }; + messenger: TransactionPayControllerMessenger; + quoteRequest: QuoteRequest; +}): TransactionPayQuote { + const sourceFiatRate = getTokenFiatRate( + messenger, + PUSD_ADDRESS_POLYGON, + CHAIN_ID_POLYGON, + ); + + const usdToFiatRate = + sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) + ? new BigNumber(sourceFiatRate.fiatRate).dividedBy(sourceFiatRate.usdRate) + : new BigNumber(1); + + const sourceAmount = buildAmount( + intent.amount.toString(), + PUSD_DECIMALS, + sourceFiatRate, + ); + + const targetAmountUsd = new BigNumber( + relayQuote.details.currencyOut.amountUsd, + ); + const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); + + const providerFeeUsd = new BigNumber( + relayQuote.fees.relayer?.amountUsd ?? '0', + ).plus(relayQuote.fees.app?.amountUsd ?? '0'); + const provider = getFiatValueFromUsd(providerFeeUsd, usdToFiatRate); + + return { + original: { relayQuote }, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider, + sourceNetwork: { + estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, + max: { fiat: '0', usd: '0', human: '0', raw: '0' }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + sourceAmount, + targetAmount, + dust: { fiat: '0', usd: '0' }, + estimatedDuration: relayQuote.details.timeEstimate ?? 30, + strategy: TransactionPayStrategy.PolymarketBridge, + request: quoteRequest, + }; +} + +function buildAmount( + raw: string, + decimals: number, + fiatRate: { fiatRate: string; usdRate: string } | undefined, +): { fiat: string; human: string; raw: string; usd: string } { + const humanValue = new BigNumber(raw).shiftedBy(-decimals); + const human = humanValue.toString(10); + const usd = fiatRate + ? humanValue.multipliedBy(fiatRate.usdRate).toString(10) + : '0'; + const fiat = fiatRate + ? humanValue.multipliedBy(fiatRate.fiatRate).toString(10) + : '0'; + return { fiat, human, raw, usd }; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts new file mode 100644 index 0000000000..4d229869cb --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts @@ -0,0 +1,453 @@ +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { CHAIN_ID_POLYGON } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyExecuteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getPolymarketRelayerUrl } from '../../utils/feature-flags'; +import { getLiveTokenBalance } from '../../utils/token'; +import { updateTransaction } from '../../utils/transaction'; +import { getRelayStatus } from '../relay/relay-api'; +import type { RelayQuote, RelayTransactionStep } from '../relay/types'; +import { + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + POLYMARKET_BATCH_DEADLINE_SECONDS, + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + PUSD_ADDRESS_POLYGON, + USDC_E_ADDRESS_POLYGON, +} from './constants'; +import { computeDepositWalletAddress } from './deposit-wallet'; +import { + encodeApprove, + encodeUnwrap, + encodeWrap, + extractErc20TransferRecipient, +} from './polymarket-calldata'; +import { PolymarketRelayerApi } from './relayer-api'; +import type { + PolymarketBridgeQuote, + PolymarketBridgeRelayerSubmitRequest, + PolymarketBridgeWalletCall, +} from './types'; +import { buildWalletBatchTypedData } from './wallet-batch-typed-data'; + +const log = createModuleLogger(projectLogger, 'polymarket-bridge-submit'); + +const POLYGON_CHAIN_ID_NUMBER = 137; + +const WALLET_BUSY_RETRY_ATTEMPTS = 5; +const WALLET_BUSY_RETRY_DELAY_MS = 3_000; + +const RELAY_STATUS_POLL_INTERVAL_MS = 5_000; +const RELAY_STATUS_POLL_MAX_ATTEMPTS = 120; + +type RelayPollOutcome = + | { kind: 'success'; targetHash: Hex } + | { kind: 'refunded' } + | { kind: 'failure' } + | { kind: 'timeout' }; + +export async function submitPolymarketBridgeQuote( + request: PayStrategyExecuteRequest, +): Promise<{ transactionHash?: Hex }> { + const quote = request.quotes[0]; + if (!quote) { + throw new Error('Polymarket bridge submit: no quote provided'); + } + + markIntentComplete(request, quote); + + const from = quote.request.from; + const depositWalletAddress = computeDepositWalletAddress(from); + const relayerApi = new PolymarketRelayerApi( + getPolymarketRelayerUrl(request.messenger), + ); + + const sourceHash = await submitUnwrapToRelayDepositAddress({ + quote, + from, + depositWalletAddress, + messenger: request.messenger, + relayerApi, + }); + + updateSourceHash(request, sourceHash); + + const relayOutcome = await pollRelayStatusUntilTerminal( + getRelayRequestId(quote.original.relayQuote), + ); + log('Relay polling complete', { kind: relayOutcome.kind }); + + await sweepDepositWalletUsdce({ + messenger: request.messenger, + from, + depositWalletAddress, + relayerApi, + }); + + if (relayOutcome.kind === 'success') { + return { transactionHash: relayOutcome.targetHash }; + } + + return { transactionHash: sourceHash }; +} + +async function submitUnwrapToRelayDepositAddress({ + quote, + from, + depositWalletAddress, + messenger, + relayerApi, +}: { + quote: TransactionPayQuote; + from: Hex; + depositWalletAddress: Hex; + messenger: TransactionPayControllerMessenger; + relayerApi: PolymarketRelayerApi; +}): Promise { + const relayDepositAddress = extractRelayDepositAddress( + quote.original.relayQuote, + ); + const amount = BigInt(quote.sourceAmount.raw); + + log('Submitting unwrap batch to Relay deposit address', { + depositWalletAddress, + relayDepositAddress, + amount: amount.toString(), + }); + + const result = await submitDepositWalletBatch({ + from, + depositWalletAddress, + calls: [ + { + target: PUSD_ADDRESS_POLYGON, + value: 0n, + data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: 0n, + data: encodeUnwrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: relayDepositAddress, + amount, + }), + }, + ], + messenger, + relayerApi, + }); + + return result.relayerTransactionHash; +} + +async function sweepDepositWalletUsdce({ + messenger, + from, + depositWalletAddress, + relayerApi, +}: { + messenger: TransactionPayControllerMessenger; + from: Hex; + depositWalletAddress: Hex; + relayerApi: PolymarketRelayerApi; +}): Promise { + let usdceBalance: bigint; + try { + const raw = await getLiveTokenBalance( + messenger, + depositWalletAddress, + CHAIN_ID_POLYGON, + USDC_E_ADDRESS_POLYGON, + ); + usdceBalance = BigInt(raw); + } catch (error) { + log('USDC.e sweep: failed to read deposit wallet balance', { error }); + return; + } + + log('USDC.e sweep: deposit wallet balance', { + depositWalletAddress, + balance: usdceBalance.toString(), + }); + + if (usdceBalance === 0n) { + log('USDC.e sweep: nothing to wrap'); + return; + } + + try { + const result = await submitDepositWalletBatch({ + from, + depositWalletAddress, + calls: [ + { + target: USDC_E_ADDRESS_POLYGON, + value: 0n, + data: encodeApprove(POLYMARKET_COLLATERAL_ONRAMP_POLYGON, usdceBalance), + }, + { + target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + value: 0n, + data: encodeWrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: depositWalletAddress, + amount: usdceBalance, + }), + }, + ], + messenger, + relayerApi, + }); + + log('USDC.e sweep: complete', { + transactionHash: result.relayerTransactionHash, + }); + } catch (error) { + log('USDC.e sweep: batch submission failed', { error }); + } +} + +export async function submitDepositWalletBatch({ + from, + depositWalletAddress, + calls, + messenger, + relayerApi, +}: { + from: Hex; + depositWalletAddress: Hex; + calls: PolymarketBridgeWalletCall[]; + messenger: TransactionPayControllerMessenger; + relayerApi: PolymarketRelayerApi; +}): Promise<{ relayerTransactionHash: Hex }> { + let lastError: unknown; + + for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { + try { + return await submitDepositWalletBatchOnce({ + from, + depositWalletAddress, + calls, + messenger, + relayerApi, + }); + } catch (error) { + lastError = error; + + const message = error instanceof Error ? error.message : String(error); + const isWalletBusy = + message.toLowerCase().includes('wallet busy') || + message.toLowerCase().includes('active action'); + + if (!isWalletBusy || attempt === WALLET_BUSY_RETRY_ATTEMPTS) { + throw error; + } + + log('Wallet busy, retrying', { + attempt, + delayMs: WALLET_BUSY_RETRY_DELAY_MS, + }); + + await delay(WALLET_BUSY_RETRY_DELAY_MS); + } + } + + throw lastError; +} + +async function submitDepositWalletBatchOnce({ + from, + depositWalletAddress, + calls, + messenger, + relayerApi, +}: { + from: Hex; + depositWalletAddress: Hex; + calls: PolymarketBridgeWalletCall[]; + messenger: TransactionPayControllerMessenger; + relayerApi: PolymarketRelayerApi; +}): Promise<{ relayerTransactionHash: Hex }> { + const nonce = await relayerApi.getNonce(from, 'WALLET'); + const deadline = + Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; + + const typedData = buildWalletBatchTypedData({ + wallet: depositWalletAddress, + nonce, + deadline, + calls, + chainId: POLYGON_CHAIN_ID_NUMBER, + }); + + const signature = (await messenger.call( + 'KeyringController:signTypedMessage', + { + from, + data: JSON.stringify(typedData), + }, + SignTypedDataVersion.V4, + )) as Hex; + + const submitRequest: PolymarketBridgeRelayerSubmitRequest = { + type: 'WALLET', + from, + to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + nonce, + signature, + depositWalletParams: { + depositWallet: depositWalletAddress, + deadline: deadline.toString(), + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }, + }; + + const submitResponse = await relayerApi.submit(submitRequest); + log('Relayer accepted submission', { + transactionID: submitResponse.transactionID, + state: submitResponse.state, + }); + + const terminalStatus = await relayerApi.pollUntilTerminal( + submitResponse.transactionID, + ); + + if ( + terminalStatus.state === 'STATE_FAILED' || + terminalStatus.state === 'STATE_INVALID' + ) { + throw new Error( + `Polymarket bridge withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, + ); + } + + if (!terminalStatus.transactionHash) { + throw new Error( + `Polymarket bridge withdraw: terminal state=${terminalStatus.state} but no transactionHash`, + ); + } + + log('Wallet batch complete', { + transactionHash: terminalStatus.transactionHash, + state: terminalStatus.state, + }); + + return { + relayerTransactionHash: terminalStatus.transactionHash as Hex, + }; +} + +async function pollRelayStatusUntilTerminal( + requestId: string, +): Promise { + for (let attempt = 0; attempt < RELAY_STATUS_POLL_MAX_ATTEMPTS; attempt++) { + try { + const status = await getRelayStatus(requestId); + log('Relay status', { + attempt, + status: status.status, + txHashes: status.txHashes, + }); + + if (status.status === 'success' && status.txHashes?.length) { + return { + kind: 'success', + targetHash: status.txHashes[status.txHashes.length - 1] as Hex, + }; + } + + if (status.status === 'refunded') { + return { kind: 'refunded' }; + } + + if (status.status === 'failure') { + return { kind: 'failure' }; + } + } catch (error) { + log('Relay status poll error', { attempt, error }); + } + + await delay(RELAY_STATUS_POLL_INTERVAL_MS); + } + + return { kind: 'timeout' }; +} + +function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { + const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); + + if (!depositStep || depositStep.kind !== 'transaction') { + throw new Error( + 'Polymarket bridge submit: Relay quote has no deposit step', + ); + } + + const transactionStep = depositStep as RelayTransactionStep; + const depositCallData = transactionStep.items[0]?.data?.data; + + if (!depositCallData) { + throw new Error( + 'Polymarket bridge submit: Relay quote deposit step is missing calldata', + ); + } + + return extractErc20TransferRecipient(depositCallData); +} + +function getRelayRequestId(relayQuote: RelayQuote): string { + const requestId = relayQuote.steps[0]?.requestId; + if (!requestId) { + throw new Error('Polymarket bridge submit: Relay quote has no requestId'); + } + return requestId; +} + +function markIntentComplete( + request: PayStrategyExecuteRequest, + quote: TransactionPayQuote, +): void { + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Mark intent complete at Polymarket bridge execute start', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + void quote; +} + +function updateSourceHash( + request: PayStrategyExecuteRequest, + sourceHash: Hex, +): void { + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Add source hash from Polymarket relayer', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; + }, + ); +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts index 4eb6973596..855e91cd37 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts +++ b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts @@ -2,79 +2,29 @@ import type { Hex } from '@metamask/utils'; import type { RelayQuote } from '../relay/types'; -/** Quote returned by Polymarket Bridge /quote endpoint. */ +/** + * Strategy-level quote type. The Polymarket bridge withdraw flow delegates + * cross-chain routing to Relay, so the quote it carries is a Relay quote. + */ export type PolymarketBridgeQuote = { - /** Unique quote identifier. */ - quoteId: string; - /** One-shot deposit address; null until execute() mints it via /withdraw. */ - bridgeDepositAddress: Hex | null; - /** Amount being sent, in base units (e.g. 6 decimals for pUSD). */ - fromAmount: string; - /** Estimated tokens received, in base units. */ - toAmount: string; - /** Minimum amount the user will receive. */ - minReceived: string; - /** Estimated checkout time in milliseconds. */ - estCheckoutTimeMs: number; - /** Fee breakdown from Polymarket (typically all zero for pUSD→USDC). */ - estFeeBreakdown: PolymarketBridgeFeeBreakdown; - /** - * When the USE_RELAY_BRIDGE flag is on, the Relay quote fetched at - * getQuotes time and replayed at execute time after the deposit-wallet - * transfers pUSD to the user EOA. Absent in the legacy Polymarket bridge - * flow. - */ - relayQuote?: RelayQuote; -}; - -/** Fee breakdown from Bridge /quote response. */ -export type PolymarketBridgeFeeBreakdown = { - gasUsd: number; - appFeeUsd: number; - swapImpactUsd: number; + relayQuote: RelayQuote; }; -/** EIP-712 Batch structure for DepositWallet. */ -export type PolymarketBridgeWalletBatch = { - /** Deposit wallet address. */ - wallet: Hex; - /** Relayer nonce for the wallet. */ - nonce: string; - /** Unix timestamp deadline. */ - deadline: number; - /** Calls to execute in the batch. */ - calls: PolymarketBridgeWalletCall[]; -}; - -/** Single call within a DepositWallet Batch. */ export type PolymarketBridgeWalletCall = { - /** Target contract address. */ target: Hex; - /** ETH value (usually 0n for token transfers). */ value: bigint; - /** Encoded calldata. */ data: Hex; }; -/** Request body for relayer /submit (WALLET type). */ export type PolymarketBridgeRelayerSubmitRequest = { - /** Request type. */ type: 'WALLET'; - /** Owner/signer EOA address. */ from: Hex; - /** Deposit wallet factory address. */ to: Hex; - /** Wallet nonce (fetched from relayer). */ nonce: string; - /** 65-byte EIP-712 Batch signature. */ signature: Hex; - /** Deposit wallet batch parameters. */ depositWalletParams: { - /** Deposit wallet contract address. */ depositWallet: Hex; - /** Unix timestamp deadline as string. */ deadline: string; - /** Calls to execute in the batch. */ calls: { target: string; value: string; @@ -83,41 +33,25 @@ export type PolymarketBridgeRelayerSubmitRequest = { }; }; -/** Response from relayer /submit. */ export type PolymarketBridgeRelayerSubmitResponse = { - /** Transaction tracking ID. */ transactionID: string; - /** Initial state. */ state: string; }; -/** Response from relayer /transaction?id=. */ export type PolymarketBridgeRelayerStatusResponse = { - /** On-chain transaction hash (available once STATE_MINED or later). */ transactionHash: string | null; - /** Current state. */ state: PolymarketRelayerState; - /** Signer address. */ from: string; - /** Target address. */ to: string; - /** Proxy wallet address. */ proxyAddress: string; - /** Hex-encoded data. */ data: string; - /** Nonce. */ nonce: string; - /** Signature. */ signature: string; - /** Transaction type. */ type: string; - /** ISO timestamp. */ createdAt: string; - /** ISO timestamp. */ updatedAt: string; }; -/** Relayer transaction states. */ export type PolymarketRelayerState = | 'STATE_NEW' | 'STATE_EXECUTED' @@ -126,14 +60,7 @@ export type PolymarketRelayerState = | 'STATE_INVALID' | 'STATE_FAILED'; -/** - * Envelope posted to the MetaMask Polymarket relayer proxy. The proxy - * authenticates the request and forwards it to the underlying Polymarket - * relayer using the path/method/body or query described here. - */ export type PolymarketRelayerProxyEnvelope = | { path: '/submit'; method: 'POST'; body: unknown } | { path: '/nonce'; method: 'GET'; query: Record } | { path: '/transaction'; method: 'GET'; query: Record }; - - diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts deleted file mode 100644 index 9709ff210a..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/withdraw.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../../logger'; -import type { - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types'; -import { - DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - POLYMARKET_BATCH_DEADLINE_SECONDS, - PUSD_ADDRESS_POLYGON, -} from './constants'; -import type { PolymarketRelayerApi } from './relayer-api'; -import type { - PolymarketBridgeQuote, - PolymarketBridgeRelayerSubmitRequest, -} from './types'; -import { buildWalletBatchTypedData } from './wallet-batch-typed-data'; - -const log = createModuleLogger(projectLogger, 'polymarket-bridge-withdraw'); - -const CHAIN_ID_POLYGON = 137; - -/** - * Submit a Polymarket Bridge withdrawal via the relayer. - * - * Orchestrates the full flow: fetch nonce → build transfer calldata → - * construct EIP-712 Batch → sign → POST to relayer → poll until terminal. - * - * @param quote - The bridge quote containing fromAmount and bridgeDepositAddress. - * @param from - The user's EOA address (signer that owns the deposit wallet). - * @param depositWalletAddress - The DepositWallet contract address on Polygon. - * @param messenger - Controller messenger for KeyringController:signTypedMessage. - * @param relayerApi - Authenticated Polymarket relayer API client. - * @returns The relayer's on-chain transaction hash. - */ -export async function submitPolymarketBridgeWithdraw( - quote: TransactionPayQuote, - from: Hex, - depositWalletAddress: Hex, - bridgeDepositAddress: Hex, - messenger: TransactionPayControllerMessenger, - relayerApi: PolymarketRelayerApi, -): Promise<{ relayerTransactionHash: Hex }> { - const { fromAmount } = quote.original; - - const amount = BigInt(fromAmount); - const transferCalldata = encodeTransferCalldata(bridgeDepositAddress, amount); - - log('Built transfer calldata', { - target: PUSD_ADDRESS_POLYGON, - to: bridgeDepositAddress, - amount: amount.toString(), - }); - - return await submitDepositWalletBatch({ - from, - depositWalletAddress, - calls: [ - { - target: PUSD_ADDRESS_POLYGON, - value: 0n, - data: transferCalldata, - }, - ], - messenger, - relayerApi, - }); -} - -/** - * Submit an arbitrary batch of calls from a Polymarket deposit wallet via the - * existing relayer proxy. Handles nonce fetch, EIP-712 signing, submission, - * and polling to terminal state. - * - * @param options - Submission options. - * @param options.from - The owner EOA of the deposit wallet. - * @param options.depositWalletAddress - The deposit wallet address. - * @param options.calls - Calls to execute in the batch. - * @param options.messenger - Controller messenger for signing. - * @param options.relayerApi - Authenticated relayer API client. - * @returns The relayer's on-chain transaction hash. - */ -export async function submitDepositWalletBatch({ - from, - depositWalletAddress, - calls, - messenger, - relayerApi, -}: { - from: Hex; - depositWalletAddress: Hex; - calls: { target: Hex; value: bigint; data: Hex }[]; - messenger: TransactionPayControllerMessenger; - relayerApi: PolymarketRelayerApi; -}): Promise<{ relayerTransactionHash: Hex }> { - log('Fetching wallet nonce', { from }); - const nonce = await relayerApi.getNonce(from, 'WALLET'); - - const deadline = - Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; - - const typedData = buildWalletBatchTypedData({ - wallet: depositWalletAddress, - nonce, - deadline, - calls, - chainId: CHAIN_ID_POLYGON, - }); - - log('Signing Batch via EIP-712', { nonce, deadline, callCount: calls.length }); - - const signature = await messenger.call( - 'KeyringController:signTypedMessage', - { - from, - data: JSON.stringify(typedData), - }, - SignTypedDataVersion.V4, - ); - - const submitRequest: PolymarketBridgeRelayerSubmitRequest = { - type: 'WALLET', - from, - to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - nonce, - signature: signature as Hex, - depositWalletParams: { - depositWallet: depositWalletAddress, - deadline: deadline.toString(), - calls: calls.map((call) => ({ - target: call.target, - value: call.value.toString(), - data: call.data, - })), - }, - }; - - log('Submitting to relayer'); - const submitResponse = await relayerApi.submit(submitRequest); - - log('Relayer accepted', { - transactionID: submitResponse.transactionID, - state: submitResponse.state, - }); - - const terminalStatus = await relayerApi.pollUntilTerminal( - submitResponse.transactionID, - ); - - if ( - terminalStatus.state === 'STATE_FAILED' || - terminalStatus.state === 'STATE_INVALID' - ) { - throw new Error( - `Polymarket bridge withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, - ); - } - - if (!terminalStatus.transactionHash) { - throw new Error( - `Polymarket bridge withdraw: terminal state=${terminalStatus.state} but no transactionHash`, - ); - } - - log('Withdrawal complete', { - transactionHash: terminalStatus.transactionHash, - state: terminalStatus.state, - }); - - return { - relayerTransactionHash: terminalStatus.transactionHash as Hex, - }; -} - -/** - * Encode an ERC-20 transfer(address,uint256) call. - * - * Selector: 0xa9059cbb - * Layout: 4-byte selector + 32-byte left-padded address + 32-byte uint256 - * - * @param to - Recipient address. - * @param amount - Token amount in base units. - * @returns The hex-encoded calldata. - */ -function encodeTransferCalldata(to: Hex, amount: bigint): Hex { - const selector = '0xa9059cbb'; - const paddedAddress = to.slice(2).toLowerCase().padStart(64, '0'); - const paddedAmount = amount.toString(16).padStart(64, '0'); - - return `0x${selector.slice(2)}${paddedAddress}${paddedAmount}` as Hex; -} diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index e5d0e49ee3..f01a332c19 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -2,7 +2,7 @@ import { TransactionPayStrategy } from '../constants'; import { AcrossStrategy } from '../strategy/across/AcrossStrategy'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; -import { PolymarketBridgeStrategy } from '../strategy/polymarket-bridge/PolymarketBridgeStrategy'; +import { PolymarketStrategy } from '../strategy/polymarket-bridge/PolymarketStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import type { @@ -39,7 +39,7 @@ export function getStrategyByName( return new FiatStrategy() as never; case TransactionPayStrategy.PolymarketBridge: - return new PolymarketBridgeStrategy() as never; + return new PolymarketStrategy() as never; case TransactionPayStrategy.Test: return new TestStrategy() as never; From ea5f2930bb84e823add739b7879b03bb8c7dad2c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:07:09 +0100 Subject: [PATCH 14/16] refactor(transaction-pay-controller): fold Polymarket deposit wallet withdraw into Relay strategy The Polymarket withdraw flow is now a flavour of the Relay strategy, matching the HyperLiquid precedent. The standalone PolymarketStrategy class and its TransactionPayStrategy enum entry are removed in favour of an isPolymarketDepositWallet branch in the existing Relay quote and submit pipelines. Why: 90% of the previous strategy duplicated work the Relay strategy already does (quote fetch, response normalisation, status polling, destination tx hash extraction). The only Polymarket-specific parts are the deposit-wallet transport (EIP-712 batch via the Polymarket relayer proxy), the pUSD <-> USDC.e conversion (unwrap pre-deposit, wrap-back sweep on completion), and the useDepositAddress=true Relay request shape. Structure: - strategy/relay/polymarket/ holds all Polymarket-specific code: - withdraw.ts orchestrates the approve+unwrap source-leg batch, the USDC.e sweep helper run after Relay completion, and the deposit wallet batch transport with wallet-busy retry. - quotes.ts builds the USDC.e + useDepositAddress=true Relay quote body. - calldata.ts, constants.ts, deposit-wallet.ts, relayer-api.ts, types.ts, wallet-batch-typed-data.ts host the supporting primitives. - relay-quotes.ts branches on isPolymarketDepositWallet when building the Relay quote body and skips the contract-call embedding step (Polymarket sends a bare token transfer to the deposit address). - relay-submit.ts branches on isPolymarketDepositWallet to call submitPolymarketDepositWalletWithdraw, then runs waitForRelayCompletion (tolerating refund-failure to allow sweep recovery), then sweepPolymarketDepositWalletUsdce. - TransactionPayController short-circuit for isPolymarketDepositWallet now returns Relay instead of the removed PolymarketBridge. Removed: - strategy/polymarket-bridge/ directory entirely (8 files). - TransactionPayStrategy.PolymarketBridge enum entry. - PolymarketStrategy class and registration. --- .../src/TransactionPayController.ts | 2 +- .../src/constants.ts | 1 - .../transaction-pay-controller/src/index.ts | 2 - .../polymarket-bridge/PolymarketStrategy.ts | 43 --- .../src/strategy/polymarket-bridge/intent.ts | 173 ------------ .../polymarket-bridge/polymarket-quotes.ts | 186 ------------- .../polymarket/calldata.ts} | 0 .../polymarket}/constants.ts | 4 +- .../polymarket}/deposit-wallet.ts | 0 .../src/strategy/relay/polymarket/quotes.ts | 40 +++ .../polymarket}/relayer-api.ts | 26 +- .../polymarket}/types.ts | 18 +- .../polymarket}/wallet-batch-typed-data.ts | 0 .../polymarket/withdraw.ts} | 253 +++++------------- .../src/strategy/relay/relay-quotes.ts | 37 +-- .../src/strategy/relay/relay-submit.ts | 53 ++++ .../src/utils/feature-flags.ts | 2 +- .../src/utils/strategy.ts | 4 - 18 files changed, 201 insertions(+), 643 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge/polymarket-calldata.ts => relay/polymarket/calldata.ts} (100%) rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge => relay/polymarket}/constants.ts (93%) rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge => relay/polymarket}/deposit-wallet.ts (100%) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge => relay/polymarket}/relayer-api.ts (87%) rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge => relay/polymarket}/types.ts (67%) rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge => relay/polymarket}/wallet-batch-typed-data.ts (100%) rename packages/transaction-pay-controller/src/strategy/{polymarket-bridge/polymarket-submit.ts => relay/polymarket/withdraw.ts} (57%) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 96422e1edc..4143d89586 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -317,7 +317,7 @@ export class TransactionPayController extends BaseController< const transactionData = this.state.transactionData[transaction.id]; if (transactionData?.isPolymarketDepositWallet) { - return [TransactionPayStrategy.PolymarketBridge]; + return [TransactionPayStrategy.Relay]; } const strategyCandidates: unknown[] = diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 6b577aa1c4..5a097ee85d 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -41,7 +41,6 @@ export enum TransactionPayStrategy { Across = 'across', Bridge = 'bridge', Fiat = 'fiat', - PolymarketBridge = 'polymarket-bridge', Relay = 'relay', Test = 'test', } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index f5896500c3..dc7764daff 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -30,5 +30,3 @@ export { TransactionPayStrategy } from './constants'; export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; -export { PolymarketStrategy } from './strategy/polymarket-bridge/PolymarketStrategy'; -export type { PolymarketBridgeQuote } from './strategy/polymarket-bridge/types'; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts deleted file mode 100644 index 8207c37727..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/PolymarketStrategy.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - PayStrategy, - PayStrategyExecuteRequest, - PayStrategyGetBatchRequest, - PayStrategyGetQuotesRequest, - PayStrategyGetRefreshIntervalRequest, - TransactionPayQuote, -} from '../../types'; -import { getPolymarketBridgeQuotes } from './polymarket-quotes'; -import { submitPolymarketBridgeQuote } from './polymarket-submit'; -import type { PolymarketBridgeQuote } from './types'; - -const REFRESH_INTERVAL_MS = 25_000; - -export class PolymarketStrategy implements PayStrategy { - supports(_request: PayStrategyGetQuotesRequest): boolean { - return true; - } - - async getQuotes( - request: PayStrategyGetQuotesRequest, - ): Promise[]> { - return getPolymarketBridgeQuotes(request); - } - - async execute( - request: PayStrategyExecuteRequest, - ): Promise<{ transactionHash?: `0x${string}` }> { - return submitPolymarketBridgeQuote(request); - } - - async getBatchTransactions( - _request: PayStrategyGetBatchRequest, - ): Promise<[]> { - return []; - } - - async getRefreshInterval( - _request: PayStrategyGetRefreshIntervalRequest, - ): Promise { - return REFRESH_INTERVAL_MS; - } -} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts deleted file mode 100644 index aba23debd1..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/intent.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { CHAIN_ID_POLYGON } from '../../constants'; -import { projectLogger } from '../../logger'; -import { PUSD_ADDRESS_POLYGON } from './constants'; -import { computeDepositWalletAddress } from './deposit-wallet'; - -const log = createModuleLogger(projectLogger, 'polymarket-bridge-intent'); - -/** - * ERC-20 `transfer(address,uint256)` four-byte selector. - */ -const TOKEN_TRANSFER_SELECTOR = '0xa9059cbb'; - -/** - * Minimum length of a valid `transfer(address,uint256)` calldata string. - * 0x (2) + selector (8) + address param (64) + uint256 param (64) = 138. - */ -const TRANSFER_CALLDATA_MIN_LENGTH = 138; - -/** - * Extract the intent from a Polymarket deposit-wallet predictWithdraw - * transaction. - * - * Returns the pUSD transfer amount and the deposit wallet address for - * deposit-wallet users. Returns `undefined` for non-matching transactions - * (wrong type, wrong chain, Safe-based withdrawals, etc.). - * - * @param transaction - Transaction metadata. - * @returns The withdrawal intent or `undefined`. - */ -export function extractPolymarketWithdrawIntent( - transaction: TransactionMeta, -): { amount: bigint; depositWalletAddress: Hex } | undefined { - if (!isPredictWithdraw(transaction)) { - log('Not a predictWithdraw transaction', transaction.type); - return undefined; - } - - if (transaction.chainId !== CHAIN_ID_POLYGON) { - log('Not on Polygon', transaction.chainId); - return undefined; - } - - const transferCall = findPusdTransferCall(transaction); - - if (!transferCall) { - log('No pUSD transfer call found'); - return undefined; - } - - const { data, from: ownerAddress } = transferCall; - - const decoded = decodeTransferCalldata(data); - - if (!decoded) { - log('Failed to decode transfer calldata'); - return undefined; - } - - const depositWalletAddress = computeDepositWalletAddress(ownerAddress); - - const result = { - amount: decoded.amount, - depositWalletAddress, - }; - - log('Extracted withdraw intent', { - amount: result.amount.toString(), - depositWalletAddress: result.depositWalletAddress, - }); - - return result; -} - -/** - * Check whether a transaction is a predictWithdraw, either directly or - * via nested transactions. - * - * @param transaction - Transaction metadata. - * @returns `true` when the transaction is a predictWithdraw. - */ -function isPredictWithdraw(transaction: TransactionMeta): boolean { - return ( - transaction.type === TransactionType.predictWithdraw || - (transaction.nestedTransactions?.some( - (nt) => nt.type === TransactionType.predictWithdraw, - ) ?? - false) - ); -} - -/** - * Locate the nested or top-level call that transfers pUSD. - * - * For deposit-wallet users the transaction contains a `pUSD.transfer` call - * targeting the pUSD contract on Polygon. Safe users use a different - * calldata shape (execTransaction) which will not match here. - * - * The deposit wallet address is always recovered from `txParams.from` - * (the top-level sender), because nested transactions do not carry a - * separate `from` field. - * - * @param transaction - Transaction metadata. - * @returns The `to`, `data`, and `from` of the matching call, or `undefined`. - */ -function findPusdTransferCall( - transaction: TransactionMeta, -): { to: Hex; data: Hex; from: Hex } | undefined { - const isPusdTarget = (to?: string): boolean => - to?.toLowerCase() === PUSD_ADDRESS_POLYGON.toLowerCase(); - - const isTransferData = (data?: string): boolean => - Boolean(data?.startsWith(TOKEN_TRANSFER_SELECTOR)); - - // Check nested transactions first (batch wrapper pattern). - const nestedMatch = transaction.nestedTransactions?.find( - (nt) => isPusdTarget(nt.to) && isTransferData(nt.data), - ); - - if (nestedMatch) { - return { - to: nestedMatch.to as Hex, - data: nestedMatch.data as Hex, - from: transaction.txParams.from as Hex, - }; - } - - // Fall back to the top-level txParams. - const { txParams } = transaction; - - if (isPusdTarget(txParams.to) && isTransferData(txParams.data)) { - return { - to: txParams.to as Hex, - data: txParams.data as Hex, - from: txParams.from as Hex, - }; - } - - return undefined; -} - -/** - * Decode `transfer(address,uint256)` calldata into recipient and amount. - * - * Layout: - * - bytes 0–3 (chars 2–9 after 0x): selector `0xa9059cbb` - * - bytes 4–35 (chars 10–73): ABI-encoded address (left-padded to 32 bytes) - * - bytes 36–67 (chars 74–137): ABI-encoded uint256 - * - * @param data - Raw calldata hex string. - * @returns Decoded recipient and amount, or `undefined` if invalid. - */ -function decodeTransferCalldata( - data: Hex, -): { recipient: Hex; amount: bigint } | undefined { - if (data.length < TRANSFER_CALLDATA_MIN_LENGTH) { - return undefined; - } - - // Extract the 20-byte address from the 32-byte ABI-encoded slot. - // Chars 10–73 is the full 32-byte word; the address is the last 20 bytes (chars 34–73). - const recipient = `0x${data.slice(34, 74)}` as Hex; - - // Chars 74–137 is the 32-byte uint256 amount. - const amountHex = data.slice(74, 138); - const amount = BigInt(`0x${amountHex}`); - - return { recipient, amount }; -} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts b/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts deleted file mode 100644 index 0abf550605..0000000000 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-quotes.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; - -import { CHAIN_ID_POLYGON, TransactionPayStrategy } from '../../constants'; -import { projectLogger } from '../../logger'; -import type { - PayStrategyGetQuotesRequest, - QuoteRequest, - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types'; -import { getFiatValueFromUsd } from '../../utils/amounts'; -import { getSlippage } from '../../utils/feature-flags'; -import { getTokenFiatRate } from '../../utils/token'; -import { fetchRelayQuote } from '../relay/relay-api'; -import type { RelayQuote, RelayQuoteRequest } from '../relay/types'; -import { - PUSD_ADDRESS_POLYGON, - PUSD_DECIMALS, - USDC_E_ADDRESS_POLYGON, -} from './constants'; -import { computeDepositWalletAddress } from './deposit-wallet'; -import { extractPolymarketWithdrawIntent } from './intent'; -import type { PolymarketBridgeQuote } from './types'; - -const log = createModuleLogger(projectLogger, 'polymarket-bridge-quotes'); - -const POLYGON_CHAIN_ID_NUMBER = 137; - -export async function getPolymarketBridgeQuotes( - request: PayStrategyGetQuotesRequest, -): Promise[]> { - const intent = extractPolymarketWithdrawIntent(request.transaction); - if (!intent) { - return []; - } - - const quoteRequest = request.requests[0]; - if (!quoteRequest) { - return []; - } - - const depositWalletAddress = computeDepositWalletAddress(quoteRequest.from); - - const body = buildRelayQuoteRequest({ - intent, - quoteRequest, - depositWalletAddress, - messenger: request.messenger, - }); - - log('Fetching Relay quote', { - originCurrency: body.originCurrency, - destinationChainId: body.destinationChainId, - destinationCurrency: body.destinationCurrency, - amount: body.amount, - }); - - const relayQuote = await fetchRelayQuote( - request.messenger, - body, - request.signal, - ); - - log('Relay quote fetched', { - currencyOutAmount: relayQuote.details.currencyOut.amountFormatted, - totalImpactUsd: relayQuote.details.totalImpact.usd, - stepCount: relayQuote.steps.length, - }); - - return [ - buildTransactionPayQuote({ - relayQuote, - intent, - messenger: request.messenger, - quoteRequest, - }), - ]; -} - -function buildRelayQuoteRequest({ - intent, - quoteRequest, - depositWalletAddress, - messenger, -}: { - intent: { amount: bigint }; - quoteRequest: QuoteRequest; - depositWalletAddress: Hex; - messenger: TransactionPayControllerMessenger; -}): RelayQuoteRequest { - const slippageTolerance = new BigNumber( - getSlippage(messenger, CHAIN_ID_POLYGON, USDC_E_ADDRESS_POLYGON) * - 100 * - 100, - ).toFixed(0); - - return { - amount: intent.amount.toString(), - destinationChainId: parseInt(quoteRequest.targetChainId, 16), - destinationCurrency: quoteRequest.targetTokenAddress, - originChainId: POLYGON_CHAIN_ID_NUMBER, - originCurrency: USDC_E_ADDRESS_POLYGON, - recipient: quoteRequest.from, - refundTo: depositWalletAddress, - slippageTolerance, - tradeType: 'EXACT_INPUT', - useDepositAddress: true, - user: depositWalletAddress, - }; -} - -function buildTransactionPayQuote({ - relayQuote, - intent, - messenger, - quoteRequest, -}: { - relayQuote: RelayQuote; - intent: { amount: bigint }; - messenger: TransactionPayControllerMessenger; - quoteRequest: QuoteRequest; -}): TransactionPayQuote { - const sourceFiatRate = getTokenFiatRate( - messenger, - PUSD_ADDRESS_POLYGON, - CHAIN_ID_POLYGON, - ); - - const usdToFiatRate = - sourceFiatRate && new BigNumber(sourceFiatRate.usdRate).isGreaterThan(0) - ? new BigNumber(sourceFiatRate.fiatRate).dividedBy(sourceFiatRate.usdRate) - : new BigNumber(1); - - const sourceAmount = buildAmount( - intent.amount.toString(), - PUSD_DECIMALS, - sourceFiatRate, - ); - - const targetAmountUsd = new BigNumber( - relayQuote.details.currencyOut.amountUsd, - ); - const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); - - const providerFeeUsd = new BigNumber( - relayQuote.fees.relayer?.amountUsd ?? '0', - ).plus(relayQuote.fees.app?.amountUsd ?? '0'); - const provider = getFiatValueFromUsd(providerFeeUsd, usdToFiatRate); - - return { - original: { relayQuote }, - fees: { - metaMask: { fiat: '0', usd: '0' }, - provider, - sourceNetwork: { - estimate: { fiat: '0', usd: '0', human: '0', raw: '0' }, - max: { fiat: '0', usd: '0', human: '0', raw: '0' }, - }, - targetNetwork: { fiat: '0', usd: '0' }, - }, - sourceAmount, - targetAmount, - dust: { fiat: '0', usd: '0' }, - estimatedDuration: relayQuote.details.timeEstimate ?? 30, - strategy: TransactionPayStrategy.PolymarketBridge, - request: quoteRequest, - }; -} - -function buildAmount( - raw: string, - decimals: number, - fiatRate: { fiatRate: string; usdRate: string } | undefined, -): { fiat: string; human: string; raw: string; usd: string } { - const humanValue = new BigNumber(raw).shiftedBy(-decimals); - const human = humanValue.toString(10); - const usd = fiatRate - ? humanValue.multipliedBy(fiatRate.usdRate).toString(10) - : '0'; - const fiat = fiatRate - ? humanValue.multipliedBy(fiatRate.fiatRate).toString(10) - : '0'; - return { fiat, human, raw, usd }; -} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts similarity index 100% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-calldata.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts similarity index 93% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts index b945484889..d7f4a13764 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -26,11 +26,9 @@ export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; export const POLYMARKET_BATCH_DEADLINE_SECONDS = 240; -export const RELAYER_TERMINAL_STATES = [ +export const POLYMARKET_RELAYER_TERMINAL_STATES = [ 'STATE_MINED', 'STATE_CONFIRMED', 'STATE_FAILED', 'STATE_INVALID', ] as const; - -export const PUSD_DECIMALS = 6; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts similarity index 100% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/deposit-wallet.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts new file mode 100644 index 0000000000..2f83e71038 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts @@ -0,0 +1,40 @@ +import { BigNumber } from 'bignumber.js'; + +import { CHAIN_ID_POLYGON } from '../../../constants'; +import type { + QuoteRequest, + TransactionPayControllerMessenger, +} from '../../../types'; +import { getSlippage } from '../../../utils/feature-flags'; +import type { RelayQuoteRequest } from '../types'; +import { USDC_E_ADDRESS_POLYGON } from './constants'; +import { computeDepositWalletAddress } from './deposit-wallet'; + +const POLYGON_CHAIN_ID_NUMBER = 137; + +export function buildPolymarketDepositWalletQuoteBody( + request: QuoteRequest, + messenger: TransactionPayControllerMessenger, +): RelayQuoteRequest { + const depositWalletAddress = computeDepositWalletAddress(request.from); + + const slippageTolerance = new BigNumber( + getSlippage(messenger, CHAIN_ID_POLYGON, USDC_E_ADDRESS_POLYGON) * + 100 * + 100, + ).toFixed(0); + + return { + amount: request.sourceTokenAmount, + destinationChainId: parseInt(request.targetChainId, 16), + destinationCurrency: request.targetTokenAddress, + originChainId: POLYGON_CHAIN_ID_NUMBER, + originCurrency: USDC_E_ADDRESS_POLYGON, + recipient: request.from, + refundTo: depositWalletAddress, + slippageTolerance, + tradeType: 'EXACT_INPUT', + useDepositAddress: true, + user: depositWalletAddress, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts similarity index 87% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts index fc8c180944..bf861af8e5 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts @@ -1,13 +1,13 @@ import { createModuleLogger } from '@metamask/utils'; -import { projectLogger } from '../../logger'; -import { RELAYER_TERMINAL_STATES } from './constants'; +import { projectLogger } from '../../../logger'; +import { POLYMARKET_RELAYER_TERMINAL_STATES } from './constants'; import type { - PolymarketBridgeRelayerStatusResponse, - PolymarketBridgeRelayerSubmitRequest, - PolymarketBridgeRelayerSubmitResponse, PolymarketRelayerProxyEnvelope, PolymarketRelayerState, + PolymarketRelayerStatusResponse, + PolymarketRelayerSubmitRequest, + PolymarketRelayerSubmitResponse, } from './types'; const log = createModuleLogger(projectLogger, 'polymarket-relayer-api'); @@ -50,12 +50,12 @@ export class PolymarketRelayerApi { } async submit( - request: PolymarketBridgeRelayerSubmitRequest, - ): Promise { + request: PolymarketRelayerSubmitRequest, + ): Promise { log('Submitting transaction', { from: request.from, to: request.to }); const result = - await this.#postEnvelope({ + await this.#postEnvelope({ path: '/submit', method: 'POST', body: request, @@ -71,10 +71,10 @@ export class PolymarketRelayerApi { async getTransaction( transactionId: string, - ): Promise { + ): Promise { const result = await this.#postEnvelope< - | PolymarketBridgeRelayerStatusResponse - | PolymarketBridgeRelayerStatusResponse[] + | PolymarketRelayerStatusResponse + | PolymarketRelayerStatusResponse[] >({ path: '/transaction', method: 'GET', @@ -86,7 +86,7 @@ export class PolymarketRelayerApi { async pollUntilTerminal( transactionId: string, - ): Promise { + ): Promise { log('Starting polling', { transactionId }); for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { @@ -197,7 +197,7 @@ export class PolymarketRelayerApi { } function isTerminalState(state: PolymarketRelayerState): boolean { - return (RELAYER_TERMINAL_STATES as readonly string[]).includes(state); + return (POLYMARKET_RELAYER_TERMINAL_STATES as readonly string[]).includes(state); } async function delay(ms: number): Promise { diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts similarity index 67% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts index 855e91cd37..6eefaf1f66 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts @@ -1,22 +1,12 @@ import type { Hex } from '@metamask/utils'; -import type { RelayQuote } from '../relay/types'; - -/** - * Strategy-level quote type. The Polymarket bridge withdraw flow delegates - * cross-chain routing to Relay, so the quote it carries is a Relay quote. - */ -export type PolymarketBridgeQuote = { - relayQuote: RelayQuote; -}; - -export type PolymarketBridgeWalletCall = { +export type PolymarketWalletCall = { target: Hex; value: bigint; data: Hex; }; -export type PolymarketBridgeRelayerSubmitRequest = { +export type PolymarketRelayerSubmitRequest = { type: 'WALLET'; from: Hex; to: Hex; @@ -33,12 +23,12 @@ export type PolymarketBridgeRelayerSubmitRequest = { }; }; -export type PolymarketBridgeRelayerSubmitResponse = { +export type PolymarketRelayerSubmitResponse = { transactionID: string; state: string; }; -export type PolymarketBridgeRelayerStatusResponse = { +export type PolymarketRelayerStatusResponse = { transactionHash: string | null; state: PolymarketRelayerState; from: string; diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts similarity index 100% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/wallet-batch-typed-data.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts diff --git a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts similarity index 57% rename from packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts rename to packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index 4d229869cb..c42b61d2b4 100644 --- a/packages/transaction-pay-controller/src/strategy/polymarket-bridge/polymarket-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -2,18 +2,23 @@ import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { CHAIN_ID_POLYGON } from '../../constants'; -import { projectLogger } from '../../logger'; +import { CHAIN_ID_POLYGON } from '../../../constants'; +import { projectLogger } from '../../../logger'; import type { PayStrategyExecuteRequest, TransactionPayControllerMessenger, TransactionPayQuote, -} from '../../types'; -import { getPolymarketRelayerUrl } from '../../utils/feature-flags'; -import { getLiveTokenBalance } from '../../utils/token'; -import { updateTransaction } from '../../utils/transaction'; -import { getRelayStatus } from '../relay/relay-api'; -import type { RelayQuote, RelayTransactionStep } from '../relay/types'; +} from '../../../types'; +import { getPolymarketRelayerUrl } from '../../../utils/feature-flags'; +import { getLiveTokenBalance } from '../../../utils/token'; +import { updateTransaction } from '../../../utils/transaction'; +import type { RelayQuote, RelayTransactionStep } from '../types'; +import { + encodeApprove, + encodeUnwrap, + encodeWrap, + extractErc20TransferRecipient, +} from './calldata'; import { DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, POLYMARKET_BATCH_DEADLINE_SECONDS, @@ -23,97 +28,27 @@ import { USDC_E_ADDRESS_POLYGON, } from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; -import { - encodeApprove, - encodeUnwrap, - encodeWrap, - extractErc20TransferRecipient, -} from './polymarket-calldata'; import { PolymarketRelayerApi } from './relayer-api'; import type { - PolymarketBridgeQuote, - PolymarketBridgeRelayerSubmitRequest, - PolymarketBridgeWalletCall, + PolymarketRelayerSubmitRequest, + PolymarketWalletCall, } from './types'; import { buildWalletBatchTypedData } from './wallet-batch-typed-data'; -const log = createModuleLogger(projectLogger, 'polymarket-bridge-submit'); +const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); const POLYGON_CHAIN_ID_NUMBER = 137; const WALLET_BUSY_RETRY_ATTEMPTS = 5; const WALLET_BUSY_RETRY_DELAY_MS = 3_000; -const RELAY_STATUS_POLL_INTERVAL_MS = 5_000; -const RELAY_STATUS_POLL_MAX_ATTEMPTS = 120; - -type RelayPollOutcome = - | { kind: 'success'; targetHash: Hex } - | { kind: 'refunded' } - | { kind: 'failure' } - | { kind: 'timeout' }; - -export async function submitPolymarketBridgeQuote( - request: PayStrategyExecuteRequest, -): Promise<{ transactionHash?: Hex }> { - const quote = request.quotes[0]; - if (!quote) { - throw new Error('Polymarket bridge submit: no quote provided'); - } - - markIntentComplete(request, quote); - - const from = quote.request.from; +export async function submitPolymarketDepositWalletWithdraw( + quote: TransactionPayQuote, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise<{ sourceHash: Hex }> { const depositWalletAddress = computeDepositWalletAddress(from); - const relayerApi = new PolymarketRelayerApi( - getPolymarketRelayerUrl(request.messenger), - ); - - const sourceHash = await submitUnwrapToRelayDepositAddress({ - quote, - from, - depositWalletAddress, - messenger: request.messenger, - relayerApi, - }); - - updateSourceHash(request, sourceHash); - - const relayOutcome = await pollRelayStatusUntilTerminal( - getRelayRequestId(quote.original.relayQuote), - ); - log('Relay polling complete', { kind: relayOutcome.kind }); - - await sweepDepositWalletUsdce({ - messenger: request.messenger, - from, - depositWalletAddress, - relayerApi, - }); - - if (relayOutcome.kind === 'success') { - return { transactionHash: relayOutcome.targetHash }; - } - - return { transactionHash: sourceHash }; -} - -async function submitUnwrapToRelayDepositAddress({ - quote, - from, - depositWalletAddress, - messenger, - relayerApi, -}: { - quote: TransactionPayQuote; - from: Hex; - depositWalletAddress: Hex; - messenger: TransactionPayControllerMessenger; - relayerApi: PolymarketRelayerApi; -}): Promise { - const relayDepositAddress = extractRelayDepositAddress( - quote.original.relayQuote, - ); + const relayDepositAddress = extractRelayDepositAddress(quote.original); const amount = BigInt(quote.sourceAmount.raw); log('Submitting unwrap batch to Relay deposit address', { @@ -122,6 +57,8 @@ async function submitUnwrapToRelayDepositAddress({ amount: amount.toString(), }); + const relayerApi = new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); + const result = await submitDepositWalletBatch({ from, depositWalletAddress, @@ -145,20 +82,20 @@ async function submitUnwrapToRelayDepositAddress({ relayerApi, }); - return result.relayerTransactionHash; + return { sourceHash: result.relayerTransactionHash }; } -async function sweepDepositWalletUsdce({ - messenger, - from, - depositWalletAddress, - relayerApi, -}: { - messenger: TransactionPayControllerMessenger; - from: Hex; - depositWalletAddress: Hex; - relayerApi: PolymarketRelayerApi; -}): Promise { +export async function sweepPolymarketDepositWalletUsdce( + request: PayStrategyExecuteRequest, +): Promise { + const { messenger } = request; + const from = request.quotes[0]?.request.from; + if (!from) { + return; + } + + const depositWalletAddress = computeDepositWalletAddress(from); + let usdceBalance: bigint; try { const raw = await getLiveTokenBalance( @@ -183,6 +120,8 @@ async function sweepDepositWalletUsdce({ return; } + const relayerApi = new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); + try { const result = await submitDepositWalletBatch({ from, @@ -191,7 +130,10 @@ async function sweepDepositWalletUsdce({ { target: USDC_E_ADDRESS_POLYGON, value: 0n, - data: encodeApprove(POLYMARKET_COLLATERAL_ONRAMP_POLYGON, usdceBalance), + data: encodeApprove( + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + usdceBalance, + ), }, { target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, @@ -215,7 +157,24 @@ async function sweepDepositWalletUsdce({ } } -export async function submitDepositWalletBatch({ +export function setPolymarketSourceHash( + request: PayStrategyExecuteRequest, + sourceHash: Hex, +): void { + updateTransaction( + { + transactionId: request.transaction.id, + messenger: request.messenger, + note: 'Add source hash from Polymarket relayer', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; + }, + ); +} + +async function submitDepositWalletBatch({ from, depositWalletAddress, calls, @@ -224,7 +183,7 @@ export async function submitDepositWalletBatch({ }: { from: Hex; depositWalletAddress: Hex; - calls: PolymarketBridgeWalletCall[]; + calls: PolymarketWalletCall[]; messenger: TransactionPayControllerMessenger; relayerApi: PolymarketRelayerApi; }): Promise<{ relayerTransactionHash: Hex }> { @@ -272,7 +231,7 @@ async function submitDepositWalletBatchOnce({ }: { from: Hex; depositWalletAddress: Hex; - calls: PolymarketBridgeWalletCall[]; + calls: PolymarketWalletCall[]; messenger: TransactionPayControllerMessenger; relayerApi: PolymarketRelayerApi; }): Promise<{ relayerTransactionHash: Hex }> { @@ -297,7 +256,7 @@ async function submitDepositWalletBatchOnce({ SignTypedDataVersion.V4, )) as Hex; - const submitRequest: PolymarketBridgeRelayerSubmitRequest = { + const submitRequest: PolymarketRelayerSubmitRequest = { type: 'WALLET', from, to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, @@ -329,13 +288,13 @@ async function submitDepositWalletBatchOnce({ terminalStatus.state === 'STATE_INVALID' ) { throw new Error( - `Polymarket bridge withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, + `Polymarket deposit wallet withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, ); } if (!terminalStatus.transactionHash) { throw new Error( - `Polymarket bridge withdraw: terminal state=${terminalStatus.state} but no transactionHash`, + `Polymarket deposit wallet withdraw: terminal state=${terminalStatus.state} but no transactionHash`, ); } @@ -349,48 +308,12 @@ async function submitDepositWalletBatchOnce({ }; } -async function pollRelayStatusUntilTerminal( - requestId: string, -): Promise { - for (let attempt = 0; attempt < RELAY_STATUS_POLL_MAX_ATTEMPTS; attempt++) { - try { - const status = await getRelayStatus(requestId); - log('Relay status', { - attempt, - status: status.status, - txHashes: status.txHashes, - }); - - if (status.status === 'success' && status.txHashes?.length) { - return { - kind: 'success', - targetHash: status.txHashes[status.txHashes.length - 1] as Hex, - }; - } - - if (status.status === 'refunded') { - return { kind: 'refunded' }; - } - - if (status.status === 'failure') { - return { kind: 'failure' }; - } - } catch (error) { - log('Relay status poll error', { attempt, error }); - } - - await delay(RELAY_STATUS_POLL_INTERVAL_MS); - } - - return { kind: 'timeout' }; -} - function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); if (!depositStep || depositStep.kind !== 'transaction') { throw new Error( - 'Polymarket bridge submit: Relay quote has no deposit step', + 'Polymarket deposit wallet withdraw: Relay quote has no deposit step', ); } @@ -399,55 +322,13 @@ function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { if (!depositCallData) { throw new Error( - 'Polymarket bridge submit: Relay quote deposit step is missing calldata', + 'Polymarket deposit wallet withdraw: Relay quote deposit step is missing calldata', ); } return extractErc20TransferRecipient(depositCallData); } -function getRelayRequestId(relayQuote: RelayQuote): string { - const requestId = relayQuote.steps[0]?.requestId; - if (!requestId) { - throw new Error('Polymarket bridge submit: Relay quote has no requestId'); - } - return requestId; -} - -function markIntentComplete( - request: PayStrategyExecuteRequest, - quote: TransactionPayQuote, -): void { - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Mark intent complete at Polymarket bridge execute start', - }, - (tx) => { - tx.isIntentComplete = true; - }, - ); - void quote; -} - -function updateSourceHash( - request: PayStrategyExecuteRequest, - sourceHash: Hex, -): void { - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Add source hash from Polymarket relayer', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = sourceHash; - }, - ); -} - async function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 3ca5e23e96..c665d50cd8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -52,6 +52,7 @@ import { } from '../../utils/token'; import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; +import { buildPolymarketDepositWalletQuoteBody } from './polymarket/quotes'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { @@ -233,24 +234,28 @@ async function getSingleQuote( isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, sourceChainId); - const body: RelayQuoteRequest = { - amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, - destinationChainId: Number(targetChainId), - destinationCurrency: targetTokenAddress, - originChainId: Number(sourceChainId), - originCurrency: sourceTokenAddress, - ...(useExecute - ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } - : {}), - recipient: from, - slippageTolerance, - tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', - user: from, - }; + const body: RelayQuoteRequest = request.isPolymarketDepositWallet + ? buildPolymarketDepositWalletQuoteBody(request, messenger) + : { + amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, + destinationChainId: Number(targetChainId), + destinationCurrency: targetTokenAddress, + originChainId: Number(sourceChainId), + originCurrency: sourceTokenAddress, + ...(useExecute + ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } + : {}), + recipient: from, + slippageTolerance, + tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + user: from, + }; // Skip transaction processing for post-quote flows - the original transaction - // will be included in the batch separately, not as part of the quote - if (!request.isPostQuote) { + // will be included in the batch separately, not as part of the quote. + // Skip for Polymarket deposit wallet flows - the source is already a + // bridged token transfer, not a contract call to embed. + if (!request.isPostQuote && !request.isPolymarketDepositWallet) { await processTransactions(transaction, request, body, messenger); } else if (request.refundTo) { // For post-quote flows, honour the caller-specified refund address so that diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 0082b6af66..665104f660 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -37,6 +37,11 @@ import { RELAY_PENDING_STATUSES, } from './constants'; import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; +import { + setPolymarketSourceHash, + submitPolymarketDepositWalletWithdraw, + sweepPolymarketDepositWalletUsdce, +} from './polymarket/withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, @@ -90,6 +95,10 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); + if (quote.request.isPolymarketDepositWallet) { + return executePolymarketDepositWalletQuote(quote, messenger, transaction); + } + updateTransaction( { transactionId: transaction.id, @@ -143,6 +152,50 @@ async function executeSingleQuote( return { transactionHash: targetHash }; } +async function executePolymarketDepositWalletQuote( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): Promise<{ transactionHash?: Hex }> { + const request: PayStrategyExecuteRequest = { + quotes: [quote], + messenger, + transaction, + accountSupports7702: false, + isSmartTransaction: () => false, + }; + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Mark intent complete at Polymarket deposit wallet execute start', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + + const { sourceHash } = await submitPolymarketDepositWalletWithdraw( + quote, + quote.request.from, + messenger, + ); + + setPolymarketSourceHash(request, sourceHash); + + let targetHash: Hex | undefined; + try { + targetHash = await waitForRelayCompletion(quote.original, messenger); + } catch (error) { + log('Relay polling ended in failure (refund expected)', { error }); + } + + await sweepPolymarketDepositWalletUsdce(request); + + return { transactionHash: targetHash ?? sourceHash }; +} + async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 09d72ae508..ab490d6d24 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -4,7 +4,7 @@ import { uniq } from 'lodash'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; -import { POLYMARKET_RELAYER_PROXY_URL_PROD } from '../strategy/polymarket-bridge/constants'; +import { POLYMARKET_RELAYER_PROXY_URL_PROD } from '../strategy/relay/polymarket/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index f01a332c19..3ec3ef5ca8 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -2,7 +2,6 @@ import { TransactionPayStrategy } from '../constants'; import { AcrossStrategy } from '../strategy/across/AcrossStrategy'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; -import { PolymarketStrategy } from '../strategy/polymarket-bridge/PolymarketStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import type { @@ -38,9 +37,6 @@ export function getStrategyByName( case TransactionPayStrategy.Fiat: return new FiatStrategy() as never; - case TransactionPayStrategy.PolymarketBridge: - return new PolymarketStrategy() as never; - case TransactionPayStrategy.Test: return new TestStrategy() as never; From 33eb5e0f2efeb94654746ae1c8e2a69db0f12a89 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:25:02 +0100 Subject: [PATCH 15/16] refactor(transaction-pay-controller): tighten Polymarket integration into Relay strategy Five cleanups based on review: 1. Use @ethersproject/{abi,address,bytes,keccak256} standard helpers in computeDepositWalletAddress instead of the hand-rolled abiEncode, hexZeroPad, hexConcat, and getCreate2Address. The Solady ERC-1967 initCodeHash bytecode-prefix math stays as-is because no standard library reproduces it. 2. Inline encodeUnwrap/encodeWrap shared selector+args pattern. encodeTwoArg/encodeThreeArg helpers removed - the two call sites carry the selector literal directly and share the same padAddress and padUint256 primitives. 3. relay-quotes.ts builds the standard quote body once, then patches the Polymarket overrides (originCurrency, user, refundTo, useDepositAddress) via applyPolymarketDepositWalletOverrides. No more parallel body-construction branch. 4. relay-submit.ts consolidates the parallel executePolymarketDepositWalletQuote function into the single executeSingleQuote. Source-leg branches on isHyperliquidSource vs isPolymarketDepositWallet vs default, then runs a unified waitForRelayCompletion with tolerateFailure=true for Polymarket. waitForRelayCompletion's signature is now (quote, messenger, { onSourceHash?, tolerateFailure? }) and returns Hex | undefined. The sweep call happens only when isPolymarket, between the status poll and the isIntentComplete marker. 5. polymarket/withdraw.ts exports are slimmed to submitPolymarketWithdraw (source leg) and sweepPolymarketDepositWallet (post-completion). setPolymarketSourceHash is inlined into relay-submit as setRelaySourceHash since it is a generic Relay-flow concern, not Polymarket-specific. --- .../transaction-pay-controller/package.json | 3 + .../src/strategy/relay/polymarket/calldata.ts | 22 +-- .../relay/polymarket/deposit-wallet.ts | 74 ++-------- .../src/strategy/relay/polymarket/quotes.ts | 40 ++---- .../src/strategy/relay/polymarket/withdraw.ts | 32 +---- .../src/strategy/relay/relay-quotes.ts | 36 ++--- .../src/strategy/relay/relay-submit.ts | 133 ++++++++---------- 7 files changed, 112 insertions(+), 228 deletions(-) diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 39349b2ef1..0ca99ae817 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -55,7 +55,10 @@ }, "dependencies": { "@ethersproject/abi": "^5.7.0", + "@ethersproject/address": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", "@ethersproject/contracts": "^5.7.0", + "@ethersproject/keccak256": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/assets-controller": "^6.4.0", "@metamask/assets-controllers": "^106.0.0", diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts index 26a8ecaaaa..6fc309d50b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts @@ -1,12 +1,9 @@ import type { Hex } from '@metamask/utils'; const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; -const ERC20_APPROVE_SELECTOR = '095ea7b3'; -const POLYMARKET_UNWRAP_SELECTOR = '8cc7104f'; -const POLYMARKET_WRAP_SELECTOR = '62355638'; export function encodeApprove(spender: Hex, amount: bigint): Hex { - return encodeTwoArg(ERC20_APPROVE_SELECTOR, spender, amount); + return `0x095ea7b3${padAddress(spender)}${padUint256(amount)}` as Hex; } export function encodeUnwrap({ @@ -18,7 +15,7 @@ export function encodeUnwrap({ recipient: Hex; amount: bigint; }): Hex { - return encodeThreeArg(POLYMARKET_UNWRAP_SELECTOR, asset, recipient, amount); + return `0x8cc7104f${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; } export function encodeWrap({ @@ -30,7 +27,7 @@ export function encodeWrap({ recipient: Hex; amount: bigint; }): Hex { - return encodeThreeArg(POLYMARKET_WRAP_SELECTOR, asset, recipient, amount); + return `0x62355638${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; } export function extractErc20TransferRecipient(data: Hex): Hex { @@ -42,19 +39,6 @@ export function extractErc20TransferRecipient(data: Hex): Hex { return `0x${data.slice(34, 74)}` as Hex; } -function encodeTwoArg(selector: string, address: Hex, amount: bigint): Hex { - return `0x${selector}${padAddress(address)}${padUint256(amount)}` as Hex; -} - -function encodeThreeArg( - selector: string, - asset: Hex, - recipient: Hex, - amount: bigint, -): Hex { - return `0x${selector}${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; -} - function padAddress(address: Hex): string { return address.slice(2).toLowerCase().padStart(64, '0'); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts index fb1c41803c..abf233b877 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts @@ -1,3 +1,7 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import { getCreate2Address } from '@ethersproject/address'; +import { hexConcat, hexZeroPad } from '@ethersproject/bytes'; +import { keccak256 } from '@ethersproject/keccak256'; import type { Hex } from '@metamask/utils'; import { @@ -24,13 +28,13 @@ const ERC1967_PREFIX = 0x61003d3d8160233d3973n; export function computeDepositWalletAddress(ownerAddress: string): Hex { const walletId = hexZeroPad(ownerAddress.toLowerCase(), 32); - const args = abiEncode( + const args = defaultAbiCoder.encode( ['address', 'bytes32'], [DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, walletId], ); const salt = keccak256(args); - const bytecodeHash = initCodeHashERC1967( + const initCodeHash = computeSoladyERC1967InitCodeHash( DEPOSIT_WALLET_IMPLEMENTATION_POLYGON, args, ); @@ -38,17 +42,20 @@ export function computeDepositWalletAddress(ownerAddress: string): Hex { return getCreate2Address( DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, salt, - bytecodeHash, - ); + initCodeHash, + ) as Hex; } -function initCodeHashERC1967(implementation: string, args: string): string { - const n = BigInt((args.length - 2) / 2); - const combined = ERC1967_PREFIX + (n << 56n); +function computeSoladyERC1967InitCodeHash( + implementation: string, + args: string, +): string { + const argByteLength = BigInt((args.length - 2) / 2); + const prefixWithLength = ERC1967_PREFIX + (argByteLength << 56n); return keccak256( hexConcat([ - bigintToHex(combined, 10), + `0x${prefixWithLength.toString(16).padStart(20, '0')}`, implementation, '0x6009', ERC1967_CONST2, @@ -57,54 +64,3 @@ function initCodeHashERC1967(implementation: string, args: string): string { ]), ); } - -function bigintToHex(value: bigint, byteLength: number): string { - const hex = value.toString(16).padStart(byteLength * 2, '0'); - return `0x${hex}`; -} - -function hexZeroPad(value: string, length: number): string { - const stripped = value.startsWith('0x') ? value.slice(2) : value; - return `0x${stripped.padStart(length * 2, '0')}`; -} - -function abiEncode(types: string[], values: string[]): string { - const encoded = types.map((type, i) => { - const val = values[i]; - if (type === 'address') { - return hexZeroPad(val, 32); - } - if (type === 'bytes32') { - return val.startsWith('0x') ? val : `0x${val}`; - } - throw new Error(`Unsupported ABI type: ${type}`); - }); - - return `0x${encoded.map((e) => e.slice(2)).join('')}`; -} - -function keccak256(data: string): string { - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - const { keccak256: k } = require('@ethersproject/keccak256'); - return k(data) as string; -} - -function hexConcat(items: string[]): string { - return `0x${items.map((item) => (item.startsWith('0x') ? item.slice(2) : item)).join('')}`; -} - -function getCreate2Address( - deployer: string, - salt: string, - bytecodeHash: string, -): Hex { - const data = hexConcat([ - '0xff', - hexZeroPad(deployer, 20), - salt, - bytecodeHash, - ]); - - const hash = keccak256(data); - return `0x${hash.slice(26)}` as Hex; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts index 2f83e71038..dc8c3639e2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts @@ -1,40 +1,16 @@ -import { BigNumber } from 'bignumber.js'; - -import { CHAIN_ID_POLYGON } from '../../../constants'; -import type { - QuoteRequest, - TransactionPayControllerMessenger, -} from '../../../types'; -import { getSlippage } from '../../../utils/feature-flags'; +import type { QuoteRequest } from '../../../types'; import type { RelayQuoteRequest } from '../types'; import { USDC_E_ADDRESS_POLYGON } from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; -const POLYGON_CHAIN_ID_NUMBER = 137; - -export function buildPolymarketDepositWalletQuoteBody( +export function applyPolymarketDepositWalletOverrides( + body: RelayQuoteRequest, request: QuoteRequest, - messenger: TransactionPayControllerMessenger, -): RelayQuoteRequest { +): void { const depositWalletAddress = computeDepositWalletAddress(request.from); - const slippageTolerance = new BigNumber( - getSlippage(messenger, CHAIN_ID_POLYGON, USDC_E_ADDRESS_POLYGON) * - 100 * - 100, - ).toFixed(0); - - return { - amount: request.sourceTokenAmount, - destinationChainId: parseInt(request.targetChainId, 16), - destinationCurrency: request.targetTokenAddress, - originChainId: POLYGON_CHAIN_ID_NUMBER, - originCurrency: USDC_E_ADDRESS_POLYGON, - recipient: request.from, - refundTo: depositWalletAddress, - slippageTolerance, - tradeType: 'EXACT_INPUT', - useDepositAddress: true, - user: depositWalletAddress, - }; + body.originCurrency = USDC_E_ADDRESS_POLYGON; + body.user = depositWalletAddress; + body.refundTo = depositWalletAddress; + body.useDepositAddress = true; } diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index c42b61d2b4..0cc9969955 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -5,13 +5,11 @@ import { createModuleLogger } from '@metamask/utils'; import { CHAIN_ID_POLYGON } from '../../../constants'; import { projectLogger } from '../../../logger'; import type { - PayStrategyExecuteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../../types'; import { getPolymarketRelayerUrl } from '../../../utils/feature-flags'; import { getLiveTokenBalance } from '../../../utils/token'; -import { updateTransaction } from '../../../utils/transaction'; import type { RelayQuote, RelayTransactionStep } from '../types'; import { encodeApprove, @@ -42,7 +40,7 @@ const POLYGON_CHAIN_ID_NUMBER = 137; const WALLET_BUSY_RETRY_ATTEMPTS = 5; const WALLET_BUSY_RETRY_DELAY_MS = 3_000; -export async function submitPolymarketDepositWalletWithdraw( +export async function submitPolymarketWithdraw( quote: TransactionPayQuote, from: Hex, messenger: TransactionPayControllerMessenger, @@ -85,15 +83,10 @@ export async function submitPolymarketDepositWalletWithdraw( return { sourceHash: result.relayerTransactionHash }; } -export async function sweepPolymarketDepositWalletUsdce( - request: PayStrategyExecuteRequest, +export async function sweepPolymarketDepositWallet( + from: Hex, + messenger: TransactionPayControllerMessenger, ): Promise { - const { messenger } = request; - const from = request.quotes[0]?.request.from; - if (!from) { - return; - } - const depositWalletAddress = computeDepositWalletAddress(from); let usdceBalance: bigint; @@ -157,23 +150,6 @@ export async function sweepPolymarketDepositWalletUsdce( } } -export function setPolymarketSourceHash( - request: PayStrategyExecuteRequest, - sourceHash: Hex, -): void { - updateTransaction( - { - transactionId: request.transaction.id, - messenger: request.messenger, - note: 'Add source hash from Polymarket relayer', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = sourceHash; - }, - ); -} - async function submitDepositWalletBatch({ from, depositWalletAddress, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index c665d50cd8..8497218569 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -52,7 +52,7 @@ import { } from '../../utils/token'; import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; -import { buildPolymarketDepositWalletQuoteBody } from './polymarket/quotes'; +import { applyPolymarketDepositWalletOverrides } from './polymarket/quotes'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { @@ -234,22 +234,24 @@ async function getSingleQuote( isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, sourceChainId); - const body: RelayQuoteRequest = request.isPolymarketDepositWallet - ? buildPolymarketDepositWalletQuoteBody(request, messenger) - : { - amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, - destinationChainId: Number(targetChainId), - destinationCurrency: targetTokenAddress, - originChainId: Number(sourceChainId), - originCurrency: sourceTokenAddress, - ...(useExecute - ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } - : {}), - recipient: from, - slippageTolerance, - tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', - user: from, - }; + const body: RelayQuoteRequest = { + amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, + destinationChainId: Number(targetChainId), + destinationCurrency: targetTokenAddress, + originChainId: Number(sourceChainId), + originCurrency: sourceTokenAddress, + ...(useExecute + ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } + : {}), + recipient: from, + slippageTolerance, + tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + user: from, + }; + + if (request.isPolymarketDepositWallet) { + applyPolymarketDepositWalletOverrides(body, request); + } // Skip transaction processing for post-quote flows - the original transaction // will be included in the batch separately, not as part of the quote. diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 665104f660..2916173fd6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -38,9 +38,8 @@ import { } from './constants'; import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; import { - setPolymarketSourceHash, - submitPolymarketDepositWalletWithdraw, - sweepPolymarketDepositWalletUsdce, + sweepPolymarketDepositWallet, + submitPolymarketWithdraw, } from './polymarket/withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { @@ -95,49 +94,50 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); - if (quote.request.isPolymarketDepositWallet) { - return executePolymarketDepositWalletQuote(quote, messenger, transaction); - } + const isPolymarket = Boolean(quote.request.isPolymarketDepositWallet); + let sourceHash: Hex | undefined; - updateTransaction( - { - transactionId: transaction.id, + if (isPolymarket) { + const result = await submitPolymarketWithdraw( + quote, + quote.request.from, messenger, - note: 'Remove nonce from skipped transaction', - }, - (tx) => { - tx.txParams.nonce = undefined; - }, - ); - - if (quote.request.isHyperliquidSource) { - await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + ); + sourceHash = result.sourceHash; + setRelaySourceHash(transaction, messenger, sourceHash); } else { - await submitTransactions(quote, transaction, messenger); - } + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Remove nonce from skipped transaction', + }, + (tx) => { + tx.txParams.nonce = undefined; + }, + ); - const targetHash = await waitForRelayCompletion( - quote.original, - messenger, - (sourceHash) => { - log('Source hash received', sourceHash); + if (quote.request.isHyperliquidSource) { + await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + } else { + await submitTransactions(quote, transaction, messenger); + } + } - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Add source hash from Relay status', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = sourceHash; - }, - ); + const targetHash = await waitForRelayCompletion(quote.original, messenger, { + onSourceHash: (hash) => { + log('Source hash received', hash); + setRelaySourceHash(transaction, messenger, hash); }, - ); + tolerateFailure: isPolymarket, + }); log('Relay request completed', targetHash); + if (isPolymarket) { + await sweepPolymarketDepositWallet(quote.request.from, messenger); + } + updateTransaction( { transactionId: transaction.id, @@ -149,58 +149,37 @@ async function executeSingleQuote( }, ); - return { transactionHash: targetHash }; + return { transactionHash: targetHash ?? sourceHash }; } -async function executePolymarketDepositWalletQuote( - quote: TransactionPayQuote, - messenger: TransactionPayControllerMessenger, +function setRelaySourceHash( transaction: TransactionMeta, -): Promise<{ transactionHash?: Hex }> { - const request: PayStrategyExecuteRequest = { - quotes: [quote], - messenger, - transaction, - accountSupports7702: false, - isSmartTransaction: () => false, - }; - + messenger: TransactionPayControllerMessenger, + sourceHash: Hex, +): void { updateTransaction( { transactionId: transaction.id, messenger, - note: 'Mark intent complete at Polymarket deposit wallet execute start', + note: 'Add source hash from Relay status', }, (tx) => { - tx.isIntentComplete = true; + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; }, ); - - const { sourceHash } = await submitPolymarketDepositWalletWithdraw( - quote, - quote.request.from, - messenger, - ); - - setPolymarketSourceHash(request, sourceHash); - - let targetHash: Hex | undefined; - try { - targetHash = await waitForRelayCompletion(quote.original, messenger); - } catch (error) { - log('Relay polling ended in failure (refund expected)', { error }); - } - - await sweepPolymarketDepositWalletUsdce(request); - - return { transactionHash: targetHash ?? sourceHash }; } async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, - onSourceHash?: (hash: Hex) => void, -): Promise { + options: { + onSourceHash?: (hash: Hex) => void; + tolerateFailure?: boolean; + } = {}, +): Promise { + const { onSourceHash, tolerateFailure } = options; + const isSameChain = quote.details.currencyIn.currency.chainId === quote.details.currencyOut.currency.chainId; @@ -250,6 +229,10 @@ async function waitForRelayCompletion( } if (RELAY_FAILURE_STATUSES.includes(status.status)) { + if (tolerateFailure) { + log('Relay ended in failure status (tolerated)', status.status); + return undefined; + } throw new Error(`Relay request failed with status: ${status.status}`); } @@ -260,6 +243,10 @@ async function waitForRelayCompletion( if (hasTimeout && Date.now() - startTime >= pollingTimeout) { const statusDetail = lastStatus ? ` (last status: ${lastStatus})` : ''; + if (tolerateFailure) { + log('Relay polling timed out (tolerated)', statusDetail); + return undefined; + } throw new Error(`Relay polling timed out${statusDetail}`); } From a7ad075d3455c9a2de63cf219d3184bd5dde8769 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:44:24 +0100 Subject: [PATCH 16/16] refactor(transaction-pay-controller): cleanup Polymarket relay integration - Move polymarketRelayerUrl feature flag under payStrategies.relay so it sits with the other relay-strategy settings instead of at the feature-flag root. - Collapse the verbose JSDoc on isPolymarketDepositWallet to the single-line shape matching the existing two instances. - Remove the redundant if/else split in executeSingleQuote: the nonce-removal step now runs unconditionally before any source-leg branch, then a single if-else-if-else dispatches to hyperliquid, polymarket, or the default submitTransactions path. - Fold wallet-batch-typed-data into relayer-api.ts and drop the PolymarketRelayerApi class. Replaced with standalone utils getNonce and submitDepositWalletBatch that take the messenger directly, resolve the URL via getPolymarketRelayerUrl, build the typed data, call KeyringController:signTypedMessage, and poll the relayer to a terminal state in one call. - Inline the four-line applyPolymarketDepositWalletOverrides into withdraw.ts so the polymarket subdirectory drops the quotes.ts file entirely; the override applier sits next to the withdraw helper that uses the resulting quote. - One-line CHANGELOG entry now that the per-step detail belongs to the PR description not the changelog. --- .../transaction-pay-controller/CHANGELOG.md | 6 +- .../src/strategy/relay/polymarket/quotes.ts | 16 - .../strategy/relay/polymarket/relayer-api.ts | 399 ++++++++++++------ .../polymarket/wallet-batch-typed-data.ts | 109 ----- .../src/strategy/relay/polymarket/withdraw.ts | 161 ++----- .../src/strategy/relay/relay-quotes.ts | 2 +- .../src/strategy/relay/relay-submit.ts | 32 +- .../transaction-pay-controller/src/types.ts | 7 +- .../src/utils/feature-flags.ts | 7 +- 9 files changed, 315 insertions(+), 424 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 53ea23709b..9a6773f07a 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,11 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `PolymarketBridgeStrategy` for `predictWithdraw` transactions of deposit-wallet users via Polymarket's Bridge and Relayer APIs ([#8754](https://github.com/MetaMask/core/pull/8754)) - - Add `isPolymarketDepositWallet` flag on `TransactionConfig`. When set via `setTransactionConfig`, the controller routes the transaction's quotes and execution to `PolymarketBridgeStrategy`. - - Add `polymarketRelayerUrl` remote feature flag to override the Polymarket relayer proxy URL without a release. - - Surface bridge fees (`gasUsd + appFeeUsd + swapImpactUsd`) as `fees.provider`, and populate `sourceAmount` and `targetAmount` with fiat/USD values from token rates. - - Mark `isIntentComplete` at the start of `PolymarketBridgeStrategy.execute()` so the wrapper batch transaction is treated as confirmed by `PendingTransactionTracker` instead of failed (no on-chain receipt exists for the wrapper; the relayer broadcasts a separate transaction). +- Add Polymarket deposit-wallet support to the Relay strategy for `predictWithdraw` transactions, routed via the `isPolymarketDepositWallet` flag on `TransactionConfig` ([#8754](https://github.com/MetaMask/core/pull/8754)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts deleted file mode 100644 index dc8c3639e2..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/quotes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { QuoteRequest } from '../../../types'; -import type { RelayQuoteRequest } from '../types'; -import { USDC_E_ADDRESS_POLYGON } from './constants'; -import { computeDepositWalletAddress } from './deposit-wallet'; - -export function applyPolymarketDepositWalletOverrides( - body: RelayQuoteRequest, - request: QuoteRequest, -): void { - const depositWalletAddress = computeDepositWalletAddress(request.from); - - body.originCurrency = USDC_E_ADDRESS_POLYGON; - body.user = depositWalletAddress; - body.refundTo = depositWalletAddress; - body.useDepositAddress = true; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts index bf861af8e5..34a16029ef 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts @@ -1,13 +1,24 @@ +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../../logger'; -import { POLYMARKET_RELAYER_TERMINAL_STATES } from './constants'; +import type { TransactionPayControllerMessenger } from '../../../types'; +import { getPolymarketRelayerUrl } from '../../../utils/feature-flags'; +import { + DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + POLYMARKET_BATCH_DEADLINE_SECONDS, + POLYMARKET_RELAYER_TERMINAL_STATES, + POLYMARKET_WALLET_DOMAIN_NAME, + POLYMARKET_WALLET_DOMAIN_VERSION, +} from './constants'; import type { PolymarketRelayerProxyEnvelope, PolymarketRelayerState, PolymarketRelayerStatusResponse, PolymarketRelayerSubmitRequest, PolymarketRelayerSubmitResponse, + PolymarketWalletCall, } from './types'; const log = createModuleLogger(projectLogger, 'polymarket-relayer-api'); @@ -15,6 +26,15 @@ const log = createModuleLogger(projectLogger, 'polymarket-relayer-api'); const POLLING_INTERVAL_MS = 2000; const POLLING_MAX_ATTEMPTS = 90; +const POLYGON_CHAIN_ID_NUMBER = 137; + +const EIP712_DOMAIN_FIELDS = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + export class PolymarketRelayerError extends Error { code: string; @@ -28,176 +48,285 @@ export class PolymarketRelayerError extends Error { } } -export class PolymarketRelayerApi { - readonly #baseUrl: string; - - constructor(baseUrl: string) { - this.#baseUrl = baseUrl; - } - - async getNonce(address: string, type: 'WALLET'): Promise { - log('Fetching nonce', { address, type }); - - const result = await this.#postEnvelope<{ nonce: string }>({ - path: '/nonce', - method: 'GET', - query: { address, type }, - }); - - log('Nonce received', { nonce: result.nonce }); +export async function getNonce( + messenger: TransactionPayControllerMessenger, + address: Hex, +): Promise { + const result = await postEnvelope<{ nonce: string }>(messenger, { + path: '/nonce', + method: 'GET', + query: { address, type: 'WALLET' }, + }); + + log('Nonce received', { address, nonce: result.nonce }); + return result.nonce; +} - return result.nonce; +export async function submitDepositWalletBatch( + messenger: TransactionPayControllerMessenger, + { + from, + depositWalletAddress, + calls, + }: { + from: Hex; + depositWalletAddress: Hex; + calls: PolymarketWalletCall[]; + }, +): Promise<{ transactionHash: Hex }> { + const nonce = await getNonce(messenger, from); + const deadline = + Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; + + const typedData = buildWalletBatchTypedData({ + wallet: depositWalletAddress, + nonce, + deadline, + calls, + }); + + const signature = (await messenger.call( + 'KeyringController:signTypedMessage', + { from, data: JSON.stringify(typedData) }, + SignTypedDataVersion.V4, + )) as Hex; + + const submitRequest: PolymarketRelayerSubmitRequest = { + type: 'WALLET', + from, + to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, + nonce, + signature, + depositWalletParams: { + depositWallet: depositWalletAddress, + deadline: deadline.toString(), + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }, + }; + + const submitResponse = await postEnvelope( + messenger, + { path: '/submit', method: 'POST', body: submitRequest }, + ); + + log('Relayer accepted submission', { + transactionID: submitResponse.transactionID, + state: submitResponse.state, + }); + + const terminalStatus = await pollUntilTerminal( + messenger, + submitResponse.transactionID, + ); + + if ( + terminalStatus.state === 'STATE_FAILED' || + terminalStatus.state === 'STATE_INVALID' + ) { + throw new Error( + `Polymarket deposit wallet withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, + ); } - async submit( - request: PolymarketRelayerSubmitRequest, - ): Promise { - log('Submitting transaction', { from: request.from, to: request.to }); - - const result = - await this.#postEnvelope({ - path: '/submit', - method: 'POST', - body: request, - }); - - log('Transaction submitted', { - transactionID: result.transactionID, - state: result.state, - }); - - return result; + if (!terminalStatus.transactionHash) { + throw new Error( + `Polymarket deposit wallet withdraw: terminal state=${terminalStatus.state} but no transactionHash`, + ); } - async getTransaction( - transactionId: string, - ): Promise { - const result = await this.#postEnvelope< - | PolymarketRelayerStatusResponse - | PolymarketRelayerStatusResponse[] - >({ - path: '/transaction', - method: 'GET', - query: { id: transactionId }, - }); - - return Array.isArray(result) ? result : [result]; - } + log('Wallet batch complete', { + transactionHash: terminalStatus.transactionHash, + state: terminalStatus.state, + }); - async pollUntilTerminal( - transactionId: string, - ): Promise { - log('Starting polling', { transactionId }); + return { transactionHash: terminalStatus.transactionHash as Hex }; +} - for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { - await delay(POLLING_INTERVAL_MS); +async function getTransactionStatus( + messenger: TransactionPayControllerMessenger, + transactionId: string, +): Promise { + const result = await postEnvelope< + PolymarketRelayerStatusResponse | PolymarketRelayerStatusResponse[] + >(messenger, { + path: '/transaction', + method: 'GET', + query: { id: transactionId }, + }); + + return Array.isArray(result) ? result : [result]; +} - const statuses = await this.getTransaction(transactionId); - const latest = statuses[0]; +async function pollUntilTerminal( + messenger: TransactionPayControllerMessenger, + transactionId: string, +): Promise { + for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { + await delay(POLLING_INTERVAL_MS); - if (latest && isTerminalState(latest.state)) { - log('Reached terminal state', { - transactionId, - state: latest.state, - attempt: attempt + 1, - }); - return latest; - } + const statuses = await getTransactionStatus(messenger, transactionId); + const latest = statuses[0]; - log('Polling attempt', { + if (latest && isTerminalState(latest.state)) { + log('Reached terminal state', { transactionId, - state: latest?.state, + state: latest.state, attempt: attempt + 1, }); + return latest; } + log('Polling attempt', { + transactionId, + state: latest?.state, + attempt: attempt + 1, + }); + } + + throw new PolymarketRelayerError( + `Polling timed out after ${POLLING_MAX_ATTEMPTS} attempts`, + 'POLLING_TIMEOUT', + ); +} + +function buildWalletBatchTypedData({ + wallet, + nonce, + deadline, + calls, +}: { + wallet: Hex; + nonce: string; + deadline: number; + calls: PolymarketWalletCall[]; +}): { + domain: Record; + types: Record; + primaryType: 'Batch'; + message: Record; +} { + return { + domain: { + name: POLYMARKET_WALLET_DOMAIN_NAME, + version: POLYMARKET_WALLET_DOMAIN_VERSION, + chainId: POLYGON_CHAIN_ID_NUMBER, + verifyingContract: wallet, + }, + types: { + EIP712Domain: EIP712_DOMAIN_FIELDS, + Batch: [ + { name: 'wallet', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'calls', type: 'Call[]' }, + ], + Call: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + primaryType: 'Batch' as const, + message: { + wallet, + nonce, + deadline, + calls: calls.map((call) => ({ + target: call.target, + value: call.value.toString(), + data: call.data, + })), + }, + }; +} + +async function postEnvelope( + messenger: TransactionPayControllerMessenger, + envelope: PolymarketRelayerProxyEnvelope, +): Promise { + const url = `${getPolymarketRelayerUrl(messenger)}/transaction`; + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(envelope), + }); + } catch (error) { throw new PolymarketRelayerError( - `Polling timed out after ${POLLING_MAX_ATTEMPTS} attempts`, - 'POLLING_TIMEOUT', + `Relayer proxy request failed: ${String(error)}`, + 'REQUEST_FAILED', + error, ); } - async #postEnvelope( - envelope: PolymarketRelayerProxyEnvelope, - ): Promise { - const url = `${this.#baseUrl}/transaction`; + const text = await response.text(); - let response: Response; + let parsed: unknown; + if (text) { try { - response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(envelope), - }); + parsed = JSON.parse(text); } catch (error) { - throw new PolymarketRelayerError( - `Relayer proxy request failed: ${String(error)}`, - 'REQUEST_FAILED', - error, - ); - } - - const text = await response.text(); - - let parsed: unknown; - if (text) { - try { - parsed = JSON.parse(text); - } catch (error) { - if (!response.ok) { - throw new PolymarketRelayerError( - `Relayer proxy returned ${response.status} with non-JSON body`, - 'HTTP_ERROR', - error, - ); - } + if (!response.ok) { throw new PolymarketRelayerError( - 'Relayer proxy returned malformed JSON', - 'MALFORMED_JSON', + `Relayer proxy returned ${response.status} with non-JSON body`, + 'HTTP_ERROR', error, ); } - } - - if (!response.ok) { - const detail = - typeof parsed === 'object' && parsed !== null - ? (parsed as { error?: string; message?: string }).error ?? - (parsed as { error?: string; message?: string }).message - : undefined; - throw new PolymarketRelayerError( - detail ?? `Relayer proxy returned status ${response.status}`, - 'PROXY_ERROR', - parsed, + 'Relayer proxy returned malformed JSON', + 'MALFORMED_JSON', + error, ); } + } - if (parsed === undefined) { - throw new PolymarketRelayerError( - 'Relayer proxy returned an empty response', - 'EMPTY_RESPONSE', - ); - } + if (!response.ok) { + const detail = + typeof parsed === 'object' && parsed !== null + ? (parsed as { error?: string; message?: string }).error ?? + (parsed as { error?: string; message?: string }).message + : undefined; - if ( - typeof parsed === 'object' && - parsed !== null && - 'error' in parsed && - typeof (parsed as { error: unknown }).error === 'string' - ) { - throw new PolymarketRelayerError( - (parsed as { error: string }).error, - 'PROXY_ERROR', - ); - } + throw new PolymarketRelayerError( + detail ?? `Relayer proxy returned status ${response.status}`, + 'PROXY_ERROR', + parsed, + ); + } - return parsed as TResponse; + if (parsed === undefined) { + throw new PolymarketRelayerError( + 'Relayer proxy returned an empty response', + 'EMPTY_RESPONSE', + ); } + + if ( + typeof parsed === 'object' && + parsed !== null && + 'error' in parsed && + typeof (parsed as { error: unknown }).error === 'string' + ) { + throw new PolymarketRelayerError( + (parsed as { error: string }).error, + 'PROXY_ERROR', + ); + } + + return parsed as TResponse; } function isTerminalState(state: PolymarketRelayerState): boolean { - return (POLYMARKET_RELAYER_TERMINAL_STATES as readonly string[]).includes(state); + return (POLYMARKET_RELAYER_TERMINAL_STATES as readonly string[]).includes( + state, + ); } async function delay(ms: number): Promise { diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts deleted file mode 100644 index 9b44719b83..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/wallet-batch-typed-data.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Hex } from '@metamask/utils'; - -import { - POLYMARKET_WALLET_DOMAIN_NAME, - POLYMARKET_WALLET_DOMAIN_VERSION, -} from './constants'; - -type EIP712DomainField = { name: string; type: string }; - -const DOMAIN_FIELD_MAP: Record = { - name: { name: 'name', type: 'string' }, - version: { name: 'version', type: 'string' }, - chainId: { name: 'chainId', type: 'uint256' }, - verifyingContract: { name: 'verifyingContract', type: 'address' }, - salt: { name: 'salt', type: 'bytes32' }, -}; - -/** - * Build EIP-712 typed data for a Polymarket DepositWallet Batch. - * - * The typed data follows Polymarket's spec: - * - Domain: { name: 'DepositWallet', version: '1', chainId, verifyingContract: wallet } - * - Types: Call[] = [{ target: address, value: uint256, data: bytes }] - * Batch = [{ nonce: uint256, deadline: uint256, calls: Call[] }] - * - PrimaryType: 'Batch' - * - Message: { nonce, deadline, calls: [{ target, value, data }] } - * - * @param options - The options for building the typed data. - * @param options.wallet - The verifying contract address (the user's DepositWallet). - * @param options.nonce - The nonce for the batch. - * @param options.deadline - The expiration timestamp for the batch. - * @param options.calls - The list of calls to execute. - * @param options.chainId - The chain ID where the wallet is deployed. - * @returns The EIP-712 typed data object. - */ -export function buildWalletBatchTypedData({ - wallet, - nonce, - deadline, - calls, - chainId, -}: { - wallet: Hex; - nonce: string; - deadline: number; - calls: { target: Hex; value: bigint; data: Hex }[]; - chainId: number; -}): { - domain: Record; - types: Record; - primaryType: 'Batch'; - message: Record; -} { - const domain = { - name: POLYMARKET_WALLET_DOMAIN_NAME, - version: POLYMARKET_WALLET_DOMAIN_VERSION, - chainId, - verifyingContract: wallet, - }; - - const types = { - EIP712Domain: deriveEIP712DomainType(domain), - Batch: [ - { name: 'wallet', type: 'address' }, - { name: 'nonce', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - { name: 'calls', type: 'Call[]' }, - ], - Call: [ - { name: 'target', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'data', type: 'bytes' }, - ], - }; - - const message = { - wallet, - nonce, - deadline, - calls: calls.map((call) => ({ - target: call.target, - value: call.value.toString(), - data: call.data, - })), - }; - - return { - domain, - types, - primaryType: 'Batch' as const, - message, - }; -} - -/** - * Derive the EIP712Domain type array from a domain object. - * eth-sig-util defaults to EIP712Domain: [] when absent, breaking - * the domain separator hash. This ensures it matches ethers.js behavior. - * - * @param domain - The EIP-712 domain object. - * @returns The EIP712Domain type array in canonical order. - */ -function deriveEIP712DomainType( - domain: Record, -): EIP712DomainField[] { - return Object.keys(DOMAIN_FIELD_MAP) - .filter((key) => Object.prototype.hasOwnProperty.call(domain, key)) - .map((key) => DOMAIN_FIELD_MAP[key]); -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index 0cc9969955..64f08d75a4 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -1,16 +1,19 @@ -import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { CHAIN_ID_POLYGON } from '../../../constants'; import { projectLogger } from '../../../logger'; import type { + QuoteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../../types'; -import { getPolymarketRelayerUrl } from '../../../utils/feature-flags'; import { getLiveTokenBalance } from '../../../utils/token'; -import type { RelayQuote, RelayTransactionStep } from '../types'; +import type { + RelayQuote, + RelayQuoteRequest, + RelayTransactionStep, +} from '../types'; import { encodeApprove, encodeUnwrap, @@ -18,28 +21,31 @@ import { extractErc20TransferRecipient, } from './calldata'; import { - DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - POLYMARKET_BATCH_DEADLINE_SECONDS, POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, POLYMARKET_COLLATERAL_ONRAMP_POLYGON, PUSD_ADDRESS_POLYGON, USDC_E_ADDRESS_POLYGON, } from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; -import { PolymarketRelayerApi } from './relayer-api'; -import type { - PolymarketRelayerSubmitRequest, - PolymarketWalletCall, -} from './types'; -import { buildWalletBatchTypedData } from './wallet-batch-typed-data'; +import { submitDepositWalletBatch } from './relayer-api'; const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); -const POLYGON_CHAIN_ID_NUMBER = 137; - const WALLET_BUSY_RETRY_ATTEMPTS = 5; const WALLET_BUSY_RETRY_DELAY_MS = 3_000; +export function applyPolymarketDepositWalletOverrides( + body: RelayQuoteRequest, + request: QuoteRequest, +): void { + const depositWalletAddress = computeDepositWalletAddress(request.from); + + body.originCurrency = USDC_E_ADDRESS_POLYGON; + body.user = depositWalletAddress; + body.refundTo = depositWalletAddress; + body.useDepositAddress = true; +} + export async function submitPolymarketWithdraw( quote: TransactionPayQuote, from: Hex, @@ -55,9 +61,7 @@ export async function submitPolymarketWithdraw( amount: amount.toString(), }); - const relayerApi = new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); - - const result = await submitDepositWalletBatch({ + const { transactionHash } = await submitWithBusyRetry(messenger, { from, depositWalletAddress, calls: [ @@ -76,11 +80,9 @@ export async function submitPolymarketWithdraw( }), }, ], - messenger, - relayerApi, }); - return { sourceHash: result.relayerTransactionHash }; + return { sourceHash: transactionHash }; } export async function sweepPolymarketDepositWallet( @@ -113,10 +115,8 @@ export async function sweepPolymarketDepositWallet( return; } - const relayerApi = new PolymarketRelayerApi(getPolymarketRelayerUrl(messenger)); - try { - const result = await submitDepositWalletBatch({ + const { transactionHash } = await submitWithBusyRetry(messenger, { from, depositWalletAddress, calls: [ @@ -138,42 +138,23 @@ export async function sweepPolymarketDepositWallet( }), }, ], - messenger, - relayerApi, }); - log('USDC.e sweep: complete', { - transactionHash: result.relayerTransactionHash, - }); + log('USDC.e sweep: complete', { transactionHash }); } catch (error) { log('USDC.e sweep: batch submission failed', { error }); } } -async function submitDepositWalletBatch({ - from, - depositWalletAddress, - calls, - messenger, - relayerApi, -}: { - from: Hex; - depositWalletAddress: Hex; - calls: PolymarketWalletCall[]; - messenger: TransactionPayControllerMessenger; - relayerApi: PolymarketRelayerApi; -}): Promise<{ relayerTransactionHash: Hex }> { +async function submitWithBusyRetry( + messenger: TransactionPayControllerMessenger, + args: Parameters[1], +): Promise<{ transactionHash: Hex }> { let lastError: unknown; for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { try { - return await submitDepositWalletBatchOnce({ - from, - depositWalletAddress, - calls, - messenger, - relayerApi, - }); + return await submitDepositWalletBatch(messenger, args); } catch (error) { lastError = error; @@ -198,92 +179,6 @@ async function submitDepositWalletBatch({ throw lastError; } -async function submitDepositWalletBatchOnce({ - from, - depositWalletAddress, - calls, - messenger, - relayerApi, -}: { - from: Hex; - depositWalletAddress: Hex; - calls: PolymarketWalletCall[]; - messenger: TransactionPayControllerMessenger; - relayerApi: PolymarketRelayerApi; -}): Promise<{ relayerTransactionHash: Hex }> { - const nonce = await relayerApi.getNonce(from, 'WALLET'); - const deadline = - Math.floor(Date.now() / 1000) + POLYMARKET_BATCH_DEADLINE_SECONDS; - - const typedData = buildWalletBatchTypedData({ - wallet: depositWalletAddress, - nonce, - deadline, - calls, - chainId: POLYGON_CHAIN_ID_NUMBER, - }); - - const signature = (await messenger.call( - 'KeyringController:signTypedMessage', - { - from, - data: JSON.stringify(typedData), - }, - SignTypedDataVersion.V4, - )) as Hex; - - const submitRequest: PolymarketRelayerSubmitRequest = { - type: 'WALLET', - from, - to: DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - nonce, - signature, - depositWalletParams: { - depositWallet: depositWalletAddress, - deadline: deadline.toString(), - calls: calls.map((call) => ({ - target: call.target, - value: call.value.toString(), - data: call.data, - })), - }, - }; - - const submitResponse = await relayerApi.submit(submitRequest); - log('Relayer accepted submission', { - transactionID: submitResponse.transactionID, - state: submitResponse.state, - }); - - const terminalStatus = await relayerApi.pollUntilTerminal( - submitResponse.transactionID, - ); - - if ( - terminalStatus.state === 'STATE_FAILED' || - terminalStatus.state === 'STATE_INVALID' - ) { - throw new Error( - `Polymarket deposit wallet withdraw failed: relayer state=${terminalStatus.state}, txId=${submitResponse.transactionID}`, - ); - } - - if (!terminalStatus.transactionHash) { - throw new Error( - `Polymarket deposit wallet withdraw: terminal state=${terminalStatus.state} but no transactionHash`, - ); - } - - log('Wallet batch complete', { - transactionHash: terminalStatus.transactionHash, - state: terminalStatus.state, - }); - - return { - relayerTransactionHash: terminalStatus.transactionHash as Hex, - }; -} - function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 8497218569..90ececd02d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -52,7 +52,7 @@ import { } from '../../utils/token'; import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; -import { applyPolymarketDepositWalletOverrides } from './polymarket/quotes'; +import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 2916173fd6..a29c4f3b02 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -97,7 +97,20 @@ async function executeSingleQuote( const isPolymarket = Boolean(quote.request.isPolymarketDepositWallet); let sourceHash: Hex | undefined; - if (isPolymarket) { + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Remove nonce from skipped transaction', + }, + (tx) => { + tx.txParams.nonce = undefined; + }, + ); + + if (quote.request.isHyperliquidSource) { + await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + } else if (isPolymarket) { const result = await submitPolymarketWithdraw( quote, quote.request.from, @@ -106,22 +119,7 @@ async function executeSingleQuote( sourceHash = result.sourceHash; setRelaySourceHash(transaction, messenger, sourceHash); } else { - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Remove nonce from skipped transaction', - }, - (tx) => { - tx.txParams.nonce = undefined; - }, - ); - - if (quote.request.isHyperliquidSource) { - await submitHyperliquidWithdraw(quote, quote.request.from, messenger); - } else { - await submitTransactions(quote, transaction, messenger); - } + await submitTransactions(quote, transaction, messenger); } const targetHash = await waitForRelayCompletion(quote.original, messenger, { diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index f4af95201e..6a381c2647 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -100,12 +100,7 @@ export type TransactionConfig = { */ isHyperliquidSource?: boolean; - /** - * Whether the source of funds is a Polymarket deposit wallet. - * When true, transaction-pay routes the post-quote `predictWithdraw` to - * the Polymarket Bridge strategy, which signs a deposit-wallet `Batch` - * and submits it via the Polymarket relayer proxy. - */ + /** Whether the source of funds is a Polymarket deposit wallet. */ isPolymarketDepositWallet?: boolean; /** Whether the user has selected the maximum amount. */ diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index ab490d6d24..24568fbfc9 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -41,7 +41,6 @@ type FeatureFlagsRaw = { } >; }; - polymarketRelayerUrl?: string; relayDisabledGasStationChains?: Hex[]; relayExecuteUrl?: string; relayFallbackGas?: { @@ -126,6 +125,7 @@ export type PayStrategiesConfigRaw = { enabled?: boolean; gaslessEnabled?: boolean; originGasOverhead?: string; + polymarketRelayerUrl?: string; pollingInterval?: number; pollingTimeout?: number; }; @@ -566,7 +566,10 @@ export function getPolymarketRelayerUrl( (state.remoteFeatureFlags?.confirmations_pay as | FeatureFlagsRaw | undefined) ?? {}; - return featureFlags.polymarketRelayerUrl ?? DEFAULT_POLYMARKET_RELAYER_URL; + return ( + featureFlags.payStrategies?.relay?.polymarketRelayerUrl ?? + DEFAULT_POLYMARKET_RELAYER_URL + ); } /**