diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 699fcd6d41..5495abc37f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add 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 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 f86daa2642..76d3ecd6d6 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 missing'); + }); + + 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 missing'); + }); + }); + 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 f9bd0e53ae..70b4b3f8d6 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 ( @@ -219,6 +229,39 @@ 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 missing'); + } + return this.#polymarket; + } + #removeTransactionData(transactionId: string): void { this.update((state) => { delete state.transactionData[transactionId]; @@ -328,6 +371,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)] : []); @@ -341,7 +386,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..9949ab3be9 --- /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 POLYMARKET_COLLATERAL_OFFRAMP_POLYGON = + '0x2957922Eb93258b93368531d39fAcCA3B4dC5854' as Hex; + +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 new file mode 100644 index 0000000000..61c116f4ed --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -0,0 +1,280 @@ +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'; +import type { RelayQuote, RelayQuoteRequest } from '../types'; +import { + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_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: POLYGON_USDCE_ADDRESS, + user: DEPOSIT_WALLET_MOCK, + refundTo: DEPOSIT_WALLET_MOCK, + useDepositAddress: true, + strict: 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, + preSubmitUsdceBalance: 0n, + }); + 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(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'); + }); + + 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); + + 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', () => { + 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, successOptions); + + 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(POLYGON_USDCE_ADDRESS); + 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, successOptions); + + 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, successOptions), + ).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, 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 new file mode 100644 index 0000000000..30a14183a4 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -0,0 +1,288 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { + CHAIN_ID_POLYGON, + POLYGON_PUSD_ADDRESS, + POLYGON_USDCE_ADDRESS, +} from '../../../constants'; +import { projectLogger } from '../../../logger'; +import type { + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../../types'; +import { getLiveTokenBalance } from '../../../utils/token'; +import type { + RelayQuote, + RelayQuoteRequest, + RelayStatus, + RelayTransactionStep, +} from '../types'; +import { + encodeApprove, + encodeUnwrap, + encodeWrap, + extractErc20TransferRecipient, +} from './calldata'; +import { + POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + SWEEP_BALANCE_RETRY_ATTEMPTS, + SWEEP_BALANCE_RETRY_DELAY_MS, + SWEEP_RELAYER_SETTLE_DELAY_MS, +} 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 = POLYGON_USDCE_ADDRESS; + 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; 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(), + }); + + const result = await submitDepositWalletBatch(messenger, { + eoa: from, + depositWallet: depositWalletAddress, + calls: [ + { + target: POLYGON_PUSD_ADDRESS, + value: '0', + data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: '0', + data: encodeUnwrap({ + asset: POLYGON_USDCE_ADDRESS, + recipient: relayDepositAddress, + amount, + }), + }, + ], + }); + + 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, + ); + + if (usdceBalance === undefined) { + return; + } + + 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, + depositWallet: depositWalletAddress, + calls: [ + { + target: POLYGON_USDCE_ADDRESS, + value: '0', + data: encodeApprove( + POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + usdceBalance, + ), + }, + { + target: POLYMARKET_COLLATERAL_ONRAMP_POLYGON, + value: '0', + data: encodeWrap({ + asset: POLYGON_USDCE_ADDRESS, + recipient: depositWalletAddress, + amount: usdceBalance, + }), + }, + ], + }); + } catch (error) { + log('USDC.e sweep: batch submission failed', { error }); + } +} + +async function readUsdceBalanceOrZero( + messenger: TransactionPayControllerMessenger, + depositWalletAddress: Hex, +): Promise { + try { + const raw = await getLiveTokenBalance( + messenger, + depositWalletAddress, + CHAIN_ID_POLYGON, + POLYGON_USDCE_ADDRESS, + ); + 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, + POLYGON_USDCE_ADDRESS, + ); + 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, +): 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..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 { @@ -183,6 +184,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock, getKeyringControllerStateMock, getRemoteFeatureFlagControllerStateMock, + polymarketGetDepositWalletAddressMock, } = getMessengerMock(); beforeEach(() => { @@ -3336,6 +3338,43 @@ 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(POLYGON_USDCE_ADDRESS); + expect(body.user).toBe(DEPOSIT_WALLET_MOCK); + expect(body.refundTo).toBe(DEPOSIT_WALLET_MOCK); + expect(body.useDepositAddress).toBe(true); + expect(body.strict).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..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 @@ -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'; @@ -658,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({ @@ -1332,6 +1333,122 @@ 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, + preSubmitUsdceBalance: 0n, + }); + 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 with the success status on success', async () => { + const { sweepPolymarketDepositWallet } = getPolymarketMocks(); + + await submitRelayQuotes(request); + + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'success', preSubmitUsdceBalance: 0n }, + ); + }); + + 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: 'refund' }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay request failed with status: refund', + ); + expect(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'refund', preSubmitUsdceBalance: 1000000n }, + ); + }); + + 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(sweepPolymarketDepositWallet).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'refunded', preSubmitUsdceBalance: 2500000n }, + ); + }); + + 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).toHaveBeenCalledWith( + FROM_MOCK, + messenger, + { relayStatus: 'timeout', preSubmitUsdceBalance: 0n }, + ); + }); + }); + 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..372a016ab5 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, @@ -101,33 +108,39 @@ async function executeSingleQuote( }, ); + let polymarketPreSubmitUsdceBalance = 0n; + if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); + } else if (isPolymarket) { + const { sourceHash, preSubmitUsdceBalance } = + await submitPolymarketWithdraw(quote, quote.request.from, messenger); + polymarketPreSubmitUsdceBalance = preSubmitUsdceBalance; + 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', targetHash); + log('Relay request completed', completion); + + if (isPolymarket) { + 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}`); + } + } updateTransaction( { @@ -140,14 +153,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 +198,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 +211,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 +234,27 @@ 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)) { + 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..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,6 +25,8 @@ export type RelayQuoteRequest = { data: Hex; value: Hex; }[]; + useDepositAddress?: boolean; + strict?: 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; } 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..493b268e1a 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -332,13 +332,16 @@ 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); + 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(); }