diff --git a/package.json b/package.json index 29a8b79c2a..ad2f9197d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "982.0.0", + "version": "983.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index 7156bcbf90..011cbcfac8 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -1,8 +1,8 @@ { - "lastSyncedMobileCommit": "106369c46bd01ee87faeaf51530fe67ad03ca178", - "lastSyncedMobileBranch": "fix/perps/flip-position-tat-2123", - "lastSyncedCoreCommit": "db4cc9ec8ff8530d8e9e0e3737b358e3269ce1b2", - "lastSyncedCoreBranch": "feat/perps/controller-sync-may-5", - "lastSyncedDate": "2026-05-05T20:50:44Z", - "sourceChecksum": "894eb0960741e569f1f025dbaaff6e1ffcab2489a80ad71e74cdf43af98aa764" + "lastSyncedMobileCommit": "35953448cf3c32b2867de8fe0599a356925913ef", + "lastSyncedMobileBranch": "main", + "lastSyncedCoreCommit": "3e549deb97d362c6798a0062dd8b01ac481615c4", + "lastSyncedCoreBranch": "main", + "lastSyncedDate": "2026-05-13T21:35:30Z", + "sourceChecksum": "79a9acc7ad058802b357c6f54774799229b44e9418e802f2f7958e345e16cf59" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 400ad2bcfa..38b5a6b800 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,11 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.1.0] + ### Changed +- Pass the perps builder base fee into rewards discount resolution and treat unhydrated rewards subscription state as retryable instead of a definitive no-discount result ([#8803](https://github.com/MetaMask/core/pull/8803)) - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) - Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) +### Fixed + +- Defer signing-backed HyperLiquid unified-account setup for hardware wallets across migratable abstraction modes, including Ledger, Trezor, OneKey, Lattice, and QR keyrings, to avoid repeated signing prompts while browsing ([#8803](https://github.com/MetaMask/core/pull/8803)) +- Improve logging and retry classification for failed cancel/close/TP-SL operations and SDK-wrapped keyring-locked errors ([#8803](https://github.com/MetaMask/core/pull/8803)) + ## [6.0.1] ### Changed @@ -290,7 +298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.1.0...HEAD +[6.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.1...@metamask/perps-controller@6.1.0 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.0...@metamask/perps-controller@6.0.1 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...@metamask/perps-controller@6.0.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@4.0.0...@metamask/perps-controller@5.0.0 diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 82fa67c4db..099b468bf4 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/perps-controller", - "version": "6.0.1", + "version": "6.1.0", "description": "Controller for perpetual trading functionality in MetaMask", "keywords": [ "Ethereum", diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 9ef2058fc8..7019c270aa 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -128,7 +128,8 @@ import { addSpotBalanceToAccountState, aggregateAccountStates, } from '../utils/accountUtils'; -import { ensureError } from '../utils/errorUtils'; +import { ensureError, isKeyringLockedError } from '../utils/errorUtils'; +import { shouldDeferUnifiedAccountSetup } from '../utils/hyperLiquidAbstraction'; import { adaptAccountStateFromSDK, adaptHyperLiquidLedgerUpdateToUserHistoryItem, @@ -627,7 +628,8 @@ export class HyperLiquidProvider implements PerpsProvider { const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; // Check global cache first to avoid repeated signing requests - // This is CRITICAL for hardware wallets to prevent QR popup spam + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. const cachedStatus = TradingReadinessCache.get(network, userAddress); if (cachedStatus?.attempted) { this.#deps.debugLogger.log( @@ -726,14 +728,14 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - // Defer the user-signed transition until the user attempts an action. + // Defer signing-backed transitions until the user attempts an action. // Cache is intentionally left untouched so the next entry re-evaluates; // the read-only userAbstraction call is cheap and gated by the in-flight // lock, preventing concurrent prompts. - if (currentMode === 'dexAbstraction' && !allowUserSigning) { + if (shouldDeferUnifiedAccountSetup(currentMode, allowUserSigning)) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: Deferring dexAbstraction → unifiedAccount migration to action time', - { user: userAddress, network }, + 'HyperLiquidProvider: Deferring unified account migration to action time', + { user: userAddress, network, mode: currentMode }, ); completeInFlight(); return; @@ -814,8 +816,9 @@ export class HyperLiquidProvider implements PerpsProvider { ); completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', ); @@ -927,10 +930,10 @@ export class HyperLiquidProvider implements PerpsProvider { } // Attempt Unified Account migration as early as possible so users aren't - // blocked when they try to trade. Software-wallet dexAbstraction users can - // complete the one-time EIP-712 migration during initial setup so the first - // trade sees the unified balance. Hardware wallets remain deferred to - // action time to avoid QR / Ledger prompt spam while browsing. + // blocked when they try to trade. Software wallets can complete the + // signing-backed migration during initial setup so the first trade sees + // the unified balance. Hardware wallets remain deferred to action time to + // avoid repeated signing prompts while browsing. await this.#ensureUnifiedAccountEnabled({ allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), }); @@ -963,7 +966,7 @@ export class HyperLiquidProvider implements PerpsProvider { * - Builder fee approval (required for orders) * - Referral code setup (attribution) * - * These operations are DEFERRED from ensureReady() to avoid QR popup spam + * These operations are DEFERRED from ensureReady() to avoid hardware wallet prompt spam * when users are just viewing the Perps section (critical for hardware wallets). * * Call this method before any trading operation (placeOrder, cancelOrder, etc.) @@ -2542,11 +2545,12 @@ export class HyperLiquidProvider implements PerpsProvider { const cacheKey = this.#getCacheKey(network, userAddress); // Check GLOBAL cache first to avoid repeated signing requests across reconnections - // This is CRITICAL for hardware wallets to prevent QR popup spam + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. const globalCached = PerpsSigningCache.getBuilderFee(network, userAddress); if (globalCached?.attempted) { this.#deps.debugLogger.log( - '[ensureBuilderFeeApproval] Using global cache (prevents QR popup spam)', + '[ensureBuilderFeeApproval] Using global cache (prevents hardware wallet prompt spam)', { network, success: globalCached.success }, ); if (globalCached.success) { @@ -2650,8 +2654,9 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Keyring locked, will retry later', ); @@ -8374,7 +8379,7 @@ export class HyperLiquidProvider implements PerpsProvider { const globalCached = PerpsSigningCache.getReferral(network, userAddress); if (globalCached?.attempted) { this.#deps.debugLogger.log( - '[ensureReferralSet] Using global cache (prevents QR popup spam)', + '[ensureReferralSet] Using global cache (prevents hardware wallet prompt spam)', { network, success: globalCached.success }, ); return; @@ -8465,8 +8470,9 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { - // If keyring is locked, don't cache so it retries when unlocked - if (ensureError(error).message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureReferralSet] Keyring locked, will retry later', ); diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 144f53af32..1f8dbcbbf8 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -18,6 +18,9 @@ import { findEvmAccount, getSelectedEvmAccount } from '../utils/accountUtils'; // service portable between mobile and the core monorepo. const HARDWARE_KEYRING_TYPES = new Set([ 'Ledger Hardware', + 'Trezor Hardware', + 'OneKey Hardware', + 'Lattice Hardware', 'QR Hardware Wallet Device', ]); @@ -55,7 +58,7 @@ export class HyperLiquidWalletService { /** * Check whether the selected EVM account is backed by hardware. * - * @returns True for Ledger / QR hardware keyrings; false for software accounts. + * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { const selectedEvmAccount = findEvmAccount( diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index ffd8eadbab..a74e231f58 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -1,3 +1,7 @@ +import { + BASIS_POINTS_DIVISOR, + BUILDER_FEE_CONFIG, +} from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { PerpsControllerMessengerBase } from '../types/messenger'; @@ -118,9 +122,23 @@ export class RewardsIntegrationService { return undefined; } - // Use rewards via DI (no RewardsController in Core yet) - const discountBips = - await this.#deps.rewards.getPerpsDiscountForAccount(caipAccountId); + // Use rewards via DI (no RewardsController in Core yet). + // The rewards controller needs the perps MetaMask builder base fee in + // bips to convert an absolute VIP fee into a discount fraction. + const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( + caipAccountId, + BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR, + ); + + // null = subscription state not hydrated yet; surface as undefined so + // callers don't treat it as a definitive "no discount" answer. + if (discountBips === null) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', + { address: evmAccount.address, caipAccountId }, + ); + return undefined; + } this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount calculated', diff --git a/packages/perps-controller/src/services/TradingReadinessCache.ts b/packages/perps-controller/src/services/TradingReadinessCache.ts index 082c7e27b0..2f2b79d98f 100644 --- a/packages/perps-controller/src/services/TradingReadinessCache.ts +++ b/packages/perps-controller/src/services/TradingReadinessCache.ts @@ -2,7 +2,8 @@ * Global singleton cache for Perps signing operations * * This cache persists across provider reconnections to prevent repeated - * signing requests for hardware wallets. Critical for preventing QR popup spam. + * signing requests for hardware wallets. Critical for preventing repeated + * hardware wallet signing prompts. * * Cache is intentionally kept separate from provider instances because providers * are recreated on account/network changes, which would reset instance-level caches. diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 18b2356ef0..4f9191691c 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1123,6 +1123,15 @@ export class TradingService { }, ); + this.#deps.logger.error( + ensureError(result.error, 'TradingService.cancelOrder'), + this.#getErrorContext('cancelOrder', { + symbol: params.symbol, + orderId: params.orderId, + providerError: result.error ?? 'Unknown error', + }), + ); + traceData = { success: false, error: result.error ?? 'Unknown error' }; } @@ -1298,6 +1307,31 @@ export class TradingService { }; }, ['orders']); // Disconnect orders stream during operation + if ( + provider.cancelOrders && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => + `${result.symbol}/${result.orderId}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('cancelOrders', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + cancelAll: params.cancelAll, + }), + ); + } + return operationResult; } catch (error) { operationError = @@ -1416,6 +1450,14 @@ export class TradingService { this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } else { traceData = { success: false, error: result.error ?? 'Unknown error' }; + + this.#deps.logger.error( + ensureError(result.error, 'TradingService.closePosition'), + this.#getErrorContext('closePosition', { + symbol: params.symbol, + providerError: result.error ?? 'Unknown error', + }), + ); } // Track analytics (success or failure, includes partial fills) @@ -1602,6 +1644,31 @@ export class TradingService { }; } + if ( + provider.closePositions && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => `${result.symbol}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('closePositions', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + symbols: params.symbols?.length ?? 0, + closeAll: params.closeAll, + }), + ); + } + return operationResult; } catch (error) { operationError = @@ -1722,6 +1789,16 @@ export class TradingService { } catch (error) { errorMessage = error instanceof Error ? error.message : 'Unknown error'; traceData = { success: false, error: errorMessage }; + + this.#deps.logger.error( + ensureError(error, 'TradingService.updatePositionTPSL'), + this.#getErrorContext('updatePositionTPSL', { + symbol: params.symbol, + hasTakeProfit: Boolean(params.takeProfitPrice), + hasStopLoss: Boolean(params.stopLossPrice), + }), + ); + throw error; } finally { const completionDuration = this.#deps.performance.now() - startTime; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7be2c978b6..95ac7081f6 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1589,11 +1589,17 @@ export type PerpsPlatformDependencies = { rewards: { /** * Get fee discount for an account from the RewardsController. - * Returns discount in basis points (e.g., 6500 = 65% discount) + * Returns discount in basis points (e.g., 6500 = 65% discount), or null + * when subscription state hasn't hydrated yet — callers should skip + * caching null results and retry on the next fee calculation. + * + * Pass the perps MetaMask builder base fee in bips so the rewards + * controller can convert an absolute VIP fee into a discount fraction. */ getPerpsDiscountForAccount( caipAccountId: `${string}:${string}:${string}`, - ): Promise; + baseFeeBips: number, + ): Promise; }; }; diff --git a/packages/perps-controller/src/utils/errorUtils.ts b/packages/perps-controller/src/utils/errorUtils.ts index 2e0725361f..51bf49a744 100644 --- a/packages/perps-controller/src/utils/errorUtils.ts +++ b/packages/perps-controller/src/utils/errorUtils.ts @@ -1,9 +1,11 @@ /** - * Utility functions for error handling across the application. - * These are general-purpose utilities, not domain-specific. + * Utility functions for error handling across Perps controller code. + * Includes generic error helpers and Perps error classification helpers. */ import { hasProperty } from '@metamask/utils'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; + /** * Detects expected cancellation/abort errors that should not be reported to Sentry. * These occur during normal navigation or view teardown when in-flight fetch requests @@ -23,6 +25,30 @@ export function isAbortError(error: unknown): boolean { return false; } +/** + * Detects keyring-locked errors, including SDK-wrapped errors that preserve the + * original error in `cause`. + * + * @param error - The error to check. + * @returns True if any error in the cause chain is KEYRING_LOCKED. + */ +export function isKeyringLockedError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + + if (current.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + return true; + } + + current = (current as { cause?: unknown }).cause; + } + + return false; +} + /** * Ensures we have a proper Error object for logging. * Converts unknown/string errors to proper Error instances. diff --git a/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts new file mode 100644 index 0000000000..7f45768b26 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts @@ -0,0 +1,26 @@ +import type { HyperLiquidAbstractionMode } from '../types/hyperliquid-types'; + +const MIGRATABLE_ABSTRACTION_MODES = new Set([ + 'dexAbstraction', + 'default', + 'disabled', +]); + +/** + * Determine whether unified-account setup should be deferred until a user + * explicitly starts a trading or withdrawal action. + * + * @param currentMode - The user's current HyperLiquid abstraction mode. + * @param allowUserSigning - Whether the caller is allowed to trigger wallet signing. + * @returns True when migration would require a signing-backed mutation that should be deferred. + */ +export function shouldDeferUnifiedAccountSetup( + currentMode: HyperLiquidAbstractionMode | undefined, + allowUserSigning: boolean, +): boolean { + return ( + !allowUserSigning && + currentMode !== undefined && + MIGRATABLE_ABSTRACTION_MODES.has(currentMode) + ); +} 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(); }