diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ad465f52854..8a7357c0c7f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [18.2.0] - ### Added +- **BREAKING:** Add `KeyringControllerSignTypedMessageAction` to `AllowedActions` for HyperLiquid EIP-712 signing ([#8314](https://github.com/MetaMask/core/pull/8314)) + - Clients must now provide `KeyringController:signTypedMessage` permission when constructing the controller messenger +- Add HyperLiquid withdrawal submission via Relay ([#8314](https://github.com/MetaMask/core/pull/8314)) - Add HyperLiquid source quote support for Relay strategy ([#8285](https://github.com/MetaMask/core/pull/8285)) +## [18.2.0] + ### Changed - Bump `@metamask/assets-controllers` from `^101.0.1` to `^102.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index 3b7e471f0b8..9e1d8d1e6d2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -3,9 +3,11 @@ import { TransactionType } from '@metamask/transaction-controller'; import type { RelayStatus } from './types'; export const RELAY_URL_BASE = 'https://api.relay.link'; +export const RELAY_AUTHORIZE_URL = `${RELAY_URL_BASE}/authorize`; export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`; export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; export const RELAY_STATUS_URL = `${RELAY_URL_BASE}/intents/status/v3`; +export const HYPERLIQUID_EXCHANGE_URL = 'https://api.hyperliquid.xyz/exchange'; export const RELAY_POLLING_INTERVAL = 1000; // 1 Second export const TOKEN_TRANSFER_FOUR_BYTE = '0xa9059cbb'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts new file mode 100644 index 00000000000..560bc8188fd --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts @@ -0,0 +1,361 @@ +import { successfulFetch } from '@metamask/controller-utils'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; + +import { RELAY_AUTHORIZE_URL, HYPERLIQUID_EXCHANGE_URL } from './constants'; +import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; +import type { RelayQuote, RelaySignatureStep } from './types'; +import { getMessengerMock } from '../../tests/messenger-mock'; +import type { TransactionPayQuote } from '../../types'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); + +const FROM_MOCK = '0xabc123' as Hex; + +const SIGNATURE_MOCK = `0x${'aa'.repeat(32)}${'bb'.repeat(32)}1b`; + +const AUTHORIZE_STEP_MOCK: RelaySignatureStep = { + id: 'authorize', + kind: 'signature', + requestId: 'req-1', + items: [ + { + data: { + sign: { + signatureKind: 'eip712', + domain: { + name: 'relay.link', + version: '1', + chainId: 42161, + }, + types: { + Authorize: [ + { name: 'nonce', type: 'uint256' }, + { name: 'account', type: 'address' }, + ], + }, + value: { nonce: 123, account: FROM_MOCK }, + primaryType: 'Authorize', + }, + post: { + endpoint: RELAY_AUTHORIZE_URL, + method: 'POST' as const, + body: { requestId: 'req-1' }, + }, + }, + status: 'incomplete' as const, + }, + ], +}; + +const DEPOSIT_STEP_MOCK = { + id: 'deposit', + kind: 'transaction', + requestId: 'req-1', + items: [ + { + data: { + action: { + type: 'usdSend', + parameters: { + destination: '0xsolver', + amount: '10000000', + hyperliquidChain: 'Mainnet', + }, + }, + nonce: 1234567890000, + eip712Types: { + 'HyperliquidTransaction:UsdSend': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'destination', type: 'string' }, + { name: 'amount', type: 'string' }, + ], + }, + eip712PrimaryType: 'HyperliquidTransaction:UsdSend', + }, + status: 'incomplete', + }, + ], +}; + +function buildQuote( + steps: unknown[] = [AUTHORIZE_STEP_MOCK, DEPOSIT_STEP_MOCK], +): TransactionPayQuote { + return { + original: { steps }, + } as TransactionPayQuote; +} + +describe('submitHyperliquidWithdraw', () => { + const successfulFetchMock = jest.mocked(successfulFetch); + const { messenger } = getMessengerMock(); + + let signTypedMessageMock: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + signTypedMessageMock = jest.fn().mockResolvedValue(SIGNATURE_MOCK); + + messenger.registerActionHandler( + 'KeyringController:signTypedMessage' as never, + signTypedMessageMock, + ); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ status: 'ok' }), + } as Response); + }); + + afterEach(() => { + try { + messenger.unregisterActionHandler( + 'KeyringController:signTypedMessage' as never, + ); + } catch { + // already unregistered + } + }); + + it('throws if authorize step is missing', async () => { + const quote = buildQuote([DEPOSIT_STEP_MOCK]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected authorize and deposit steps'); + }); + + it('throws if deposit step is missing', async () => { + const quote = buildQuote([AUTHORIZE_STEP_MOCK]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected authorize and deposit steps'); + }); + + it('signs authorize EIP-712 message and posts to Relay', async () => { + const quote = buildQuote(); + + await submitHyperliquidWithdraw(quote, FROM_MOCK, messenger); + + expect(signTypedMessageMock).toHaveBeenCalledTimes(2); + + const authorizeCall = signTypedMessageMock.mock.calls[0]; + expect(authorizeCall[0]).toStrictEqual({ + from: FROM_MOCK, + data: expect.any(String), + }); + expect(authorizeCall[1]).toBe(SignTypedDataVersion.V4); + + const typedData = JSON.parse(authorizeCall[0].data); + expect(typedData.domain).toStrictEqual( + AUTHORIZE_STEP_MOCK.items[0].data.sign.domain, + ); + expect(typedData.primaryType).toBe('Authorize'); + expect(typedData.types.EIP712Domain).toStrictEqual([ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ]); + }); + + it('posts authorize signature to Relay /authorize', async () => { + const quote = buildQuote(); + + await submitHyperliquidWithdraw(quote, FROM_MOCK, messenger); + + expect(successfulFetchMock).toHaveBeenCalledWith( + `${RELAY_AUTHORIZE_URL}?signature=${SIGNATURE_MOCK}`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId: 'req-1' }), + }), + ); + }); + + it('signs deposit EIP-712 message with HyperliquidSignTransaction domain', async () => { + const quote = buildQuote(); + + await submitHyperliquidWithdraw(quote, FROM_MOCK, messenger); + + const depositCall = signTypedMessageMock.mock.calls[1]; + const typedData = JSON.parse(depositCall[0].data); + + expect(typedData.domain).toStrictEqual({ + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 42161, + verifyingContract: '0x0000000000000000000000000000000000000000', + }); + expect(typedData.primaryType).toBe('HyperliquidTransaction:UsdSend'); + expect(typedData.message).toStrictEqual({ + destination: '0xsolver', + amount: '10000000', + hyperliquidChain: 'Mainnet', + type: 'usdSend', + signatureChainId: '0xa4b1', + }); + }); + + it('posts deposit to HyperLiquid exchange with parsed r/s/v', async () => { + const quote = buildQuote(); + + await submitHyperliquidWithdraw(quote, FROM_MOCK, messenger); + + const depositFetchCall = successfulFetchMock.mock.calls[1]; + expect(depositFetchCall[0]).toBe(HYPERLIQUID_EXCHANGE_URL); + + const body = JSON.parse(depositFetchCall[1]?.body as string); + expect(body.action).toStrictEqual({ + destination: '0xsolver', + amount: '10000000', + hyperliquidChain: 'Mainnet', + type: 'usdSend', + signatureChainId: '0xa4b1', + }); + expect(body.nonce).toBe(1234567890000); + expect(body.signature.r).toBe(SIGNATURE_MOCK.slice(0, 66)); + expect(body.signature.s).toBe(`0x${SIGNATURE_MOCK.slice(66, 130)}`); + expect(body.signature.v).toBe(0x1b); + }); + + it('throws if HyperLiquid deposit returns non-ok status', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ status: 'ok' }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ status: 'err', response: 'Insufficient balance' }), + } as Response); + + const quote = buildQuote(); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('HyperLiquid deposit failed'); + }); + + it('throws if authorize step has no items', async () => { + const emptyAuthorize = { + ...AUTHORIZE_STEP_MOCK, + items: [], + }; + + const quote = buildQuote([emptyAuthorize, DEPOSIT_STEP_MOCK]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected exactly 1 authorize item, got 0'); + }); + + it('throws if deposit step has no items', async () => { + const emptyDeposit = { + ...DEPOSIT_STEP_MOCK, + items: [], + }; + + const quote = buildQuote([AUTHORIZE_STEP_MOCK, emptyDeposit]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected exactly 1 deposit item, got 0'); + }); + + it('throws if authorize step has a sparse single item', async () => { + const sparseAuthorize = { + ...AUTHORIZE_STEP_MOCK, + items: [undefined], + }; + + const quote = buildQuote([sparseAuthorize, DEPOSIT_STEP_MOCK]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Authorize step has no items'); + }); + + it('throws if deposit step has a sparse single item', async () => { + const sparseDeposit = { + ...DEPOSIT_STEP_MOCK, + items: [undefined], + }; + + const quote = buildQuote([AUTHORIZE_STEP_MOCK, sparseDeposit]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Deposit step has no items'); + }); + + it('throws if authorize step has multiple items', async () => { + const multiAuthorize = { + ...AUTHORIZE_STEP_MOCK, + items: [AUTHORIZE_STEP_MOCK.items[0], AUTHORIZE_STEP_MOCK.items[0]], + }; + + const quote = buildQuote([multiAuthorize, DEPOSIT_STEP_MOCK]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected exactly 1 authorize item, got 2'); + }); + + it('throws if deposit step has multiple items', async () => { + const multiDeposit = { + ...DEPOSIT_STEP_MOCK, + items: [DEPOSIT_STEP_MOCK.items[0], DEPOSIT_STEP_MOCK.items[0]], + }; + + const quote = buildQuote([AUTHORIZE_STEP_MOCK, multiDeposit]); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('Expected exactly 1 deposit item, got 2'); + }); + + it('wraps authorize fetch errors with context', async () => { + successfulFetchMock.mockRejectedValueOnce(new Error('Network timeout')); + + const quote = buildQuote(); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('HyperLiquid authorize failed: Network timeout'); + }); + + it('wraps deposit fetch errors with context', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ status: 'ok' }), + } as Response) + .mockRejectedValueOnce(new Error('Connection refused')); + + const quote = buildQuote(); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('HyperLiquid deposit failed: Connection refused'); + }); + + it('wraps deposit JSON parse errors with context', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ status: 'ok' }), + } as Response) + .mockResolvedValueOnce({ + json: async () => { + throw new Error('Invalid JSON'); + }, + } as Response); + + const quote = buildQuote(); + + await expect( + submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), + ).rejects.toThrow('HyperLiquid deposit failed: Invalid JSON'); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts new file mode 100644 index 00000000000..dd20ced8458 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -0,0 +1,268 @@ +import { successfulFetch } from '@metamask/controller-utils'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { RELAY_AUTHORIZE_URL, HYPERLIQUID_EXCHANGE_URL } from './constants'; +import type { RelayQuote, RelaySignatureStep } from './types'; +import { CHAIN_ID_ARBITRUM } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; + +const log = createModuleLogger(projectLogger, 'hyperliquid-withdraw'); + +type EIP712DomainField = { name: string; type: string }; + +const DOMAIN_FIELD_MAP: Record = { + name: { name: 'name', type: 'string' }, + version: { name: 'version', type: 'string' }, + chainId: { name: 'chainId', type: 'uint256' }, + verifyingContract: { name: 'verifyingContract', type: 'address' }, + salt: { name: 'salt', type: 'bytes32' }, +}; + +/** + * Submit a HyperLiquid 2-step withdrawal via Relay. + * + * Step 1 (authorize): Sign an EIP-712 nonce-mapping message, POST to Relay /authorize. + * Step 2 (deposit): Sign an EIP-712 HyperliquidSignTransaction, POST to HyperLiquid exchange. + * + * Both signatures are silent (no user confirmation). Both steps share the same nonce + * from the Relay quote response. + * + * @param quote - Relay quote containing the 2-step flow. + * @param from - User's account address. + * @param messenger - Controller messenger (for KeyringController:signTypedMessage). + */ +export async function submitHyperliquidWithdraw( + quote: TransactionPayQuote, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + const { steps } = quote.original; + + log('Starting HyperLiquid withdrawal', { + stepCount: steps.length, + stepIds: steps.map((step) => step.id), + }); + + const authorizeStep = steps.find( + (step) => step.kind === 'signature' && step.id === 'authorize', + ) as RelaySignatureStep | undefined; + + const depositStep = steps.find((step) => step.id === 'deposit'); + + if (!authorizeStep || !depositStep) { + throw new Error( + `Expected authorize and deposit steps for HyperLiquid withdrawal, got: ${steps.map((step) => `${step.id}(${step.kind})`).join(', ')}`, + ); + } + + // Step 1: Authorize (nonce-mapping signature -> POST to Relay /authorize) + await executeAuthorizeStep(authorizeStep, from, messenger); + + // Step 2: Deposit (HyperLiquid sendAsset -> POST to HyperLiquid exchange) + await executeDepositStep(depositStep, from, messenger); + + log('HyperLiquid withdrawal submitted successfully'); +} + +/** + * Derive the EIP712Domain type array from a domain object. + * eth-sig-util defaults to EIP712Domain: [] when absent, breaking + * the domain separator hash. This ensures it matches ethers.js behavior. + * + * @param domain - The EIP-712 domain object. + * @returns The EIP712Domain type array in canonical order. + */ +function deriveEIP712DomainType( + domain: Record, +): EIP712DomainField[] { + return Object.keys(DOMAIN_FIELD_MAP) + .filter((key) => key in domain) + .map((key) => DOMAIN_FIELD_MAP[key]); +} + +/** + * Execute the authorize step: sign EIP-712 nonce-mapping and POST to Relay. + * + * @param step - The authorize signature step from the Relay quote. + * @param from - User's account address. + * @param messenger - Controller messenger for signing. + */ +async function executeAuthorizeStep( + step: RelaySignatureStep, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + if (step.items.length !== 1) { + throw new Error( + `Expected exactly 1 authorize item, got ${step.items.length}`, + ); + } + + const item = step.items[0]; + if (!item) { + throw new Error('Authorize step has no items'); + } + + const { sign, post } = item.data; + + const typedData = { + domain: sign.domain, + types: { + ...sign.types, + EIP712Domain: deriveEIP712DomainType(sign.domain), + }, + primaryType: sign.primaryType, + message: sign.value, + }; + + log('Signing authorize (nonce-mapping)', { domain: sign.domain }); + + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { + from, + data: JSON.stringify(typedData), + }, + SignTypedDataVersion.V4, + ); + + log('Posting authorize signature to Relay'); + + const authorizeUrl = `${RELAY_AUTHORIZE_URL}?signature=${signature}`; + + try { + const response = await successfulFetch(authorizeUrl, { + method: post.method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(post.body), + }); + + const result = await response.json(); + + log('Authorize response', result); + } catch (error) { + throw new Error( + `HyperLiquid authorize failed: ${(error as Error).message}`, + ); + } +} + +/** + * Execute the deposit step: sign HyperLiquid sendAsset and POST to HL exchange. + * + * The signature data must be constructed from the step's eip712Types and action + * parameters, following the Relay HyperLiquid integration spec. + * + * @param step - The deposit step from the Relay quote. + * @param from - User's account address. + * @param messenger - Controller messenger for signing. + */ +async function executeDepositStep( + step: RelaySignatureStep | { id: string; kind: string; items: unknown[] }, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + const items = step.items as { data: Record }[]; + + if (items.length !== 1) { + throw new Error(`Expected exactly 1 deposit item, got ${items.length}`); + } + + const item = items[0]; + if (!item) { + throw new Error('Deposit step has no items'); + } + + const { data } = item; + + const action = data.action as { + type: string; + parameters: Record; + }; + const nonce = data.nonce as number; + const eip712Types = data.eip712Types as Record; + const eip712PrimaryType = data.eip712PrimaryType as string; + + // HyperLiquid's EIP-712 signing spec requires Arbitrum's chain ID in the + // domain and message. This does not affect which chain the withdrawal + // targets — the destination chain is determined by the Relay quote. + const chainId = Number(CHAIN_ID_ARBITRUM); + + const domain = { + name: 'HyperliquidSignTransaction', + version: '1', + chainId, + verifyingContract: '0x0000000000000000000000000000000000000000', + }; + + const signatureData = { + domain, + types: { + ...eip712Types, + EIP712Domain: deriveEIP712DomainType(domain), + }, + primaryType: eip712PrimaryType, + message: { + ...action.parameters, + type: action.type, + signatureChainId: `0x${chainId.toString(16)}`, + }, + }; + + log('Signing HyperLiquid deposit (sendAsset)', { + nonce, + action: action.type, + }); + + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { + from, + data: JSON.stringify(signatureData), + }, + SignTypedDataVersion.V4, + ); + + // eslint-disable-next-line id-length + const r = signature.slice(0, 66); + // eslint-disable-next-line id-length + const s = `0x${signature.slice(66, 130)}`; + // eslint-disable-next-line id-length + const v = parseInt(signature.slice(130, 132), 16); + + log('Posting deposit to HyperLiquid exchange'); + + let result: unknown; + + try { + const response = await successfulFetch(HYPERLIQUID_EXCHANGE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: { + ...action.parameters, + type: action.type, + signatureChainId: `0x${chainId.toString(16)}`, + }, + nonce, + signature: { r, s, v }, + }), + }); + + result = await response.json(); + } catch (error) { + throw new Error(`HyperLiquid deposit failed: ${(error as Error).message}`); + } + + if ((result as { status?: string })?.status !== 'ok') { + throw new Error(`HyperLiquid deposit failed: ${JSON.stringify(result)}`); + } + + log('HyperLiquid deposit response', result); +} 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 1f2619b83c4..0e38d5bcea3 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 @@ -30,6 +30,7 @@ import { jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); +jest.mock('./hyperliquid-withdraw'); jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -124,6 +125,7 @@ const REQUEST_MOCK: PayStrategyExecuteRequest = { isSmartTransaction: () => false, transaction: { id: ORIGINAL_TRANSACTION_ID_MOCK, + txParams: { from: FROM_MOCK }, } as TransactionMeta, }; @@ -135,7 +137,6 @@ describe('Relay Submit Utils', () => { const getFeatureFlagsMock = jest.mocked(getFeatureFlags); const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); const normalizeTokenAddressMock = jest.mocked(normalizeTokenAddress); - const getRelayPollingIntervalMock = jest.mocked(getRelayPollingInterval); const getRelayPollingTimeoutMock = jest.mocked(getRelayPollingTimeout); @@ -1109,6 +1110,57 @@ describe('Relay Submit Utils', () => { ); }); + describe('HyperLiquid source', () => { + it('calls submitHyperliquidWithdraw instead of submitTransactions', async () => { + const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( + './hyperliquid-withdraw', + ); + + request.quotes[0].request.isHyperliquidSource = true; + request.quotes[0].original.steps[0].kind = 'transaction'; + + await submitRelayQuotes(request); + + expect(hlWithdrawMock).toHaveBeenCalledTimes(1); + expect(hlWithdrawMock).toHaveBeenCalledWith( + request.quotes[0], + FROM_MOCK, + messenger, + ); + expect(addTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + }); + + it('still polls relay status after HyperLiquid withdraw', async () => { + request.quotes[0].request.isHyperliquidSource = true; + + successfulFetchMock.mockResolvedValue({ + json: async () => STATUS_RESPONSE_MOCK, + } as Response); + + const result = await submitRelayQuotes(request); + + expect(result.transactionHash).toBe(TRANSACTION_HASH_MOCK); + expect(successfulFetchMock).toHaveBeenCalledWith( + `${RELAY_STATUS_URL}?requestId=${REQUEST_ID_MOCK}`, + { method: 'GET' }, + ); + }); + + it('does not call submitHyperliquidWithdraw for non-HL source', async () => { + const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( + './hyperliquid-withdraw', + ); + + request.quotes[0].request.isHyperliquidSource = false; + + await submitRelayQuotes(request); + + expect(hlWithdrawMock).not.toHaveBeenCalled(); + expect(addTransactionMock).toHaveBeenCalled(); + }); + }); + describe('EIP-7702 execute path', () => { const DELEGATION_MANAGER_MOCK = '0xdelegationManager' as Hex; const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 9ae6846e53c..001973d3ef0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -14,6 +14,7 @@ import { RELAY_FAILURE_STATUSES, RELAY_PENDING_STATUSES, } from './constants'; +import { submitHyperliquidWithdraw } from './hyperliquid-withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import type { RelayExecuteRequest, @@ -100,7 +101,12 @@ async function executeSingleQuote( }, ); - await submitTransactions(quote, transaction, messenger); + if (quote.request.isHyperliquidSource) { + const from = transaction.txParams.from as Hex; + await submitHyperliquidWithdraw(quote, from, messenger); + } else { + await submitTransactions(quote, transaction, messenger); + } const targetHash = await waitForRelayCompletion( quote.original, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 723150c1da2..320f07327dc 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -12,6 +12,7 @@ import type { BridgeControllerActions } from '@metamask/bridge-controller'; import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; import type { GasFeeControllerActions } from '@metamask/gas-fee-controller'; +import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; @@ -46,6 +47,7 @@ export type AllowedActions = | BridgeStatusControllerActions | CurrencyRateControllerActions | GasFeeControllerActions + | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | RampsControllerGetQuotesAction