From 220ef89506fc48822a31bfc7670261b51afab94a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 19:16:13 +0100 Subject: [PATCH 01/33] 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 | 1 + .../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, 1347 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 699fcd6d41..2dbc4b5b85 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) +- 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 diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index f9bd0e53ae..6045cc7864 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 { buildCaipAssetType } from './utils/token'; import { getTransaction, @@ -74,6 +75,7 @@ export class TransactionPayController extends BaseController< getStrategy, getStrategies, messenger, + polymarketBridgeOptions, state, }: TransactionPayControllerOptions) { super({ @@ -87,6 +89,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 52e39b0efb..16602b448c 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -52,6 +52,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 f1e26a291f..1d8f9c39e1 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -190,10 +190,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 4df13c76acabd432bd889840e5e5d6fdc9bac89c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 14:11:03 +0100 Subject: [PATCH 02/33] 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 6c941309b7d7b4b957f7af49d48bab155b087f46 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 14:56:19 +0100 Subject: [PATCH 03/33] 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 2dbc4b5b85..2180cdc3b1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) - 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 6045cc7864..f7ef09f9b1 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -134,6 +134,7 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, + isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -146,6 +147,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 ( @@ -332,6 +334,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)] : []); @@ -341,6 +344,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 7d639eea2d..8762a18d9d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -234,7 +234,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 53165cb4af..c68336b448 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 1d8f9c39e1..c6310d9570 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -112,6 +112,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; @@ -241,6 +249,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -420,6 +431,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the 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 757bba37c2b03a5b0cb5eb9ef275fad6d2306385 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 17:50:56 +0100 Subject: [PATCH 04/33] 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 8762a18d9d..95ec54a04c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -644,8 +644,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 d2d8e6b9e1e994ef4d4595babdd72f7a9cbcedcf Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:21:44 +0100 Subject: [PATCH 05/33] 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 2180cdc3b1..9bac779480 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -42,8 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) -- 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 f83acdf7ef60ad4bdd5404e052ae1857386a4683 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:31:04 +0100 Subject: [PATCH 06/33] 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 9bac779480..d27559e300 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -43,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) - 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 f7ef09f9b1..6045cc7864 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -134,7 +134,6 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, - isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -147,7 +146,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 ( @@ -334,7 +332,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)] : []); @@ -344,12 +341,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 95ec54a04c..7d639eea2d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -234,8 +234,7 @@ async function getSingleQuote( const useExecute = supports7702 && isRelayExecuteEnabled(messenger) && - isEIP7702Chain(messenger, sourceChainId) && - !request.isPolymarketDepositWallet; + isEIP7702Chain(messenger, sourceChainId); const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, @@ -644,10 +643,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 c68336b448..53165cb4af 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 c6310d9570..1d8f9c39e1 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -112,14 +112,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; @@ -249,9 +241,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -431,9 +420,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the 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 32bc6816fd1b7a6c3e0fbd6692336b3e7586cb54 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 09:38:51 +0100 Subject: [PATCH 07/33] 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 6045cc7864..f9bd0e53ae 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 { buildCaipAssetType } from './utils/token'; import { getTransaction, @@ -75,7 +74,6 @@ export class TransactionPayController extends BaseController< getStrategy, getStrategies, messenger, - polymarketBridgeOptions, state, }: TransactionPayControllerOptions) { super({ @@ -89,8 +87,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 1d8f9c39e1..f1e26a291f 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -190,28 +190,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 f3b3144325..5f471a7bd7 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -10,6 +10,7 @@ import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE, } from '../strategy/fiat/constants'; +import { POLYMARKET_RELAYER_BASE_URL_PROD } from '../strategy/polymarket-bridge/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, @@ -27,6 +28,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 = [ @@ -45,6 +47,7 @@ type FeatureFlagsRaw = { } >; }; + polymarketRelayerUrl?: string; relayDisabledGasStationChains?: Hex[]; relayExecuteUrl?: string; relayFallbackGas?: { @@ -558,6 +561,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 6be34626c3c98b9db3cb1fe20a7e17362a6e4e43 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 10:07:16 +0100 Subject: [PATCH 08/33] 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 d27559e300..5bbde7aee8 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -43,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) - 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 f9bd0e53ae..46aa13b291 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -130,6 +130,7 @@ export class TransactionPayController extends BaseController< isMaxAmount: transactionData.isMaxAmount, isPostQuote: transactionData.isPostQuote, isHyperliquidSource: transactionData.isHyperliquidSource, + isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, }; @@ -142,6 +143,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 ( @@ -328,6 +331,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)] : []); @@ -341,7 +350,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 f1e26a291f..6461270446 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -112,6 +112,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; @@ -223,6 +231,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -402,6 +413,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 quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 5f471a7bd7..5cfe76fb57 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -10,7 +10,7 @@ import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE, } from '../strategy/fiat/constants'; -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, @@ -28,7 +28,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 b0e01509f3a3cfe4387a1fcbecd8335628a618ad Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 13:18:46 +0100 Subject: [PATCH 09/33] 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 5bbde7aee8..5b2647a89d 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -45,6 +45,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 1ed1ea132aee3bca0e880e5739ff3d28706bf06f Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 13:39:44 +0100 Subject: [PATCH 10/33] 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 5b2647a89d..a9bec6841b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -46,6 +46,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 6d53a4cd4979475f831d404e27cf34875e796c6f Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 20:13:45 +0100 Subject: [PATCH 11/33] 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 15d85b3753c1c729580570716cbbba24d852d83e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 22:18:38 +0100 Subject: [PATCH 12/33] 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 2a482ffb16a757a1c79a9fb78ad10f8f8ce03d0a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 11 May 2026 23:29:51 +0100 Subject: [PATCH 13/33] 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 1525b4d91cd59402a1bf3ee01d9f92a9c99c3e08 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:07:09 +0100 Subject: [PATCH 14/33] 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 46aa13b291..fed6fa241d 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -334,7 +334,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 16602b448c..52e39b0efb 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -52,7 +52,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 7d639eea2d..d160357601 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -53,6 +53,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 { @@ -236,24 +237,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 53165cb4af..9a3cbaa2f0 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 5cfe76fb57..951cef0e1c 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -10,7 +10,7 @@ import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE, } from '../strategy/fiat/constants'; -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 ed93efb2f48f03f3a3234610135e0e33eaba6cdc Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:25:02 +0100 Subject: [PATCH 15/33] 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 18355ad1d7..40030dcd8d 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": "^7.1.2", "@metamask/assets-controllers": "^108.1.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 d160357601..7cc9157611 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -53,7 +53,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 { @@ -237,22 +237,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 9a3cbaa2f0..9842787e80 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 f5081a7d5ce34bb544fbebfd917f4c2590a0d05d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 00:44:24 +0100 Subject: [PATCH 16/33] 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 a9bec6841b..1bc999c62c 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -42,11 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) -- 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 7cc9157611..19e8ce68d9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -53,7 +53,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 9842787e80..7bc4a6d0ba 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 6461270446..552d6f3131 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -112,12 +112,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 951cef0e1c..549bca92cb 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -47,7 +47,6 @@ type FeatureFlagsRaw = { } >; }; - polymarketRelayerUrl?: string; relayDisabledGasStationChains?: Hex[]; relayExecuteUrl?: string; relayFallbackGas?: { @@ -138,6 +137,7 @@ export type PayStrategiesConfigRaw = { enabled?: boolean; gaslessEnabled?: boolean; originGasOverhead?: string; + polymarketRelayerUrl?: string; pollingInterval?: number; pollingTimeout?: number; }; @@ -578,7 +578,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 + ); } /** From 6fdc6ef716723f67947702ed5e1b1d62b42d255c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 08:05:58 +0100 Subject: [PATCH 17/33] refactor(transaction-pay-controller): split Polymarket relayer into api + orchestration - relayer-api.ts is now strictly the HTTP transport (getNonce, submitRelayerRequest, getTransactionStatus) plus the JSON envelope parser and PolymarketRelayerError. - relayer.ts is the orchestration layer: submitDepositWalletBatch builds typed data, calls KeyringController:signTypedMessage, posts via the api, polls getTransactionStatus to terminal, and wraps the whole thing in wallet-busy retry. - withdraw.ts drops its own busy-retry helper - retry is now automatic for any submitDepositWalletBatch caller. - calldata.ts uses @ethersproject/abi Interface with inline function signatures instead of hand-padded hex; decoding the transfer recipient also goes through Interface.decodeFunctionData so the selector mismatch check comes for free. - TransactionPayController.#getStrategiesWithFallback no longer short-circuits to Relay for isPolymarketDepositWallet; the regular strategy chain selects Relay naturally now that the flag is just a source-leg flavour inside the Relay strategy. --- .../src/TransactionPayController.ts | 4 - .../src/strategy/relay/polymarket/calldata.ts | 30 +- .../strategy/relay/polymarket/relayer-api.ts | 200 +------------- .../src/strategy/relay/polymarket/relayer.ts | 256 ++++++++++++++++++ .../src/strategy/relay/polymarket/withdraw.ts | 46 +--- 5 files changed, 280 insertions(+), 256 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index fed6fa241d..067dab5c5c 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -333,10 +333,6 @@ export class TransactionPayController extends BaseController< ): TransactionPayStrategy[] { const transactionData = this.state.transactionData[transaction.id]; - if (transactionData?.isPolymarketDepositWallet) { - return [TransactionPayStrategy.Relay]; - } - const strategyCandidates: unknown[] = this.#getStrategies?.(transaction) ?? (this.#getStrategy ? [this.#getStrategy(transaction)] : []); 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 6fc309d50b..d4da07a0ab 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts @@ -1,9 +1,15 @@ +import { Interface } from '@ethersproject/abi'; import type { Hex } from '@metamask/utils'; -const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; +const iface = new Interface([ + 'function approve(address spender, uint256 amount)', + 'function unwrap(address asset, address recipient, uint256 amount)', + 'function wrap(address asset, address recipient, uint256 amount)', + 'function transfer(address recipient, uint256 amount)', +]); export function encodeApprove(spender: Hex, amount: bigint): Hex { - return `0x095ea7b3${padAddress(spender)}${padUint256(amount)}` as Hex; + return iface.encodeFunctionData('approve', [spender, amount]) as Hex; } export function encodeUnwrap({ @@ -15,7 +21,7 @@ export function encodeUnwrap({ recipient: Hex; amount: bigint; }): Hex { - return `0x8cc7104f${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; + return iface.encodeFunctionData('unwrap', [asset, recipient, amount]) as Hex; } export function encodeWrap({ @@ -27,22 +33,10 @@ export function encodeWrap({ recipient: Hex; amount: bigint; }): Hex { - return `0x62355638${padAddress(asset)}${padAddress(recipient)}${padUint256(amount)}` as Hex; + return iface.encodeFunctionData('wrap', [asset, recipient, amount]) as Hex; } 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 padAddress(address: Hex): string { - return address.slice(2).toLowerCase().padStart(64, '0'); -} - -function padUint256(value: bigint): string { - return value.toString(16).padStart(64, '0'); + const [recipient] = iface.decodeFunctionData('transfer', data); + return recipient as Hex; } 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 34a16029ef..516bae802a 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,40 +1,18 @@ -import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../../logger'; 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'); -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; @@ -62,91 +40,24 @@ export async function getNonce( return result.nonce; } -export async function submitDepositWalletBatch( +export async function submitRelayerRequest( 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( + request: PolymarketRelayerSubmitRequest, +): Promise { + const response = await postEnvelope( messenger, - { path: '/submit', method: 'POST', body: submitRequest }, + { path: '/submit', method: 'POST', body: request }, ); 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}`, - ); - } - - 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, + transactionID: response.transactionID, + state: response.state, }); - return { transactionHash: terminalStatus.transactionHash as Hex }; + return response; } -async function getTransactionStatus( +export async function getTransactionStatus( messenger: TransactionPayControllerMessenger, transactionId: string, ): Promise { @@ -161,89 +72,6 @@ async function getTransactionStatus( return Array.isArray(result) ? result : [result]; } -async function pollUntilTerminal( - messenger: TransactionPayControllerMessenger, - transactionId: string, -): Promise { - for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { - await delay(POLLING_INTERVAL_MS); - - const statuses = await getTransactionStatus(messenger, 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', - ); -} - -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, @@ -322,13 +150,3 @@ async function postEnvelope( return parsed as TResponse; } - -function isTerminalState(state: PolymarketRelayerState): boolean { - return (POLYMARKET_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/relay/polymarket/relayer.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts new file mode 100644 index 0000000000..2f1ef66da2 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts @@ -0,0 +1,256 @@ +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../../logger'; +import type { TransactionPayControllerMessenger } from '../../../types'; +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 { + PolymarketRelayerError, + getNonce, + getTransactionStatus, + submitRelayerRequest, +} from './relayer-api'; +import type { + PolymarketRelayerState, + PolymarketRelayerStatusResponse, + PolymarketRelayerSubmitRequest, + PolymarketWalletCall, +} from './types'; + +const log = createModuleLogger(projectLogger, 'polymarket-relayer'); + +const POLYGON_CHAIN_ID_NUMBER = 137; + +const POLLING_INTERVAL_MS = 2000; +const POLLING_MAX_ATTEMPTS = 90; + +const WALLET_BUSY_RETRY_ATTEMPTS = 5; +const WALLET_BUSY_RETRY_DELAY_MS = 3_000; + +const EIP712_DOMAIN_FIELDS = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +export async function submitDepositWalletBatch( + messenger: TransactionPayControllerMessenger, + { + from, + depositWalletAddress, + calls, + }: { + from: Hex; + depositWalletAddress: Hex; + calls: PolymarketWalletCall[]; + }, +): Promise<{ transactionHash: Hex }> { + let lastError: unknown; + + for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { + try { + return await submitDepositWalletBatchOnce(messenger, { + from, + depositWalletAddress, + calls, + }); + } 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( + 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 submitRelayerRequest(messenger, submitRequest); + + 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}`, + ); + } + + 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 { transactionHash: terminalStatus.transactionHash as Hex }; +} + +async function pollUntilTerminal( + messenger: TransactionPayControllerMessenger, + transactionId: string, +): Promise { + for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { + await delay(POLLING_INTERVAL_MS); + + const statuses = await getTransactionStatus(messenger, 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', + ); +} + +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, + })), + }, + }; +} + +function isTerminalState(state: PolymarketRelayerState): boolean { + return (POLYMARKET_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/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index 64f08d75a4..be09a7ba3e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -27,13 +27,10 @@ import { USDC_E_ADDRESS_POLYGON, } from './constants'; import { computeDepositWalletAddress } from './deposit-wallet'; -import { submitDepositWalletBatch } from './relayer-api'; +import { submitDepositWalletBatch } from './relayer'; const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); -const WALLET_BUSY_RETRY_ATTEMPTS = 5; -const WALLET_BUSY_RETRY_DELAY_MS = 3_000; - export function applyPolymarketDepositWalletOverrides( body: RelayQuoteRequest, request: QuoteRequest, @@ -61,7 +58,7 @@ export async function submitPolymarketWithdraw( amount: amount.toString(), }); - const { transactionHash } = await submitWithBusyRetry(messenger, { + const { transactionHash } = await submitDepositWalletBatch(messenger, { from, depositWalletAddress, calls: [ @@ -116,7 +113,7 @@ export async function sweepPolymarketDepositWallet( } try { - const { transactionHash } = await submitWithBusyRetry(messenger, { + const { transactionHash } = await submitDepositWalletBatch(messenger, { from, depositWalletAddress, calls: [ @@ -146,39 +143,6 @@ export async function sweepPolymarketDepositWallet( } } -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 submitDepositWalletBatch(messenger, 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'); - - 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; -} - function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); @@ -199,7 +163,3 @@ function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { return extractErc20TransferRecipient(depositCallData); } - -async function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} From bf8f5682649520f6c5fdb7513949bb01e5896cec Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 08:21:23 +0100 Subject: [PATCH 18/33] fix(transaction-pay-controller): wait for refunded before sweeping and throw post-sweep when Relay did not succeed waitForRelayCompletion now returns the terminal status plus the optional target hash instead of just a hash (the default behaviour for non-tolerant callers is unchanged - they still throw on any failure status). For the Polymarket path (tolerateFailure: true): - refund is treated as mid-flight (Relay-issued refund tx not yet confirmed) - polling continues until refunded so the deposit wallet's USDC.e balance is actually present by the time the sweep runs. - refunded, failure, and timeout all return a terminal status without throwing; the caller still runs the USDC.e sweep so any USDC.e left at the deposit wallet is wrapped back into pUSD. - After the sweep, the caller throws unless the status is success, so the transaction reports as failed in activity even when the sweep recovered the funds. --- .../src/strategy/relay/relay-submit.ts | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) 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 7bc4a6d0ba..022e5b1203 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -45,6 +45,7 @@ import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, RelayQuote, + RelayStatus, RelayStatusResponse, RelayTransactionStep, } from './types'; @@ -122,7 +123,7 @@ async function executeSingleQuote( await submitTransactions(quote, transaction, messenger); } - const targetHash = await waitForRelayCompletion(quote.original, messenger, { + const completion = await waitForRelayCompletion(quote.original, messenger, { onSourceHash: (hash) => { log('Source hash received', hash); setRelaySourceHash(transaction, messenger, hash); @@ -130,10 +131,16 @@ async function executeSingleQuote( tolerateFailure: isPolymarket, }); - log('Relay request completed', targetHash); + log('Relay request completed', completion); if (isPolymarket) { await sweepPolymarketDepositWallet(quote.request.from, messenger); + + if (completion.status !== 'success') { + throw new Error( + `Relay request failed with status: ${completion.status}`, + ); + } } updateTransaction( @@ -147,7 +154,7 @@ async function executeSingleQuote( }, ); - return { transactionHash: targetHash ?? sourceHash }; + return { transactionHash: completion.targetHash ?? sourceHash }; } function setRelaySourceHash( @@ -168,6 +175,11 @@ function setRelaySourceHash( ); } +type RelayCompletionOutcome = { + status: RelayStatus | 'timeout'; + targetHash?: Hex; +}; + async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, @@ -175,7 +187,7 @@ async function waitForRelayCompletion( onSourceHash?: (hash: Hex) => void; tolerateFailure?: boolean; } = {}, -): Promise { +): Promise { const { onSourceHash, tolerateFailure } = options; const isSameChain = @@ -187,7 +199,7 @@ async function waitForRelayCompletion( if (isSameChain && !isSingleDepositStep) { log('Skipping polling as same chain'); - return FALLBACK_HASH; + return { status: 'success', targetHash: FALLBACK_HASH }; } const { requestId } = quote.steps[0]; @@ -200,7 +212,7 @@ async function waitForRelayCompletion( const startTime = Date.now(); let sourceHashEmitted = false; - let lastStatus: string | undefined; + let lastStatus: RelayStatus | undefined; while (true) { let status: RelayStatusResponse | undefined; @@ -223,18 +235,25 @@ async function waitForRelayCompletion( if (status.status === 'success') { const targetHash = (status.txHashes?.slice(-1)[0] as Hex) ?? FALLBACK_HASH; - return targetHash; + return { status: 'success', targetHash }; } - if (RELAY_FAILURE_STATUSES.includes(status.status)) { - if (tolerateFailure) { - log('Relay ended in failure status (tolerated)', status.status); - return undefined; + // When tolerating failure, refund is mid-flight (refund tx not yet + // confirmed) - keep polling until refunded. + const isPendingForCaller = + RELAY_PENDING_STATUSES.includes(status.status) || + (tolerateFailure && status.status === 'refund'); + + if (!isPendingForCaller) { + if (RELAY_FAILURE_STATUSES.includes(status.status)) { + if (tolerateFailure) { + log('Relay ended in failure status (tolerated)', status.status); + return { status: status.status }; + } + throw new Error( + `Relay request failed with status: ${status.status}`, + ); } - throw new Error(`Relay request failed with status: ${status.status}`); - } - - if (!RELAY_PENDING_STATUSES.includes(status.status)) { throw new Error(`Relay returned unrecognized status: ${status.status}`); } } @@ -243,7 +262,7 @@ async function waitForRelayCompletion( const statusDetail = lastStatus ? ` (last status: ${lastStatus})` : ''; if (tolerateFailure) { log('Relay polling timed out (tolerated)', statusDetail); - return undefined; + return { status: 'timeout' }; } throw new Error(`Relay polling timed out${statusDetail}`); } From 06f9673377be62ab7ab8c5ffa0344ea289baecf7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 08:34:37 +0100 Subject: [PATCH 19/33] fix(transaction-pay-controller): bump Polymarket batch deadline to the relayer cap Polymarket's relayer rejects deadlines above 300s. Signing, posting, and the relayer's own validation step can easily eat 60s, so 240s was leaving too small a window and producing intermittent "deadline too soon" rejections. Use the full 300s allowed by the relayer (matches mobile). --- .../src/strategy/relay/polymarket/constants.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts index d7f4a13764..ed19107f1f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -24,7 +24,11 @@ export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = export const POLYMARKET_WALLET_DOMAIN_NAME = 'DepositWallet'; export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; -export const POLYMARKET_BATCH_DEADLINE_SECONDS = 240; +/** + * Polymarket's relayer rejects deadlines above 300s, so use the maximum + * allowed window to reduce intermittent "deadline too soon" failures. + */ +export const POLYMARKET_BATCH_DEADLINE_SECONDS = 300; export const POLYMARKET_RELAYER_TERMINAL_STATES = [ 'STATE_MINED', From 9608b205ee50bf495322b848df7145a88a7165c5 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 09:10:39 +0100 Subject: [PATCH 20/33] refactor(transaction-pay-controller): inject Polymarket relayer via client callbacks The Polymarket relayer protocol (EIP-712 typed-data, HTTP transport, polling, busy retry, error shapes) is Polymarket-domain and does not belong in the controller. Move it out into a pair of client-supplied callbacks the controller invokes via messenger actions, mirroring the existing getDelegationTransaction pattern. New on TransactionPayControllerOptions: - polymarket?: PolymarketCallbacks - getDepositWalletAddress({ eoa }) - submitDepositWalletBatch({ eoa, depositWallet, calls }) The polymarket option is optional. Clients that do not use the Polymarket deposit-wallet flow can omit it; the controller throws a clear error if a strategy tries to invoke the callbacks when they were not supplied. The withdraw orchestration (Relay quote overrides, call building, USDC.e sweep, Relay polling with refund -> refunded wait) stays in core because it is Relay-flow logic. Only the Polymarket-protocol transport is moved out. Removed from core: - strategy/relay/polymarket/deposit-wallet.ts - strategy/relay/polymarket/relayer.ts - strategy/relay/polymarket/relayer-api.ts - strategy/relay/polymarket/types.ts (relayer envelope types) - POLYMARKET_BATCH_DEADLINE_SECONDS, POLYMARKET_WALLET_DOMAIN_*, POLYMARKET_RELAYER_TERMINAL_STATES, POLYMARKET_RELAYER_PROXY_URL_PROD, DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, DEPOSIT_WALLET_IMPLEMENTATION_POLYGON constants - getPolymarketRelayerUrl feature-flag helper + payStrategies.relay.polymarketRelayerUrl - @ethersproject/{address,bytes,keccak256} dependencies --- .../transaction-pay-controller/package.json | 3 - ...actionPayController-method-action-types.ts | 28 +- .../src/TransactionPayController.ts | 42 +++ .../strategy/relay/polymarket/constants.ts | 25 -- .../relay/polymarket/deposit-wallet.ts | 66 ----- .../strategy/relay/polymarket/relayer-api.ts | 152 ----------- .../src/strategy/relay/polymarket/relayer.ts | 256 ------------------ .../src/strategy/relay/polymarket/types.ts | 56 ---- .../src/strategy/relay/polymarket/withdraw.ts | 118 ++++---- .../src/strategy/relay/relay-quotes.ts | 2 +- .../transaction-pay-controller/src/types.ts | 16 ++ .../src/utils/feature-flags.ts | 26 -- 12 files changed, 151 insertions(+), 639 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 40030dcd8d..18355ad1d7 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -55,10 +55,7 @@ }, "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": "^7.1.2", "@metamask/assets-controllers": "^108.1.0", diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 00ee4405f2..39b8d952fe 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -81,6 +81,30 @@ export type TransactionPayControllerGetStrategyAction = { handler: TransactionPayController['getStrategy']; }; +/** + * Derives the Polymarket deposit-wallet address for an EOA via the + * client-supplied callback. + * + * @param params - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. + * @returns A promise resolving to the deposit-wallet address. + */ +export type TransactionPayControllerPolymarketGetDepositWalletAddressAction = { + type: `TransactionPayController:polymarketGetDepositWalletAddress`; + handler: TransactionPayController['polymarketGetDepositWalletAddress']; +}; + +/** + * Signs and broadcasts a Polymarket deposit-wallet batch via the + * client-supplied callback. + * + * @param params - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. + * @returns A promise resolving to the relayer-issued source hash. + */ +export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { + type: `TransactionPayController:polymarketSubmitDepositWalletBatch`; + handler: TransactionPayController['polymarketSubmitDepositWalletBatch']; +}; + /** * Union of all TransactionPayController action types. */ @@ -89,4 +113,6 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction | TransactionPayControllerGetDelegationTransactionAction - | TransactionPayControllerGetStrategyAction; + | TransactionPayControllerGetStrategyAction + | TransactionPayControllerPolymarketGetDepositWalletAddressAction + | TransactionPayControllerPolymarketSubmitDepositWalletBatchAction; diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 067dab5c5c..7d17b4fcc2 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -15,6 +15,7 @@ import { QuoteRefresher } from './helpers/QuoteRefresher'; import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import type { GetDelegationTransactionCallback, + PolymarketCallbacks, TransactionConfigCallback, TransactionData, TransactionPayControllerMessenger, @@ -36,6 +37,8 @@ import { const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', 'getStrategy', + 'polymarketGetDepositWalletAddress', + 'polymarketSubmitDepositWalletBatch', 'setTransactionConfig', 'updateFiatPayment', 'updatePaymentToken', @@ -69,11 +72,14 @@ export class TransactionPayController extends BaseController< transaction: TransactionMeta, ) => TransactionPayStrategy[]; + readonly #polymarket?: PolymarketCallbacks; + constructor({ getDelegationTransaction, getStrategy, getStrategies, messenger, + polymarket, state, }: TransactionPayControllerOptions) { super({ @@ -86,6 +92,7 @@ export class TransactionPayController extends BaseController< this.#getDelegationTransaction = getDelegationTransaction; this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; + this.#polymarket = polymarket; this.messenger.registerMethodActionHandlers( this, @@ -222,6 +229,41 @@ export class TransactionPayController extends BaseController< return this.#getStrategiesWithFallback(transaction)[0]; } + /** + * Derives the Polymarket deposit-wallet address for an EOA via the + * client-supplied callback. + * + * @param params - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. + * @returns A promise resolving to the deposit-wallet address. + */ + polymarketGetDepositWalletAddress( + ...args: Parameters + ): ReturnType { + return this.#requirePolymarket().getDepositWalletAddress(...args); + } + + /** + * Signs and broadcasts a Polymarket deposit-wallet batch via the + * client-supplied callback. + * + * @param params - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. + * @returns A promise resolving to the relayer-issued source hash. + */ + polymarketSubmitDepositWalletBatch( + ...args: Parameters + ): ReturnType { + return this.#requirePolymarket().submitDepositWalletBatch(...args); + } + + #requirePolymarket(): PolymarketCallbacks { + if (!this.#polymarket) { + throw new Error( + 'TransactionPayController: polymarket callbacks were not supplied to the controller constructor; the Polymarket deposit-wallet flow is not available in this client.', + ); + } + return this.#polymarket; + } + #removeTransactionData(transactionId: string): void { this.update((state) => { delete state.transactionData[transactionId]; diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts index ed19107f1f..da79fd738c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -1,14 +1,5 @@ import type { Hex } from '@metamask/utils'; -export const POLYMARKET_RELAYER_PROXY_URL_PROD = - 'https://predict.api.cx.metamask.io'; - -export const DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON = - '0x00000000000Fb5C9ADea0298D729A0CB3823Cc07' as Hex; - -export const DEPOSIT_WALLET_IMPLEMENTATION_POLYGON = - '0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB' as Hex; - export const PUSD_ADDRESS_POLYGON = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; @@ -20,19 +11,3 @@ export const POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = '0x93070a847efEf7F70739046A929D47a521F5B8ee' as Hex; - -export const POLYMARKET_WALLET_DOMAIN_NAME = 'DepositWallet'; -export const POLYMARKET_WALLET_DOMAIN_VERSION = '1'; - -/** - * Polymarket's relayer rejects deadlines above 300s, so use the maximum - * allowed window to reduce intermittent "deadline too soon" failures. - */ -export const POLYMARKET_BATCH_DEADLINE_SECONDS = 300; - -export const POLYMARKET_RELAYER_TERMINAL_STATES = [ - 'STATE_MINED', - 'STATE_CONFIRMED', - 'STATE_FAILED', - 'STATE_INVALID', -] as const; 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 deleted file mode 100644 index abf233b877..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/deposit-wallet.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 { - 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 = defaultAbiCoder.encode( - ['address', 'bytes32'], - [DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, walletId], - ); - - const salt = keccak256(args); - const initCodeHash = computeSoladyERC1967InitCodeHash( - DEPOSIT_WALLET_IMPLEMENTATION_POLYGON, - args, - ); - - return getCreate2Address( - DEPOSIT_WALLET_FACTORY_ADDRESS_POLYGON, - salt, - initCodeHash, - ) as Hex; -} - -function computeSoladyERC1967InitCodeHash( - implementation: string, - args: string, -): string { - const argByteLength = BigInt((args.length - 2) / 2); - const prefixWithLength = ERC1967_PREFIX + (argByteLength << 56n); - - return keccak256( - hexConcat([ - `0x${prefixWithLength.toString(16).padStart(20, '0')}`, - implementation, - '0x6009', - ERC1967_CONST2, - ERC1967_CONST1, - args, - ]), - ); -} 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 deleted file mode 100644 index 516bae802a..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer-api.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../../../logger'; -import type { TransactionPayControllerMessenger } from '../../../types'; -import { getPolymarketRelayerUrl } from '../../../utils/feature-flags'; -import type { - PolymarketRelayerProxyEnvelope, - PolymarketRelayerStatusResponse, - PolymarketRelayerSubmitRequest, - PolymarketRelayerSubmitResponse, -} from './types'; - -const log = createModuleLogger(projectLogger, 'polymarket-relayer-api'); - -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 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; -} - -export async function submitRelayerRequest( - messenger: TransactionPayControllerMessenger, - request: PolymarketRelayerSubmitRequest, -): Promise { - const response = await postEnvelope( - messenger, - { path: '/submit', method: 'POST', body: request }, - ); - - log('Relayer accepted submission', { - transactionID: response.transactionID, - state: response.state, - }); - - return response; -} - -export 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]; -} - -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( - `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, - ); - } - 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( - detail ?? `Relayer proxy returned status ${response.status}`, - 'PROXY_ERROR', - parsed, - ); - } - - 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; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts deleted file mode 100644 index 2f1ef66da2..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/relayer.ts +++ /dev/null @@ -1,256 +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 } from '../../../types'; -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 { - PolymarketRelayerError, - getNonce, - getTransactionStatus, - submitRelayerRequest, -} from './relayer-api'; -import type { - PolymarketRelayerState, - PolymarketRelayerStatusResponse, - PolymarketRelayerSubmitRequest, - PolymarketWalletCall, -} from './types'; - -const log = createModuleLogger(projectLogger, 'polymarket-relayer'); - -const POLYGON_CHAIN_ID_NUMBER = 137; - -const POLLING_INTERVAL_MS = 2000; -const POLLING_MAX_ATTEMPTS = 90; - -const WALLET_BUSY_RETRY_ATTEMPTS = 5; -const WALLET_BUSY_RETRY_DELAY_MS = 3_000; - -const EIP712_DOMAIN_FIELDS = [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, -]; - -export async function submitDepositWalletBatch( - messenger: TransactionPayControllerMessenger, - { - from, - depositWalletAddress, - calls, - }: { - from: Hex; - depositWalletAddress: Hex; - calls: PolymarketWalletCall[]; - }, -): Promise<{ transactionHash: Hex }> { - let lastError: unknown; - - for (let attempt = 1; attempt <= WALLET_BUSY_RETRY_ATTEMPTS; attempt++) { - try { - return await submitDepositWalletBatchOnce(messenger, { - from, - depositWalletAddress, - calls, - }); - } 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( - 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 submitRelayerRequest(messenger, submitRequest); - - 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}`, - ); - } - - 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 { transactionHash: terminalStatus.transactionHash as Hex }; -} - -async function pollUntilTerminal( - messenger: TransactionPayControllerMessenger, - transactionId: string, -): Promise { - for (let attempt = 0; attempt < POLLING_MAX_ATTEMPTS; attempt++) { - await delay(POLLING_INTERVAL_MS); - - const statuses = await getTransactionStatus(messenger, 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', - ); -} - -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, - })), - }, - }; -} - -function isTerminalState(state: PolymarketRelayerState): boolean { - return (POLYMARKET_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/relay/polymarket/types.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts deleted file mode 100644 index 6eefaf1f66..0000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Hex } from '@metamask/utils'; - -export type PolymarketWalletCall = { - target: Hex; - value: bigint; - data: Hex; -}; - -export type PolymarketRelayerSubmitRequest = { - type: 'WALLET'; - from: Hex; - to: Hex; - nonce: string; - signature: Hex; - depositWalletParams: { - depositWallet: Hex; - deadline: string; - calls: { - target: string; - value: string; - data: string; - }[]; - }; -}; - -export type PolymarketRelayerSubmitResponse = { - transactionID: string; - state: string; -}; - -export type PolymarketRelayerStatusResponse = { - transactionHash: string | null; - state: PolymarketRelayerState; - from: string; - to: string; - proxyAddress: string; - data: string; - nonce: string; - signature: string; - type: string; - createdAt: string; - updatedAt: string; -}; - -export type PolymarketRelayerState = - | 'STATE_NEW' - | 'STATE_EXECUTED' - | 'STATE_MINED' - | 'STATE_CONFIRMED' - | 'STATE_INVALID' - | 'STATE_FAILED'; - -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/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index be09a7ba3e..a9a520d172 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -26,16 +26,18 @@ import { PUSD_ADDRESS_POLYGON, USDC_E_ADDRESS_POLYGON, } from './constants'; -import { computeDepositWalletAddress } from './deposit-wallet'; -import { submitDepositWalletBatch } from './relayer'; const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); -export function applyPolymarketDepositWalletOverrides( +export async function applyPolymarketDepositWalletOverrides( body: RelayQuoteRequest, request: QuoteRequest, -): void { - const depositWalletAddress = computeDepositWalletAddress(request.from); + messenger: TransactionPayControllerMessenger, +): Promise { + const depositWalletAddress = await messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa: request.from }, + ); body.originCurrency = USDC_E_ADDRESS_POLYGON; body.user = depositWalletAddress; @@ -48,7 +50,10 @@ export async function submitPolymarketWithdraw( from: Hex, messenger: TransactionPayControllerMessenger, ): Promise<{ sourceHash: Hex }> { - const depositWalletAddress = computeDepositWalletAddress(from); + const depositWalletAddress = await messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa: from }, + ); const relayDepositAddress = extractRelayDepositAddress(quote.original); const amount = BigInt(quote.sourceAmount.raw); @@ -58,35 +63,39 @@ export async function submitPolymarketWithdraw( amount: amount.toString(), }); - const { transactionHash } = await submitDepositWalletBatch(messenger, { - 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, - }), - }, - ], - }); - - return { sourceHash: transactionHash }; + return await messenger.call( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + { + eoa: from, + depositWallet: depositWalletAddress, + calls: [ + { + target: PUSD_ADDRESS_POLYGON, + value: '0', + data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: '0', + data: encodeUnwrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: relayDepositAddress, + amount, + }), + }, + ], + }, + ); } export async function sweepPolymarketDepositWallet( from: Hex, messenger: TransactionPayControllerMessenger, ): Promise { - const depositWalletAddress = computeDepositWalletAddress(from); + const depositWalletAddress = await messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa: from }, + ); let usdceBalance: bigint; try { @@ -113,31 +122,34 @@ export async function sweepPolymarketDepositWallet( } try { - const { transactionHash } = await submitDepositWalletBatch(messenger, { - 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, - }), - }, - ], - }); + const { sourceHash } = await messenger.call( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + { + eoa: from, + depositWallet: depositWalletAddress, + calls: [ + { + target: USDC_E_ADDRESS_POLYGON, + value: '0', + data: encodeApprove( + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + usdceBalance, + ), + }, + { + target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + value: '0', + data: encodeWrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: depositWalletAddress, + amount: usdceBalance, + }), + }, + ], + }, + ); - log('USDC.e sweep: complete', { transactionHash }); + log('USDC.e sweep: complete', { sourceHash }); } catch (error) { log('USDC.e sweep: batch submission failed', { error }); } 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 19e8ce68d9..c61d326115 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -253,7 +253,7 @@ async function getSingleQuote( }; if (request.isPolymarketDepositWallet) { - applyPolymarketDepositWalletOverrides(body, request); + await applyPolymarketDepositWalletOverrides(body, request, messenger); } // Skip transaction processing for post-quote flows - the original transaction diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 552d6f3131..8844400512 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -193,6 +193,9 @@ export type TransactionPayControllerOptions = { /** Controller messenger. */ messenger: TransactionPayControllerMessenger; + /** Callbacks for the Polymarket relayer; required only for the Polymarket deposit-wallet flow. */ + polymarket?: PolymarketCallbacks; + /** Initial state of the controller. */ state?: Partial; }; @@ -679,6 +682,19 @@ export type GetDelegationTransactionCallback = ({ value: Hex; }>; +/** Client-supplied callbacks for the Polymarket relayer protocol. */ +export type PolymarketCallbacks = { + /** Derive the deposit-wallet address (CREATE2) for the given EOA. */ + getDepositWalletAddress: (params: { eoa: Hex }) => Promise; + + /** Sign and broadcast a deposit-wallet batch, returning the source hash. */ + submitDepositWalletBatch: (params: { + eoa: Hex; + depositWallet: Hex; + calls: { target: Hex; data: Hex; value: string }[]; + }) => Promise<{ sourceHash: Hex }>; +}; + /** Single amount in alternate formats. */ export type Amount = FiatValue & { /** Amount in human-readable format factoring token decimals. */ diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 549bca92cb..f3b3144325 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -10,7 +10,6 @@ import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE, } from '../strategy/fiat/constants'; -import { POLYMARKET_RELAYER_PROXY_URL_PROD } from '../strategy/relay/polymarket/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, @@ -28,7 +27,6 @@ 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_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 = [ @@ -137,7 +135,6 @@ export type PayStrategiesConfigRaw = { enabled?: boolean; gaslessEnabled?: boolean; originGasOverhead?: string; - polymarketRelayerUrl?: string; pollingInterval?: number; pollingTimeout?: number; }; @@ -561,29 +558,6 @@ 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.payStrategies?.relay?.polymarketRelayerUrl ?? - DEFAULT_POLYMARKET_RELAYER_URL - ); -} - /** * Get fallback gas limits for quote/submit flows. * From 68dea46ecfcd0431510e4f1593a1de577f3a126d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 09:22:01 +0100 Subject: [PATCH 21/33] feat(transaction-pay-controller): export PolymarketCallbacks and polymarket action types Add the PolymarketCallbacks public type and the two new polymarket messenger action types (TransactionPayControllerPolymarketGetDepositWalletAddressAction and TransactionPayControllerPolymarketSubmitDepositWalletBatchAction) to the package's public exports so clients can supply the callbacks at construction and declare the actions in their messenger types. --- packages/transaction-pay-controller/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index dc7764daff..c31382d8ba 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -10,6 +10,7 @@ export type { TransactionPayControllerMessenger, TransactionPayControllerOptions, TransactionPayControllerState, + PolymarketCallbacks, TransactionPayControllerStateChangeEvent, TransactionPaymentToken, TransactionPayQuote, @@ -22,6 +23,8 @@ export type { export type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerPolymarketGetDepositWalletAddressAction, + TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetTransactionConfigAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, From 0118420e42ff56c36e9271dc06d6e6dbdfaea2af Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 09:31:02 +0100 Subject: [PATCH 22/33] feat(transaction-pay-controller): log Polymarket callback boundaries Wrap the two polymarket messenger calls in private helpers that log the EOA + derived deposit-wallet address on every getDepositWalletAddress call, and the EOA + depositWallet + call count + returned sourceHash on every submitDepositWalletBatch call. Makes it possible to trace the full predictWithdraw flow from the polymarket-withdraw logger alone without having to dig into client-side logs. --- .../src/strategy/relay/polymarket/withdraw.ts | 145 ++++++++++-------- 1 file changed, 83 insertions(+), 62 deletions(-) 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 a9a520d172..71a29073dc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -34,9 +34,9 @@ export async function applyPolymarketDepositWalletOverrides( request: QuoteRequest, messenger: TransactionPayControllerMessenger, ): Promise { - const depositWalletAddress = await messenger.call( - 'TransactionPayController:polymarketGetDepositWalletAddress', - { eoa: request.from }, + const depositWalletAddress = await getDepositWalletAddress( + messenger, + request.from, ); body.originCurrency = USDC_E_ADDRESS_POLYGON; @@ -50,10 +50,7 @@ export async function submitPolymarketWithdraw( from: Hex, messenger: TransactionPayControllerMessenger, ): Promise<{ sourceHash: Hex }> { - const depositWalletAddress = await messenger.call( - 'TransactionPayController:polymarketGetDepositWalletAddress', - { eoa: from }, - ); + const depositWalletAddress = await getDepositWalletAddress(messenger, from); const relayDepositAddress = extractRelayDepositAddress(quote.original); const amount = BigInt(quote.sourceAmount.raw); @@ -63,39 +60,33 @@ export async function submitPolymarketWithdraw( amount: amount.toString(), }); - return await messenger.call( - 'TransactionPayController:polymarketSubmitDepositWalletBatch', - { - eoa: from, - depositWallet: depositWalletAddress, - calls: [ - { - target: PUSD_ADDRESS_POLYGON, - value: '0', - data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), - }, - { - target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, - value: '0', - data: encodeUnwrap({ - asset: USDC_E_ADDRESS_POLYGON, - recipient: relayDepositAddress, - amount, - }), - }, - ], - }, - ); + return await submitDepositWalletBatch(messenger, { + eoa: from, + depositWallet: depositWalletAddress, + calls: [ + { + target: PUSD_ADDRESS_POLYGON, + value: '0', + data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: '0', + data: encodeUnwrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: relayDepositAddress, + amount, + }), + }, + ], + }); } export async function sweepPolymarketDepositWallet( from: Hex, messenger: TransactionPayControllerMessenger, ): Promise { - const depositWalletAddress = await messenger.call( - 'TransactionPayController:polymarketGetDepositWalletAddress', - { eoa: from }, - ); + const depositWalletAddress = await getDepositWalletAddress(messenger, from); let usdceBalance: bigint; try { @@ -122,39 +113,69 @@ export async function sweepPolymarketDepositWallet( } try { - const { sourceHash } = await messenger.call( - 'TransactionPayController:polymarketSubmitDepositWalletBatch', - { - eoa: from, - depositWallet: depositWalletAddress, - calls: [ - { - target: USDC_E_ADDRESS_POLYGON, - value: '0', - data: encodeApprove( - POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - usdceBalance, - ), - }, - { - target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - value: '0', - data: encodeWrap({ - asset: USDC_E_ADDRESS_POLYGON, - recipient: depositWalletAddress, - amount: usdceBalance, - }), - }, - ], - }, - ); - - log('USDC.e sweep: complete', { sourceHash }); + await submitDepositWalletBatch(messenger, { + eoa: from, + depositWallet: depositWalletAddress, + calls: [ + { + target: USDC_E_ADDRESS_POLYGON, + value: '0', + data: encodeApprove(POLYMARKET_COLLATERAL_ONRAMP_POLYGON, usdceBalance), + }, + { + target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + value: '0', + data: encodeWrap({ + asset: USDC_E_ADDRESS_POLYGON, + recipient: depositWalletAddress, + amount: usdceBalance, + }), + }, + ], + }); } catch (error) { log('USDC.e sweep: batch submission failed', { error }); } } +async function getDepositWalletAddress( + messenger: TransactionPayControllerMessenger, + eoa: Hex, +): Promise { + const depositWalletAddress = await messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa }, + ); + log('Polymarket callback: getDepositWalletAddress', { + eoa, + depositWalletAddress, + }); + return depositWalletAddress; +} + +async function submitDepositWalletBatch( + messenger: TransactionPayControllerMessenger, + params: { + eoa: Hex; + depositWallet: Hex; + calls: { target: Hex; data: Hex; value: string }[]; + }, +): Promise<{ sourceHash: Hex }> { + log('Polymarket callback: submitDepositWalletBatch', { + eoa: params.eoa, + depositWallet: params.depositWallet, + callCount: params.calls.length, + }); + const result = await messenger.call( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + params, + ); + log('Polymarket callback: submitDepositWalletBatch returned', { + sourceHash: result.sourceHash, + }); + return result; +} + function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); From f597724872f51e6a189de24bb4f9fead49b8d31e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 12 May 2026 11:36:00 +0100 Subject: [PATCH 23/33] test(transaction-pay-controller): cover Polymarket deposit-wallet flow Add unit tests for the new Polymarket deposit-wallet path so the 100% coverage threshold is met: - polymarket/withdraw.test.ts covering applyPolymarketDepositWalletOverrides, submitPolymarketWithdraw (happy path + missing deposit step + missing calldata), and sweepPolymarketDepositWallet (wraps non-zero balance, no-op on zero, swallows balance-read failures, swallows batch submission failures). - TransactionPayController.test.ts cases for the two new messenger actions (polymarketGetDepositWalletAddress, polymarketSubmitDepositWalletBatch): delegation to the supplied callbacks and the explicit error when the polymarket option was not provided at construction. - relay-submit.test.ts cases for the Polymarket source-leg branch in executeSingleQuote: routing through submitPolymarketWithdraw, USDC.e sweep after polling, throw-on-non-success post sweep, refund -> refunded wait under tolerateFailure, and tolerated timeout. - relay-quotes.test.ts case for the applyPolymarketDepositWalletOverrides hook on the Relay quote body. - messenger-mock additions for the two new polymarket action handlers consumed by the tests above. Drop one dead-code defensive fallback (transactionHash ?? sourceHash) and the unused default for waitForRelayCompletion options to bring branch coverage to 100%. Also: rename @param to match the rest-parameter name on the two new controller methods so the jsdoc lint rule is satisfied, and add @param descriptions for isPolymarketDepositWallet in buildQuoteRequests, buildPostQuoteRequests, and calculatePostQuoteSourceAmounts. Regenerate TransactionPayController-method-action-types.ts to pick up the rename. --- ...actionPayController-method-action-types.ts | 4 +- .../src/TransactionPayController.test.ts | 88 +++++++++ .../src/TransactionPayController.ts | 4 +- .../relay/polymarket/withdraw.test.ts | 183 ++++++++++++++++++ .../src/strategy/relay/polymarket/withdraw.ts | 7 +- .../src/strategy/relay/relay-quotes.test.ts | 39 ++++ .../src/strategy/relay/relay-submit.test.ts | 99 ++++++++++ .../src/strategy/relay/relay-submit.ts | 16 +- .../src/tests/messenger-mock.ts | 22 +++ .../src/utils/quotes.ts | 2 + .../src/utils/source-amounts.ts | 1 + 11 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 39b8d952fe..8afd1b0559 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -85,7 +85,7 @@ export type TransactionPayControllerGetStrategyAction = { * Derives the Polymarket deposit-wallet address for an EOA via the * client-supplied callback. * - * @param params - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. + * @param args - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. * @returns A promise resolving to the deposit-wallet address. */ export type TransactionPayControllerPolymarketGetDepositWalletAddressAction = { @@ -97,7 +97,7 @@ export type TransactionPayControllerPolymarketGetDepositWalletAddressAction = { * Signs and broadcasts a Polymarket deposit-wallet batch via the * client-supplied callback. * - * @param params - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. + * @param args - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. * @returns A promise resolving to the relayer-issued source hash. */ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index f86daa2642..72c65eb6f4 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -457,6 +457,94 @@ describe('TransactionPayController', () => { }); }); + describe('polymarket callbacks', () => { + const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex; + const DEPOSIT_WALLET_MOCK = + '0x2222222222222222222222222222222222222222' as Hex; + const SOURCE_HASH_MOCK: Hex = `0x${'aa'.repeat(32)}`; + + it('delegates polymarketGetDepositWalletAddress to the callback', async () => { + const getDepositWalletAddressMock = jest + .fn() + .mockResolvedValue(DEPOSIT_WALLET_MOCK); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + polymarket: { + getDepositWalletAddress: getDepositWalletAddressMock, + submitDepositWalletBatch: jest.fn(), + }, + }); + + const result = await messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa: EOA_MOCK }, + ); + + expect(getDepositWalletAddressMock).toHaveBeenCalledWith({ + eoa: EOA_MOCK, + }); + expect(result).toBe(DEPOSIT_WALLET_MOCK); + }); + + it('delegates polymarketSubmitDepositWalletBatch to the callback', async () => { + const submitDepositWalletBatchMock = jest + .fn() + .mockResolvedValue({ sourceHash: SOURCE_HASH_MOCK }); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + polymarket: { + getDepositWalletAddress: jest.fn(), + submitDepositWalletBatch: submitDepositWalletBatchMock, + }, + }); + + const params = { + eoa: EOA_MOCK, + depositWallet: DEPOSIT_WALLET_MOCK, + calls: [], + }; + const result = await messenger.call( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + params, + ); + + expect(submitDepositWalletBatchMock).toHaveBeenCalledWith(params); + expect(result).toStrictEqual({ sourceHash: SOURCE_HASH_MOCK }); + }); + + it('throws if polymarketGetDepositWalletAddress is invoked without callbacks supplied', () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + }); + + expect(() => + messenger.call( + 'TransactionPayController:polymarketGetDepositWalletAddress', + { eoa: EOA_MOCK }, + ), + ).toThrow('polymarket callbacks were not supplied'); + }); + + it('throws if polymarketSubmitDepositWalletBatch is invoked without callbacks supplied', () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + }); + + expect(() => + messenger.call( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + { eoa: EOA_MOCK, depositWallet: DEPOSIT_WALLET_MOCK, calls: [] }, + ), + ).toThrow('polymarket callbacks were not supplied'); + }); + }); + describe('getStrategy Action', () => { it('returns relay if no callback', async () => { createController(); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 7d17b4fcc2..3d56b935a2 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -233,7 +233,7 @@ export class TransactionPayController extends BaseController< * Derives the Polymarket deposit-wallet address for an EOA via the * client-supplied callback. * - * @param params - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. + * @param args - The arguments forwarded to {@link PolymarketCallbacks.getDepositWalletAddress}. * @returns A promise resolving to the deposit-wallet address. */ polymarketGetDepositWalletAddress( @@ -246,7 +246,7 @@ export class TransactionPayController extends BaseController< * Signs and broadcasts a Polymarket deposit-wallet batch via the * client-supplied callback. * - * @param params - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. + * @param args - The arguments forwarded to {@link PolymarketCallbacks.submitDepositWalletBatch}. * @returns A promise resolving to the relayer-issued source hash. */ polymarketSubmitDepositWalletBatch( diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts new file mode 100644 index 0000000000..fdead074fa --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -0,0 +1,183 @@ +import type { Hex } from '@metamask/utils'; + +import { getMessengerMock } from '../../../tests/messenger-mock'; +import type { QuoteRequest, TransactionPayQuote } from '../../../types'; +import { getLiveTokenBalance } from '../../../utils/token'; +import type { RelayQuote, RelayQuoteRequest } from '../types'; +import { + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + PUSD_ADDRESS_POLYGON, + USDC_E_ADDRESS_POLYGON, +} from './constants'; +import { + applyPolymarketDepositWalletOverrides, + submitPolymarketWithdraw, + sweepPolymarketDepositWallet, +} from './withdraw'; + +jest.mock('../../../utils/token'); + +const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const DEPOSIT_WALLET_MOCK = '0x2222222222222222222222222222222222222222' as Hex; +const SOURCE_HASH_MOCK: Hex = `0x${'aa'.repeat(32)}`; +const SOURCE_AMOUNT_RAW_MOCK = '1000000'; + +// transfer(0x1234...7890, 0) encoded calldata +const TRANSFER_CALLDATA_MOCK = + '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000003b9aca00' as Hex; + +function buildQuote( + overrides: Partial = {}, +): TransactionPayQuote { + return { + original: { + steps: [ + { + id: 'deposit', + kind: 'transaction', + items: [ + { + data: { + data: TRANSFER_CALLDATA_MOCK, + }, + }, + ], + }, + ], + ...overrides, + }, + sourceAmount: { + raw: SOURCE_AMOUNT_RAW_MOCK, + human: '1', + fiat: '1', + usd: '1', + }, + } as TransactionPayQuote; +} + +describe('Polymarket withdraw', () => { + const { + messenger, + polymarketGetDepositWalletAddressMock, + polymarketSubmitDepositWalletBatchMock, + } = getMessengerMock(); + const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); + + beforeEach(() => { + jest.resetAllMocks(); + polymarketGetDepositWalletAddressMock.mockResolvedValue( + DEPOSIT_WALLET_MOCK, + ); + polymarketSubmitDepositWalletBatchMock.mockResolvedValue({ + sourceHash: SOURCE_HASH_MOCK, + }); + getLiveTokenBalanceMock.mockResolvedValue('0'); + }); + + describe('applyPolymarketDepositWalletOverrides', () => { + it('rewrites the quote body for the deposit-wallet path', async () => { + const body = {} as RelayQuoteRequest; + const request = { from: EOA_MOCK } as QuoteRequest; + + await applyPolymarketDepositWalletOverrides(body, request, messenger); + + expect(polymarketGetDepositWalletAddressMock).toHaveBeenCalledWith({ + eoa: EOA_MOCK, + }); + expect(body).toStrictEqual({ + originCurrency: USDC_E_ADDRESS_POLYGON, + user: DEPOSIT_WALLET_MOCK, + refundTo: DEPOSIT_WALLET_MOCK, + useDepositAddress: true, + }); + }); + }); + + describe('submitPolymarketWithdraw', () => { + it('submits the approve + unwrap batch via the relayer callback', async () => { + const quote = buildQuote(); + + const result = await submitPolymarketWithdraw(quote, EOA_MOCK, messenger); + + expect(result).toStrictEqual({ sourceHash: SOURCE_HASH_MOCK }); + expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); + const call = polymarketSubmitDepositWalletBatchMock.mock.calls[0][0]; + expect(call.eoa).toBe(EOA_MOCK); + expect(call.depositWallet).toBe(DEPOSIT_WALLET_MOCK); + expect(call.calls).toHaveLength(2); + expect(call.calls[0].target).toBe(PUSD_ADDRESS_POLYGON); + expect(call.calls[0].value).toBe('0'); + expect(call.calls[1].target).toBe(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON); + expect(call.calls[1].value).toBe('0'); + }); + + it('throws when the Relay quote has no deposit step', async () => { + const quote = buildQuote({ steps: [] } as Partial); + + await expect( + submitPolymarketWithdraw(quote, EOA_MOCK, messenger), + ).rejects.toThrow('Relay quote has no deposit step'); + }); + + it('throws when the Relay quote deposit step is missing calldata', async () => { + const quote = buildQuote({ + steps: [ + { + id: 'deposit', + kind: 'transaction', + items: [{ data: {} }], + }, + ], + } as unknown as Partial); + + await expect( + submitPolymarketWithdraw(quote, EOA_MOCK, messenger), + ).rejects.toThrow('deposit step is missing calldata'); + }); + }); + + describe('sweepPolymarketDepositWallet', () => { + it('wraps any USDC.e balance back into pUSD on the deposit wallet', async () => { + getLiveTokenBalanceMock.mockResolvedValue('5000000'); + + await sweepPolymarketDepositWallet(EOA_MOCK, messenger); + + expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); + const call = polymarketSubmitDepositWalletBatchMock.mock.calls[0][0]; + expect(call.eoa).toBe(EOA_MOCK); + expect(call.depositWallet).toBe(DEPOSIT_WALLET_MOCK); + expect(call.calls).toHaveLength(2); + expect(call.calls[0].target).toBe(USDC_E_ADDRESS_POLYGON); + expect(call.calls[1].target).toBe(POLYMARKET_COLLATERAL_ONRAMP_POLYGON); + }); + + it('is a no-op when the USDC.e balance is zero', async () => { + getLiveTokenBalanceMock.mockResolvedValue('0'); + + await sweepPolymarketDepositWallet(EOA_MOCK, messenger); + + expect(polymarketSubmitDepositWalletBatchMock).not.toHaveBeenCalled(); + }); + + it('does not throw when the balance read fails', async () => { + getLiveTokenBalanceMock.mockRejectedValue(new Error('rpc down')); + + expect( + await sweepPolymarketDepositWallet(EOA_MOCK, messenger), + ).toBeUndefined(); + expect(polymarketSubmitDepositWalletBatchMock).not.toHaveBeenCalled(); + }); + + it('does not throw when the wrap-back batch submission fails', async () => { + getLiveTokenBalanceMock.mockResolvedValue('5000000'); + polymarketSubmitDepositWalletBatchMock.mockRejectedValueOnce( + new Error('relayer down'), + ); + + expect( + await sweepPolymarketDepositWallet(EOA_MOCK, messenger), + ).toBeUndefined(); + }); + }); +}); 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 71a29073dc..129107302f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -120,7 +120,10 @@ export async function sweepPolymarketDepositWallet( { target: USDC_E_ADDRESS_POLYGON, value: '0', - data: encodeApprove(POLYMARKET_COLLATERAL_ONRAMP_POLYGON, usdceBalance), + data: encodeApprove( + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + usdceBalance, + ), }, { target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, @@ -179,7 +182,7 @@ async function submitDepositWalletBatch( function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { const depositStep = relayQuote.steps.find((step) => step.id === 'deposit'); - if (!depositStep || depositStep.kind !== 'transaction') { + if (depositStep?.kind !== 'transaction') { throw new Error( 'Polymarket deposit wallet withdraw: Relay quote has no deposit step', ); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 337b829e1d..18ad3896c8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -183,6 +183,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock, getKeyringControllerStateMock, getRemoteFeatureFlagControllerStateMock, + polymarketGetDepositWalletAddressMock, } = getMessengerMock(); beforeEach(() => { @@ -3336,6 +3337,44 @@ describe('Relay Quotes Utils', () => { ).rejects.toThrow('Failed to fetch Relay quotes'); }); + describe('Polymarket deposit-wallet source (isPolymarketDepositWallet)', () => { + const DEPOSIT_WALLET_MOCK = + '0x2222222222222222222222222222222222222222' as Hex; + const POLYMARKET_REQUEST: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + isPolymarketDepositWallet: true, + }; + + it('overrides origin currency, user, refundTo and useDepositAddress on the quote body', async () => { + polymarketGetDepositWalletAddressMock.mockResolvedValue( + DEPOSIT_WALLET_MOCK, + ); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [POLYMARKET_REQUEST], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.originCurrency).toBe( + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + ); + expect(body.user).toBe(DEPOSIT_WALLET_MOCK); + expect(body.refundTo).toBe(DEPOSIT_WALLET_MOCK); + expect(body.useDepositAddress).toBe(true); + }); + }); + describe('gas buffer support', () => { it('applies buffer to single transaction gas estimate', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index eb47e5a6a0..d59a817f1e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -31,6 +31,7 @@ jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); jest.mock('./hyperliquid-withdraw'); +jest.mock('./polymarket/withdraw'); const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; @@ -1332,6 +1333,104 @@ describe('Relay Submit Utils', () => { }); }); + describe('Polymarket deposit-wallet source', () => { + const POLYMARKET_SOURCE_HASH_MOCK: Hex = `0x${'bb'.repeat(32)}`; + + function getPolymarketMocks(): { + submitPolymarketWithdraw: jest.Mock; + sweepPolymarketDepositWallet: jest.Mock; + } { + const mod = jest.requireMock('./polymarket/withdraw'); + return { + submitPolymarketWithdraw: mod.submitPolymarketWithdraw as jest.Mock, + sweepPolymarketDepositWallet: + mod.sweepPolymarketDepositWallet as jest.Mock, + }; + } + + beforeEach(() => { + const { submitPolymarketWithdraw, sweepPolymarketDepositWallet } = + getPolymarketMocks(); + submitPolymarketWithdraw.mockResolvedValue({ + sourceHash: POLYMARKET_SOURCE_HASH_MOCK, + }); + sweepPolymarketDepositWallet.mockResolvedValue(undefined); + request.quotes[0].request.isPolymarketDepositWallet = true; + request.quotes[0].original.steps[0].kind = 'transaction'; + }); + + it('routes the source leg through submitPolymarketWithdraw and skips submitTransactions', async () => { + const { submitPolymarketWithdraw } = getPolymarketMocks(); + + await submitRelayQuotes(request); + + expect(submitPolymarketWithdraw).toHaveBeenCalledWith( + request.quotes[0], + FROM_MOCK, + messenger, + ); + expect(addTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + }); + + it('runs the USDC.e sweep after Relay polling completes', async () => { + const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + + await submitRelayQuotes(request); + + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + ); + }); + + it('throws after sweeping if Relay terminates with a non-success status', async () => { + const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'refunded' }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay request failed with status: refunded', + ); + expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); + }); + + it('treats refund as pending and keeps polling until refunded (tolerated)', async () => { + const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'refund' }), + } as Response) + .mockResolvedValue({ + ok: true, + json: async () => ({ status: 'refunded' }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay request failed with status: refunded', + ); + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); + }); + + it('returns timeout (tolerated) when Relay polling times out', async () => { + const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + getRelayPollingTimeoutMock.mockReturnValue(1); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'pending' }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay request failed with status: timeout', + ); + expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); + }); + }); + describe('EIP-7702 execute path', () => { const DELEGATION_MANAGER_MOCK = '0xdelegationManager' as Hex; const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; 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 022e5b1203..2dcdcc3b96 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -96,7 +96,6 @@ async function executeSingleQuote( log('Executing single quote', quote); const isPolymarket = Boolean(quote.request.isPolymarketDepositWallet); - let sourceHash: Hex | undefined; updateTransaction( { @@ -112,12 +111,11 @@ async function executeSingleQuote( if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); } else if (isPolymarket) { - const result = await submitPolymarketWithdraw( + const { sourceHash } = await submitPolymarketWithdraw( quote, quote.request.from, messenger, ); - sourceHash = result.sourceHash; setRelaySourceHash(transaction, messenger, sourceHash); } else { await submitTransactions(quote, transaction, messenger); @@ -137,9 +135,7 @@ async function executeSingleQuote( await sweepPolymarketDepositWallet(quote.request.from, messenger); if (completion.status !== 'success') { - throw new Error( - `Relay request failed with status: ${completion.status}`, - ); + throw new Error(`Relay request failed with status: ${completion.status}`); } } @@ -154,7 +150,7 @@ async function executeSingleQuote( }, ); - return { transactionHash: completion.targetHash ?? sourceHash }; + return { transactionHash: completion.targetHash }; } function setRelaySourceHash( @@ -186,7 +182,7 @@ async function waitForRelayCompletion( options: { onSourceHash?: (hash: Hex) => void; tolerateFailure?: boolean; - } = {}, + }, ): Promise { const { onSourceHash, tolerateFailure } = options; @@ -250,9 +246,7 @@ async function waitForRelayCompletion( log('Relay ended in failure status (tolerated)', status.status); return { status: status.status }; } - throw new Error( - `Relay request failed with status: ${status.status}`, - ); + throw new Error(`Relay request failed with status: ${status.status}`); } throw new Error(`Relay returned unrecognized status: ${status.status}`); } diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index cf09614a15..1931aa202a 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -30,6 +30,8 @@ import type { TransactionPayControllerMessenger } from '..'; import type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerPolymarketGetDepositWalletAddressAction, + TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, } from '../TransactionPayController-method-action-types'; import type { TransactionPayControllerGetStateAction } from '../types'; @@ -118,6 +120,14 @@ export function getMessengerMock({ TransactionPayControllerGetDelegationTransactionAction['handler'] > = jest.fn(); + const polymarketGetDepositWalletAddressMock: jest.MockedFn< + TransactionPayControllerPolymarketGetDepositWalletAddressAction['handler'] + > = jest.fn(); + + const polymarketSubmitDepositWalletBatchMock: jest.MockedFn< + TransactionPayControllerPolymarketSubmitDepositWalletBatchAction['handler'] + > = jest.fn(); + const getGasFeeTokensMock: jest.MockedFn< TransactionControllerGetGasFeeTokensAction['handler'] > = jest.fn(); @@ -245,6 +255,16 @@ export function getMessengerMock({ getDelegationTransactionMock, ); + messenger.registerActionHandler( + 'TransactionPayController:polymarketGetDepositWalletAddress', + polymarketGetDepositWalletAddressMock, + ); + + messenger.registerActionHandler( + 'TransactionPayController:polymarketSubmitDepositWalletBatch', + polymarketSubmitDepositWalletBatchMock, + ); + messenger.registerActionHandler( 'TransactionController:getGasFeeTokens', getGasFeeTokensMock, @@ -297,6 +317,8 @@ export function getMessengerMock({ getTokensControllerStateMock, getTransactionControllerStateMock, messenger: messenger as TransactionPayControllerMessenger, + polymarketGetDepositWalletAddressMock, + polymarketSubmitDepositWalletBatchMock, publish, submitTransactionMock, updateTransactionMock, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 13c923ea3e..ba2ec2afd7 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -324,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. @@ -407,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). diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index c713565edb..47fe97f73c 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -84,6 +84,7 @@ export function updateSourceAmounts( * @param paymentToken - Selected payment/destination token. * @param isMaxAmount - Whether the transaction is a maximum amount transaction. * @param isHyperliquidSource - Whether the source is HyperLiquid (perps withdrawal). + * @param isPolymarketDepositWallet - Whether the source is a Polymarket deposit wallet. * @returns Array of source amounts. */ function calculatePostQuoteSourceAmounts( From db00c0e91777e1207430ee0fd0224c0e13aff236 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 12:16:47 +0100 Subject: [PATCH 24/33] feat(transaction-pay-controller): refund-aware Polymarket USDC.e sweep When Relay strict mode returns the funds to the deposit wallet, the post-completion sweep used to fire immediately after polling and could either (a) wrap a stale pre-refund USDC.e balance the block tracker had cached, or (b) submit the wrap batch before the Polymarket relayer's RPC node had observed the refund, causing the batch to revert in simulation. This change addresses both: - Read live USDC.e balances with `blockTag: 'pending'` so the network-controller block cache and block-ref middleware are bypassed (cf. `cacheTypeForMethod` / `block-ref`) and we always hit the RPC for the current state instead of whatever the block tracker last polled up to 20s ago. - `submitPolymarketWithdraw` captures the pre-submit USDC.e balance alongside the source hash and returns both. - `sweepPolymarketDepositWallet` now takes the terminal `relayStatus` and the captured `preSubmitUsdceBalance`. On `refund` / `refunded` it polls the live balance up to five times at 1s intervals, waiting for the balance to exceed the pre-submit value, then sleeps an additional three seconds to let the Polymarket relayer's RPC catch up before submitting the wrap batch. The full new balance is wrapped, so any residue from previous failed runs is also cleared. - Drop the `refund`-is-mid-flight branch from `waitForRelayCompletion` now that both `refund` and `refunded` are surfaced to the caller as terminal statuses and the sweep handles the timing. - Add a `strict: true` flag to `RelayQuoteRequest` and set it on the Polymarket override so underpayments are auto-refunded by Relay instead of producing variable fills. - Move the sweep retry/settle constants into the polymarket constants module so they live alongside the addresses they relate to. --- .../strategy/relay/polymarket/constants.ts | 6 + .../relay/polymarket/withdraw.test.ts | 105 ++++++++++++++- .../src/strategy/relay/polymarket/withdraw.ts | 121 +++++++++++++++--- .../src/strategy/relay/relay-quotes.test.ts | 1 + .../src/strategy/relay/relay-submit.test.ts | 60 ++++++--- .../src/strategy/relay/relay-submit.ts | 23 ++-- .../src/strategy/relay/types.ts | 6 + .../src/utils/token.test.ts | 10 +- .../src/utils/token.ts | 4 +- 9 files changed, 273 insertions(+), 63 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts index da79fd738c..669132b7db 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -11,3 +11,9 @@ export const POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = '0x93070a847efEf7F70739046A929D47a521F5B8ee' as Hex; + +export const SWEEP_BALANCE_RETRY_ATTEMPTS = 5; + +export const SWEEP_BALANCE_RETRY_DELAY_MS = 1000; + +export const SWEEP_RELAYER_SETTLE_DELAY_MS = 3000; diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts index fdead074fa..3578a26615 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -90,6 +90,7 @@ describe('Polymarket withdraw', () => { user: DEPOSIT_WALLET_MOCK, refundTo: DEPOSIT_WALLET_MOCK, useDepositAddress: true, + strict: true, }); }); }); @@ -100,7 +101,10 @@ describe('Polymarket withdraw', () => { const result = await submitPolymarketWithdraw(quote, EOA_MOCK, messenger); - expect(result).toStrictEqual({ sourceHash: SOURCE_HASH_MOCK }); + expect(result).toStrictEqual({ + sourceHash: SOURCE_HASH_MOCK, + preSubmitUsdceBalance: 0n, + }); expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); const call = polymarketSubmitDepositWalletBatchMock.mock.calls[0][0]; expect(call.eoa).toBe(EOA_MOCK); @@ -112,6 +116,30 @@ describe('Polymarket withdraw', () => { expect(call.calls[1].value).toBe('0'); }); + it('captures the pre-submit USDC.e balance', async () => { + getLiveTokenBalanceMock.mockResolvedValue('2500000'); + + const result = await submitPolymarketWithdraw( + buildQuote(), + EOA_MOCK, + messenger, + ); + + expect(result.preSubmitUsdceBalance).toBe(2500000n); + }); + + it('defaults pre-submit balance to zero when the balance read fails', async () => { + getLiveTokenBalanceMock.mockRejectedValue(new Error('rpc down')); + + const result = await submitPolymarketWithdraw( + buildQuote(), + EOA_MOCK, + messenger, + ); + + expect(result.preSubmitUsdceBalance).toBe(0n); + }); + it('throws when the Relay quote has no deposit step', async () => { const quote = buildQuote({ steps: [] } as Partial); @@ -138,10 +166,15 @@ describe('Polymarket withdraw', () => { }); describe('sweepPolymarketDepositWallet', () => { + const successOptions = { + relayStatus: 'success' as const, + preSubmitUsdceBalance: 0n, + }; + it('wraps any USDC.e balance back into pUSD on the deposit wallet', async () => { getLiveTokenBalanceMock.mockResolvedValue('5000000'); - await sweepPolymarketDepositWallet(EOA_MOCK, messenger); + await sweepPolymarketDepositWallet(EOA_MOCK, messenger, successOptions); expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); const call = polymarketSubmitDepositWalletBatchMock.mock.calls[0][0]; @@ -155,7 +188,7 @@ describe('Polymarket withdraw', () => { it('is a no-op when the USDC.e balance is zero', async () => { getLiveTokenBalanceMock.mockResolvedValue('0'); - await sweepPolymarketDepositWallet(EOA_MOCK, messenger); + await sweepPolymarketDepositWallet(EOA_MOCK, messenger, successOptions); expect(polymarketSubmitDepositWalletBatchMock).not.toHaveBeenCalled(); }); @@ -164,7 +197,7 @@ describe('Polymarket withdraw', () => { getLiveTokenBalanceMock.mockRejectedValue(new Error('rpc down')); expect( - await sweepPolymarketDepositWallet(EOA_MOCK, messenger), + await sweepPolymarketDepositWallet(EOA_MOCK, messenger, successOptions), ).toBeUndefined(); expect(polymarketSubmitDepositWalletBatchMock).not.toHaveBeenCalled(); }); @@ -176,8 +209,70 @@ describe('Polymarket withdraw', () => { ); expect( - await sweepPolymarketDepositWallet(EOA_MOCK, messenger), + await sweepPolymarketDepositWallet(EOA_MOCK, messenger, successOptions), ).toBeUndefined(); }); + + describe('when relayStatus is refund', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('retries until the balance exceeds the pre-submit balance, waits for the relayer to settle, then sweeps the full new balance', async () => { + getLiveTokenBalanceMock + .mockResolvedValueOnce('1000000') + .mockResolvedValueOnce('1000000') + .mockResolvedValueOnce('4000000'); + + const sweepPromise = sweepPolymarketDepositWallet(EOA_MOCK, messenger, { + relayStatus: 'refund', + preSubmitUsdceBalance: 1000000n, + }); + + await jest.advanceTimersByTimeAsync(5000); + await sweepPromise; + + expect(getLiveTokenBalanceMock).toHaveBeenCalledTimes(3); + expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); + const call = polymarketSubmitDepositWalletBatchMock.mock.calls[0][0]; + expect(call.calls[1].target).toBe(POLYMARKET_COLLATERAL_ONRAMP_POLYGON); + }); + + it('also retries when relayStatus is refunded', async () => { + getLiveTokenBalanceMock + .mockResolvedValueOnce('1000000') + .mockResolvedValueOnce('4000000'); + + const sweepPromise = sweepPolymarketDepositWallet(EOA_MOCK, messenger, { + relayStatus: 'refunded', + preSubmitUsdceBalance: 1000000n, + }); + + await jest.advanceTimersByTimeAsync(4000); + await sweepPromise; + + expect(getLiveTokenBalanceMock).toHaveBeenCalledTimes(2); + expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); + }); + + it('gives up after five attempts and sweeps the residual stale balance', async () => { + getLiveTokenBalanceMock.mockResolvedValue('1000000'); + + const sweepPromise = sweepPolymarketDepositWallet(EOA_MOCK, messenger, { + relayStatus: 'refund', + preSubmitUsdceBalance: 1000000n, + }); + + await jest.advanceTimersByTimeAsync(4000); + await sweepPromise; + + expect(getLiveTokenBalanceMock).toHaveBeenCalledTimes(5); + expect(polymarketSubmitDepositWalletBatchMock).toHaveBeenCalledTimes(1); + }); + }); }); }); 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 129107302f..66d14c5f2b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -12,6 +12,7 @@ import { getLiveTokenBalance } from '../../../utils/token'; import type { RelayQuote, RelayQuoteRequest, + RelayStatus, RelayTransactionStep, } from '../types'; import { @@ -24,6 +25,9 @@ import { POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, POLYMARKET_COLLATERAL_ONRAMP_POLYGON, PUSD_ADDRESS_POLYGON, + SWEEP_BALANCE_RETRY_ATTEMPTS, + SWEEP_BALANCE_RETRY_DELAY_MS, + SWEEP_RELAYER_SETTLE_DELAY_MS, USDC_E_ADDRESS_POLYGON, } from './constants'; @@ -43,24 +47,31 @@ export async function applyPolymarketDepositWalletOverrides( body.user = depositWalletAddress; body.refundTo = depositWalletAddress; body.useDepositAddress = true; + body.strict = true; } export async function submitPolymarketWithdraw( quote: TransactionPayQuote, from: Hex, messenger: TransactionPayControllerMessenger, -): Promise<{ sourceHash: Hex }> { +): Promise<{ sourceHash: Hex; preSubmitUsdceBalance: bigint }> { const depositWalletAddress = await getDepositWalletAddress(messenger, from); const relayDepositAddress = extractRelayDepositAddress(quote.original); const amount = BigInt(quote.sourceAmount.raw); + const preSubmitUsdceBalance = await readUsdceBalanceOrZero( + messenger, + depositWalletAddress, + ); + log('Submitting unwrap batch to Relay deposit address', { depositWalletAddress, relayDepositAddress, amount: amount.toString(), + preSubmitUsdceBalance: preSubmitUsdceBalance.toString(), }); - return await submitDepositWalletBatch(messenger, { + const result = await submitDepositWalletBatch(messenger, { eoa: from, depositWallet: depositWalletAddress, calls: [ @@ -80,38 +91,47 @@ export async function submitPolymarketWithdraw( }, ], }); + + return { ...result, preSubmitUsdceBalance }; } export async function sweepPolymarketDepositWallet( from: Hex, messenger: TransactionPayControllerMessenger, + options: { + relayStatus: RelayStatus | 'timeout'; + preSubmitUsdceBalance: bigint; + }, ): Promise { + const isRefund = + options.relayStatus === 'refund' || options.relayStatus === 'refunded'; + const waitForBalanceAbove = isRefund + ? options.preSubmitUsdceBalance + : undefined; + const depositWalletAddress = await getDepositWalletAddress(messenger, from); + const usdceBalance = await readDepositWalletUsdceBalance( + messenger, + depositWalletAddress, + waitForBalanceAbove, + ); - 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 }); + if (usdceBalance === undefined) { return; } - log('USDC.e sweep: deposit wallet balance', { - depositWalletAddress, - balance: usdceBalance.toString(), - }); - if (usdceBalance === 0n) { log('USDC.e sweep: nothing to wrap'); return; } + if (waitForBalanceAbove !== undefined && usdceBalance > waitForBalanceAbove) { + log('USDC.e sweep: waiting for relayer RPC to catch up to new balance'); + await new Promise((resolve) => + setTimeout(resolve, SWEEP_RELAYER_SETTLE_DELAY_MS), + ); + } + try { await submitDepositWalletBatch(messenger, { eoa: from, @@ -141,6 +161,71 @@ export async function sweepPolymarketDepositWallet( } } +async function readUsdceBalanceOrZero( + messenger: TransactionPayControllerMessenger, + depositWalletAddress: Hex, +): Promise { + try { + const raw = await getLiveTokenBalance( + messenger, + depositWalletAddress, + CHAIN_ID_POLYGON, + USDC_E_ADDRESS_POLYGON, + ); + return BigInt(raw); + } catch (error) { + log('USDC.e balance read failed, defaulting to zero', { error }); + return 0n; + } +} + +async function readDepositWalletUsdceBalance( + messenger: TransactionPayControllerMessenger, + depositWalletAddress: Hex, + waitForBalanceAbove: bigint | undefined, +): Promise { + const shouldRetry = waitForBalanceAbove !== undefined; + const maxAttempts = shouldRetry ? SWEEP_BALANCE_RETRY_ATTEMPTS : 1; + let lastBalance = 0n; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (attempt > 1) { + await new Promise((resolve) => + setTimeout(resolve, SWEEP_BALANCE_RETRY_DELAY_MS), + ); + } + + try { + const raw = await getLiveTokenBalance( + messenger, + depositWalletAddress, + CHAIN_ID_POLYGON, + USDC_E_ADDRESS_POLYGON, + ); + lastBalance = BigInt(raw); + } catch (error) { + log('USDC.e sweep: failed to read deposit wallet balance', { error }); + return undefined; + } + + log('USDC.e sweep: deposit wallet balance', { + depositWalletAddress, + balance: lastBalance.toString(), + attempt, + waitForBalanceAbove: waitForBalanceAbove?.toString(), + }); + + const hasIncreased = + waitForBalanceAbove === undefined || lastBalance > waitForBalanceAbove; + + if (hasIncreased) { + return lastBalance; + } + } + + return lastBalance; +} + async function getDepositWalletAddress( messenger: TransactionPayControllerMessenger, eoa: Hex, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 18ad3896c8..8fa4d462a7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -3372,6 +3372,7 @@ describe('Relay Quotes Utils', () => { expect(body.user).toBe(DEPOSIT_WALLET_MOCK); expect(body.refundTo).toBe(DEPOSIT_WALLET_MOCK); expect(body.useDepositAddress).toBe(true); + expect(body.strict).toBe(true); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index d59a817f1e..5278b7da4e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -659,7 +659,7 @@ describe('Relay Submit Utils', () => { ); }); - it.each(['failure', 'refund', 'refunded'])( + it.each(['failure', 'refund'])( 'throws if relay status is %s', async (status) => { successfulFetchMock.mockResolvedValue({ @@ -1353,6 +1353,7 @@ describe('Relay Submit Utils', () => { getPolymarketMocks(); submitPolymarketWithdraw.mockResolvedValue({ sourceHash: POLYMARKET_SOURCE_HASH_MOCK, + preSubmitUsdceBalance: 0n, }); sweepPolymarketDepositWallet.mockResolvedValue(undefined); request.quotes[0].request.isPolymarketDepositWallet = true; @@ -1373,7 +1374,7 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).not.toHaveBeenCalled(); }); - it('runs the USDC.e sweep after Relay polling completes', async () => { + it('runs the USDC.e sweep with the success status on success', async () => { const { sweepPolymarketDepositWallet } = getPolymarketMocks(); await submitRelayQuotes(request); @@ -1381,39 +1382,52 @@ describe('Relay Submit Utils', () => { expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( FROM_MOCK, messenger, + { relayStatus: 'success', preSubmitUsdceBalance: 0n }, ); }); - it('throws after sweeping if Relay terminates with a non-success status', async () => { - const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + it('passes the refund status and pre-submit balance to the sweep on refund', async () => { + const { submitPolymarketWithdraw, sweepPolymarketDepositWallet } = + getPolymarketMocks(); + submitPolymarketWithdraw.mockResolvedValue({ + sourceHash: POLYMARKET_SOURCE_HASH_MOCK, + preSubmitUsdceBalance: 1000000n, + }); successfulFetchMock.mockResolvedValue({ ok: true, - json: async () => ({ status: 'refunded' }), + json: async () => ({ status: 'refund' }), } as Response); await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Relay request failed with status: refunded', + 'Relay request failed with status: refund', + ); + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'refund', preSubmitUsdceBalance: 1000000n }, ); - expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); }); - it('treats refund as pending and keeps polling until refunded (tolerated)', async () => { - const { sweepPolymarketDepositWallet } = getPolymarketMocks(); - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ status: 'refund' }), - } as Response) - .mockResolvedValue({ - ok: true, - json: async () => ({ status: 'refunded' }), - } as Response); + it('passes the refunded status and pre-submit balance to the sweep on refunded', async () => { + const { submitPolymarketWithdraw, sweepPolymarketDepositWallet } = + getPolymarketMocks(); + submitPolymarketWithdraw.mockResolvedValue({ + sourceHash: POLYMARKET_SOURCE_HASH_MOCK, + preSubmitUsdceBalance: 2500000n, + }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'refunded' }), + } as Response); await expect(submitRelayQuotes(request)).rejects.toThrow( 'Relay request failed with status: refunded', ); - expect(successfulFetchMock).toHaveBeenCalledTimes(2); - expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'refunded', preSubmitUsdceBalance: 2500000n }, + ); }); it('returns timeout (tolerated) when Relay polling times out', async () => { @@ -1427,7 +1441,11 @@ describe('Relay Submit Utils', () => { await expect(submitRelayQuotes(request)).rejects.toThrow( 'Relay request failed with status: timeout', ); - expect(sweepPolymarketDepositWallet).toHaveBeenCalled(); + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'timeout', preSubmitUsdceBalance: 0n }, + ); }); }); 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 2dcdcc3b96..372a016ab5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -108,14 +108,14 @@ async function executeSingleQuote( }, ); + let polymarketPreSubmitUsdceBalance = 0n; + if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); } else if (isPolymarket) { - const { sourceHash } = await submitPolymarketWithdraw( - quote, - quote.request.from, - messenger, - ); + const { sourceHash, preSubmitUsdceBalance } = + await submitPolymarketWithdraw(quote, quote.request.from, messenger); + polymarketPreSubmitUsdceBalance = preSubmitUsdceBalance; setRelaySourceHash(transaction, messenger, sourceHash); } else { await submitTransactions(quote, transaction, messenger); @@ -132,7 +132,10 @@ async function executeSingleQuote( log('Relay request completed', completion); if (isPolymarket) { - await sweepPolymarketDepositWallet(quote.request.from, messenger); + await sweepPolymarketDepositWallet(quote.request.from, messenger, { + relayStatus: completion.status, + preSubmitUsdceBalance: polymarketPreSubmitUsdceBalance, + }); if (completion.status !== 'success') { throw new Error(`Relay request failed with status: ${completion.status}`); @@ -234,13 +237,7 @@ async function waitForRelayCompletion( return { status: 'success', targetHash }; } - // When tolerating failure, refund is mid-flight (refund tx not yet - // confirmed) - keep polling until refunded. - const isPendingForCaller = - RELAY_PENDING_STATUSES.includes(status.status) || - (tolerateFailure && status.status === 'refund'); - - if (!isPendingForCaller) { + if (!RELAY_PENDING_STATUSES.includes(status.status)) { if (RELAY_FAILURE_STATUSES.includes(status.status)) { if (tolerateFailure) { log('Relay ended in failure status (tolerated)', status.status); diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index ea75599b72..b7f50975b5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -31,6 +31,12 @@ export type RelayQuoteRequest = { * by Relay for major tokens; rejected otherwise. */ useDepositAddress?: boolean; + /** + * When combined with `useDepositAddress`, ties the deposit address to a + * specific order. Underpayments fail and refund; exact payments and + * overpayments fill. Requires `refundTo`. + */ + strict?: boolean; user: Hex; }; diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index bb129d43b8..ffed34cbc1 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -654,7 +654,9 @@ describe('Token Utils', () => { expect.anything(), expect.anything(), ); - expect(mockBalanceOf).toHaveBeenCalledWith(ACCOUNT_MOCK); + expect(mockBalanceOf).toHaveBeenCalledWith(ACCOUNT_MOCK, { + blockTag: 'pending', + }); }); it('returns native balance via ethersProvider.getBalance', async () => { @@ -670,7 +672,7 @@ describe('Token Utils', () => { ); expect(result).toBe('1000000000000000000'); - expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK); + expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK, 'pending'); expect(Contract).not.toHaveBeenCalled(); }); @@ -687,7 +689,7 @@ describe('Token Utils', () => { ); expect(result).toBe('2000000000000000000'); - expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK); + expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK, 'pending'); expect(Contract).not.toHaveBeenCalled(); }); @@ -702,7 +704,7 @@ describe('Token Utils', () => { ); expect(result).toBe('500'); - expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK); + expect(mockGetBalance).toHaveBeenCalledWith(ACCOUNT_MOCK, 'pending'); expect(Contract).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index b7d4fde67c..ffbd6bcb25 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -333,12 +333,12 @@ export async function getLiveTokenBalance( tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); if (isNative) { - const balance = await ethersProvider.getBalance(account); + const balance = await ethersProvider.getBalance(account, 'pending'); return balance.toString(); } const contract = new Contract(tokenAddress, abiERC20, ethersProvider); - const balance = await contract.balanceOf(account); + const balance = await contract.balanceOf(account, { blockTag: 'pending' }); return balance.toString(); } From d2fc4fce2fa0af9a2740a4e3f7072475167852ec Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 12:36:00 +0100 Subject: [PATCH 25/33] chore(transaction-pay-controller): restore Polymarket changelog entry post-rebase Re-adding the Polymarket deposit-wallet `Unreleased` entry that was dropped during the rebase onto a base that now includes an Across `Unreleased` addition (#8760) and the 22.3.1 release. Without this, the merge-queue changelog diff check fails because the line we added in this PR is missing from the post-merge "Unreleased" section. --- packages/transaction-pay-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 1bc999c62c..9e879cf8e1 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 ### Added - Add Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) +- 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 From 7a637689a22c9e1976fb4044f890f708deb8ec64 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 13:58:50 +0100 Subject: [PATCH 26/33] chore(transaction-pay-controller): drop duplicate Polymarket changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9e879cf8e1..0fcfc3d3b5 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -43,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) -- 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 From 623936a453c3e2c0780f9ee125d791d010a4928b Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 13:58:50 +0100 Subject: [PATCH 27/33] refactor(transaction-pay-controller): shorten requirePolymarket error --- .../src/TransactionPayController.test.ts | 4 ++-- .../src/TransactionPayController.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 72c65eb6f4..76d3ecd6d6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -527,7 +527,7 @@ describe('TransactionPayController', () => { 'TransactionPayController:polymarketGetDepositWalletAddress', { eoa: EOA_MOCK }, ), - ).toThrow('polymarket callbacks were not supplied'); + ).toThrow('Polymarket callbacks missing'); }); it('throws if polymarketSubmitDepositWalletBatch is invoked without callbacks supplied', () => { @@ -541,7 +541,7 @@ describe('TransactionPayController', () => { 'TransactionPayController:polymarketSubmitDepositWalletBatch', { eoa: EOA_MOCK, depositWallet: DEPOSIT_WALLET_MOCK, calls: [] }, ), - ).toThrow('polymarket callbacks were not supplied'); + ).toThrow('Polymarket callbacks missing'); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 3d56b935a2..70b4b3f8d6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -257,9 +257,7 @@ export class TransactionPayController extends BaseController< #requirePolymarket(): PolymarketCallbacks { if (!this.#polymarket) { - throw new Error( - 'TransactionPayController: polymarket callbacks were not supplied to the controller constructor; the Polymarket deposit-wallet flow is not available in this client.', - ); + throw new Error('TransactionPayController: Polymarket callbacks missing'); } return this.#polymarket; } From ae87f2d60c40b58f464c822d7eaecd9088e6b17b Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 13:58:50 +0100 Subject: [PATCH 28/33] docs(transaction-pay-controller): document RelayQuoteRequest --- packages/transaction-pay-controller/src/strategy/relay/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index b7f50975b5..1e8134508e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +/** Request body POSTed to the Relay `/quote` endpoint. */ export type RelayQuoteRequest = { amount: string; authorizationList?: { From fbccd0e67d9facbe0893e44bbb333d51561338b6 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 14:12:22 +0100 Subject: [PATCH 29/33] revert(transaction-pay-controller): drop RelayQuoteRequest JSDoc --- packages/transaction-pay-controller/src/strategy/relay/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index 1e8134508e..b7f50975b5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -1,6 +1,5 @@ import type { Hex } from '@metamask/utils'; -/** Request body POSTed to the Relay `/quote` endpoint. */ export type RelayQuoteRequest = { amount: string; authorizationList?: { From 1eea71017b3766d35d6446e7ef97bb6ef1da56c1 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 14:12:22 +0100 Subject: [PATCH 30/33] docs(transaction-pay-controller): explain pending blockTag cache bypass --- packages/transaction-pay-controller/src/utils/token.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index ffbd6bcb25..493b268e1a 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -332,6 +332,9 @@ export async function getLiveTokenBalance( const isNative = tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); + // Use `pending` blockTag to bypass the RPC block-cache middleware so callers + // always observe the latest balance instead of a value pinned to the last + // polled block. if (isNative) { const balance = await ethersProvider.getBalance(account, 'pending'); return balance.toString(); From 4210fea93974addb8209f575c0563d302cc43e53 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 14:12:22 +0100 Subject: [PATCH 31/33] refactor(transaction-pay-controller): reuse canonical Polygon address constants --- .../strategy/relay/polymarket/constants.ts | 6 ----- .../relay/polymarket/withdraw.test.ts | 9 ++++---- .../src/strategy/relay/polymarket/withdraw.ts | 22 ++++++++++--------- .../src/strategy/relay/relay-quotes.test.ts | 5 ++--- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts index 669132b7db..9949ab3be9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -1,11 +1,5 @@ import type { Hex } from '@metamask/utils'; -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; diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts index 3578a26615..2900f9b6fd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +import { POLYGON_PUSD_ADDRESS, POLYGON_USDCE_ADDRESS } from '../../../constants'; import { getMessengerMock } from '../../../tests/messenger-mock'; import type { QuoteRequest, TransactionPayQuote } from '../../../types'; import { getLiveTokenBalance } from '../../../utils/token'; @@ -7,8 +8,6 @@ import type { RelayQuote, RelayQuoteRequest } from '../types'; import { POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - PUSD_ADDRESS_POLYGON, - USDC_E_ADDRESS_POLYGON, } from './constants'; import { applyPolymarketDepositWalletOverrides, @@ -86,7 +85,7 @@ describe('Polymarket withdraw', () => { eoa: EOA_MOCK, }); expect(body).toStrictEqual({ - originCurrency: USDC_E_ADDRESS_POLYGON, + originCurrency: POLYGON_USDCE_ADDRESS, user: DEPOSIT_WALLET_MOCK, refundTo: DEPOSIT_WALLET_MOCK, useDepositAddress: true, @@ -110,7 +109,7 @@ describe('Polymarket withdraw', () => { expect(call.eoa).toBe(EOA_MOCK); expect(call.depositWallet).toBe(DEPOSIT_WALLET_MOCK); expect(call.calls).toHaveLength(2); - expect(call.calls[0].target).toBe(PUSD_ADDRESS_POLYGON); + expect(call.calls[0].target).toBe(POLYGON_PUSD_ADDRESS); expect(call.calls[0].value).toBe('0'); expect(call.calls[1].target).toBe(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON); expect(call.calls[1].value).toBe('0'); @@ -181,7 +180,7 @@ describe('Polymarket withdraw', () => { expect(call.eoa).toBe(EOA_MOCK); expect(call.depositWallet).toBe(DEPOSIT_WALLET_MOCK); expect(call.calls).toHaveLength(2); - expect(call.calls[0].target).toBe(USDC_E_ADDRESS_POLYGON); + expect(call.calls[0].target).toBe(POLYGON_USDCE_ADDRESS); expect(call.calls[1].target).toBe(POLYMARKET_COLLATERAL_ONRAMP_POLYGON); }); 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 66d14c5f2b..30a14183a4 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -1,7 +1,11 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { CHAIN_ID_POLYGON } from '../../../constants'; +import { + CHAIN_ID_POLYGON, + POLYGON_PUSD_ADDRESS, + POLYGON_USDCE_ADDRESS, +} from '../../../constants'; import { projectLogger } from '../../../logger'; import type { QuoteRequest, @@ -24,11 +28,9 @@ import { import { POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, POLYMARKET_COLLATERAL_ONRAMP_POLYGON, - PUSD_ADDRESS_POLYGON, SWEEP_BALANCE_RETRY_ATTEMPTS, SWEEP_BALANCE_RETRY_DELAY_MS, SWEEP_RELAYER_SETTLE_DELAY_MS, - USDC_E_ADDRESS_POLYGON, } from './constants'; const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); @@ -43,7 +45,7 @@ export async function applyPolymarketDepositWalletOverrides( request.from, ); - body.originCurrency = USDC_E_ADDRESS_POLYGON; + body.originCurrency = POLYGON_USDCE_ADDRESS; body.user = depositWalletAddress; body.refundTo = depositWalletAddress; body.useDepositAddress = true; @@ -76,7 +78,7 @@ export async function submitPolymarketWithdraw( depositWallet: depositWalletAddress, calls: [ { - target: PUSD_ADDRESS_POLYGON, + target: POLYGON_PUSD_ADDRESS, value: '0', data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), }, @@ -84,7 +86,7 @@ export async function submitPolymarketWithdraw( target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, value: '0', data: encodeUnwrap({ - asset: USDC_E_ADDRESS_POLYGON, + asset: POLYGON_USDCE_ADDRESS, recipient: relayDepositAddress, amount, }), @@ -138,7 +140,7 @@ export async function sweepPolymarketDepositWallet( depositWallet: depositWalletAddress, calls: [ { - target: USDC_E_ADDRESS_POLYGON, + target: POLYGON_USDCE_ADDRESS, value: '0', data: encodeApprove( POLYMARKET_COLLATERAL_ONRAMP_POLYGON, @@ -149,7 +151,7 @@ export async function sweepPolymarketDepositWallet( target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, value: '0', data: encodeWrap({ - asset: USDC_E_ADDRESS_POLYGON, + asset: POLYGON_USDCE_ADDRESS, recipient: depositWalletAddress, amount: usdceBalance, }), @@ -170,7 +172,7 @@ async function readUsdceBalanceOrZero( messenger, depositWalletAddress, CHAIN_ID_POLYGON, - USDC_E_ADDRESS_POLYGON, + POLYGON_USDCE_ADDRESS, ); return BigInt(raw); } catch (error) { @@ -200,7 +202,7 @@ async function readDepositWalletUsdceBalance( messenger, depositWalletAddress, CHAIN_ID_POLYGON, - USDC_E_ADDRESS_POLYGON, + POLYGON_USDCE_ADDRESS, ); lastBalance = BigInt(raw); } catch (error) { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 8fa4d462a7..9439faf4cc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -14,6 +14,7 @@ import { CHAIN_ID_HYPERCORE, CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, + POLYGON_USDCE_ADDRESS, } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { @@ -3366,9 +3367,7 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(body.originCurrency).toBe( - '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', - ); + expect(body.originCurrency).toBe(POLYGON_USDCE_ADDRESS); expect(body.user).toBe(DEPOSIT_WALLET_MOCK); expect(body.refundTo).toBe(DEPOSIT_WALLET_MOCK); expect(body.useDepositAddress).toBe(true); From 466119a9886040a0c08a2d45df62f9c9a13631f7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 14:21:38 +0100 Subject: [PATCH 32/33] fix(transaction-pay-controller): strip RelayQuoteRequest JSDoc and prettier --- .../src/strategy/relay/polymarket/withdraw.test.ts | 5 ++++- .../src/strategy/relay/types.ts | 10 ---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts index 2900f9b6fd..61c116f4ed 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -1,6 +1,9 @@ import type { Hex } from '@metamask/utils'; -import { POLYGON_PUSD_ADDRESS, POLYGON_USDCE_ADDRESS } from '../../../constants'; +import { + POLYGON_PUSD_ADDRESS, + POLYGON_USDCE_ADDRESS, +} from '../../../constants'; import { getMessengerMock } from '../../../tests/messenger-mock'; import type { QuoteRequest, TransactionPayQuote } from '../../../types'; import { getLiveTokenBalance } from '../../../utils/token'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index b7f50975b5..442097d2e0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -15,7 +15,6 @@ export type RelayQuoteRequest = { originChainId: number; originCurrency: Hex; originGasOverhead?: string; - /** Required for HyperLiquid withdrawals (value: 'v2'). */ protocolVersion?: string; recipient: Hex; refundTo?: Hex; @@ -26,16 +25,7 @@ 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; - /** - * When combined with `useDepositAddress`, ties the deposit address to a - * specific order. Underpayments fail and refund; exact payments and - * overpayments fill. Requires `refundTo`. - */ strict?: boolean; user: Hex; }; From cce1b30f6269fee71c0a9f09029e53b4244334f5 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 13 May 2026 20:38:49 +0100 Subject: [PATCH 33/33] chore(transaction-pay-controller): move Polymarket entry back to Unreleased --- packages/transaction-pay-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0fcfc3d3b5..5495abc37f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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)) + ## [22.4.0] ### Added - Add Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) -- 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