diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ca0159a6cc..d8565517fa 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,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 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)) ### Fixed 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..8afd1b0559 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 args - 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 args - 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.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 04d209a576..392ef1064d 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -414,6 +414,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 69ba398283..30b30fcf53 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, @@ -130,6 +137,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 +150,8 @@ export class TransactionPayController extends BaseController< transactionData.isMaxAmount = config.isMaxAmount; transactionData.isPostQuote = config.isPostQuote; transactionData.isHyperliquidSource = config.isHyperliquidSource; + transactionData.isPolymarketDepositWallet = + config.isPolymarketDepositWallet; transactionData.refundTo = config.refundTo; if (config.accountOverride !== previousAccountOverride) { @@ -216,6 +226,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 args - 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 args - 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]; @@ -325,6 +370,8 @@ export class TransactionPayController extends BaseController< #getStrategiesWithFallback( transaction: TransactionMeta, ): TransactionPayStrategy[] { + const transactionData = this.state.transactionData[transaction.id]; + const strategyCandidates: unknown[] = this.#getStrategies?.(transaction) ?? (this.#getStrategy ? [this.#getStrategy(transaction)] : []); @@ -338,7 +385,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/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, diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts new file mode 100644 index 0000000000..d4da07a0ab --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/calldata.ts @@ -0,0 +1,42 @@ +import { Interface } from '@ethersproject/abi'; +import type { Hex } from '@metamask/utils'; + +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 iface.encodeFunctionData('approve', [spender, amount]) as Hex; +} + +export function encodeUnwrap({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + return iface.encodeFunctionData('unwrap', [asset, recipient, amount]) as Hex; +} + +export function encodeWrap({ + asset, + recipient, + amount, +}: { + asset: Hex; + recipient: Hex; + amount: bigint; +}): Hex { + return iface.encodeFunctionData('wrap', [asset, recipient, amount]) as Hex; +} + +export function extractErc20TransferRecipient(data: Hex): Hex { + const [recipient] = iface.decodeFunctionData('transfer', data); + return recipient as Hex; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts new file mode 100644 index 0000000000..da79fd738c --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/constants.ts @@ -0,0 +1,13 @@ +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; + +export const POLYMARKET_COLLATERAL_ONRAMP_POLYGON = + '0x93070a847efEf7F70739046A929D47a521F5B8ee' 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 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 new file mode 100644 index 0000000000..129107302f --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -0,0 +1,201 @@ +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 { getLiveTokenBalance } from '../../../utils/token'; +import type { + RelayQuote, + RelayQuoteRequest, + RelayTransactionStep, +} from '../types'; +import { + encodeApprove, + encodeUnwrap, + encodeWrap, + extractErc20TransferRecipient, +} from './calldata'; +import { + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + PUSD_ADDRESS_POLYGON, + USDC_E_ADDRESS_POLYGON, +} from './constants'; + +const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); + +export async function applyPolymarketDepositWalletOverrides( + body: RelayQuoteRequest, + request: QuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const depositWalletAddress = await getDepositWalletAddress( + messenger, + 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, + messenger: TransactionPayControllerMessenger, +): Promise<{ sourceHash: Hex }> { + const depositWalletAddress = await getDepositWalletAddress(messenger, from); + const relayDepositAddress = extractRelayDepositAddress(quote.original); + const amount = BigInt(quote.sourceAmount.raw); + + log('Submitting unwrap batch to Relay deposit address', { + depositWalletAddress, + relayDepositAddress, + amount: amount.toString(), + }); + + 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 getDepositWalletAddress(messenger, from); + + 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 { + 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'); + + if (depositStep?.kind !== 'transaction') { + throw new Error( + 'Polymarket deposit wallet withdraw: Relay quote has no deposit step', + ); + } + + const transactionStep = depositStep as RelayTransactionStep; + const depositCallData = transactionStep.items[0]?.data?.data; + + if (!depositCallData) { + throw new Error( + 'Polymarket deposit wallet withdraw: Relay quote deposit step is missing calldata', + ); + } + + return extractErc20TransferRecipient(depositCallData); +} 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-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 7d639eea2d..c61d326115 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 { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { @@ -251,9 +252,15 @@ async function getSingleQuote( user: from, }; + if (request.isPolymarketDepositWallet) { + await applyPolymarketDepositWalletOverrides(body, request, messenger); + } + // 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.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 53165cb4af..2dcdcc3b96 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -37,10 +37,15 @@ import { RELAY_PENDING_STATUSES, } from './constants'; import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; +import { + sweepPolymarketDepositWallet, + submitPolymarketWithdraw, +} from './polymarket/withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, RelayQuote, + RelayStatus, RelayStatusResponse, RelayTransactionStep, } from './types'; @@ -90,6 +95,8 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); + const isPolymarket = Boolean(quote.request.isPolymarketDepositWallet); + updateTransaction( { transactionId: transaction.id, @@ -103,31 +110,34 @@ async function executeSingleQuote( if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + } else if (isPolymarket) { + const { sourceHash } = await submitPolymarketWithdraw( + quote, + quote.request.from, + messenger, + ); + setRelaySourceHash(transaction, messenger, sourceHash); } else { await submitTransactions(quote, transaction, messenger); } - const targetHash = await waitForRelayCompletion( - quote.original, - messenger, - (sourceHash) => { - log('Source hash received', sourceHash); - - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Add source hash from Relay status', - }, - (tx) => { - tx.metamaskPay ??= {}; - tx.metamaskPay.sourceHash = sourceHash; - }, - ); + const completion = await waitForRelayCompletion(quote.original, messenger, { + onSourceHash: (hash) => { + log('Source hash received', hash); + setRelaySourceHash(transaction, messenger, hash); }, - ); + tolerateFailure: isPolymarket, + }); + + log('Relay request completed', completion); - log('Relay request completed', targetHash); + if (isPolymarket) { + await sweepPolymarketDepositWallet(quote.request.from, messenger); + + if (completion.status !== 'success') { + throw new Error(`Relay request failed with status: ${completion.status}`); + } + } updateTransaction( { @@ -140,14 +150,42 @@ async function executeSingleQuote( }, ); - return { transactionHash: targetHash }; + return { transactionHash: completion.targetHash }; +} + +function setRelaySourceHash( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, + sourceHash: Hex, +): void { + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Add source hash from Relay status', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; + }, + ); } +type RelayCompletionOutcome = { + status: RelayStatus | 'timeout'; + targetHash?: Hex; +}; + 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; @@ -157,7 +195,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]; @@ -170,7 +208,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; @@ -193,20 +231,33 @@ async function waitForRelayCompletion( if (status.status === 'success') { const targetHash = (status.txHashes?.slice(-1)[0] as Hex) ?? FALLBACK_HASH; - return targetHash; - } - - if (RELAY_FAILURE_STATUSES.includes(status.status)) { - throw new Error(`Relay request failed with status: ${status.status}`); + return { status: 'success', targetHash }; } - if (!RELAY_PENDING_STATUSES.includes(status.status)) { + // 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 returned unrecognized status: ${status.status}`); } } if (hasTimeout && Date.now() - startTime >= pollingTimeout) { const statusDetail = lastStatus ? ` (last status: ${lastStatus})` : ''; + if (tolerateFailure) { + log('Relay polling timed out (tolerated)', statusDetail); + return { status: 'timeout' }; + } throw new Error(`Relay polling timed out${statusDetail}`); } 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; }; 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/types.ts b/packages/transaction-pay-controller/src/types.ts index f1e26a291f..8844400512 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -112,6 +112,9 @@ export type TransactionConfig = { */ isHyperliquidSource?: boolean; + /** Whether the source of funds is a Polymarket deposit wallet. */ + isPolymarketDepositWallet?: boolean; + /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; @@ -190,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; }; @@ -223,6 +229,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 +411,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 @@ -670,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/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..47fe97f73c 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; @@ -83,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( @@ -90,6 +92,7 @@ function calculatePostQuoteSourceAmounts( paymentToken: TransactionPaymentToken, isMaxAmount: boolean, isHyperliquidSource?: boolean, + isPolymarketDepositWallet?: boolean, ): TransactionPaySourceAmount[] { return tokens .filter((token) => { @@ -103,11 +106,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; }