From 2df7f208ef543c1a88a9488a815405b010ea95e2 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:17:43 +0100 Subject: [PATCH 1/5] feat(transaction-pay-controller): add HyperLiquid withdrawal submission via Relay --- .../src/strategy/relay/constants.ts | 2 + .../relay/hyperliquid-withdraw.test.ts | 271 ++++++++++++++++++ .../strategy/relay/hyperliquid-withdraw.ts | 226 +++++++++++++++ .../src/strategy/relay/relay-submit.test.ts | 56 +++- .../src/strategy/relay/relay-submit.ts | 8 +- .../transaction-pay-controller/src/types.ts | 4 +- 6 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts 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..7f08c86224e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts @@ -0,0 +1,271 @@ +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('Authorize step has no items'); + }); + + 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('Deposit step has no items'); + }); +}); 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..2b64d986dde --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -0,0 +1,226 @@ +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' }, +}; + +/** + * 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. + */ +function deriveEIP712DomainType( + domain: Record, +): EIP712DomainField[] { + return Object.keys(domain) + .filter((key) => key in DOMAIN_FIELD_MAP) + .map((key) => DOMAIN_FIELD_MAP[key]); +} + +/** + * Execute the 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((s) => s.id), + }); + + const authorizeStep = steps.find( + (s) => s.kind === 'signature' && s.id === 'authorize', + ) as RelaySignatureStep | undefined; + + const depositStep = steps.find((s) => s.id === 'deposit'); + + if (!authorizeStep || !depositStep) { + throw new Error( + `Expected authorize and deposit steps for HyperLiquid withdrawal, got: ${steps.map((s) => `${s.id}(${s.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'); +} + +/** + * Execute the authorize step: sign EIP-712 nonce-mapping and POST to Relay. + */ +async function executeAuthorizeStep( + step: RelaySignatureStep, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + 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}`; + + 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); +} + +/** + * 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. + */ +async function executeDepositStep( + step: RelaySignatureStep | { id: string; kind: string; items: unknown[] }, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + // The deposit step for HL has kind='transaction' but its data is not a + // standard on-chain tx. It contains action, nonce, eip712Types, eip712PrimaryType. + const item = (step.items as { data: Record }[])[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; + + 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, + ); + + // Parse r, s, v from the 65-byte signature + const r = signature.slice(0, 66); + const s = `0x${signature.slice(66, 130)}`; + const v = parseInt(signature.slice(130, 132), 16); + + log('Posting deposit to HyperLiquid exchange'); + + 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 }, + }), + }); + + const result = await response.json(); + + if (result?.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..db1fb07a000 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,59 @@ describe('Relay Submit Utils', () => { ); }); + describe('HyperLiquid source', () => { + it('calls submitHyperliquidWithdraw instead of submitTransactions', async () => { + const { submitHyperliquidWithdraw: hlWithdrawMock } = + jest.requireMock('./hyperliquid-withdraw') as { + submitHyperliquidWithdraw: jest.Mock; + }; + + 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') as { + submitHyperliquidWithdraw: jest.Mock; + }; + + 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..8f0275048ad 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -33,6 +33,7 @@ import type { TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; +import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Hex, Json } from '@metamask/utils'; import type { Draft } from 'immer'; @@ -59,7 +60,8 @@ export type AllowedActions = | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction - | TransactionControllerUpdateTransactionAction; + | TransactionControllerUpdateTransactionAction + | KeyringControllerSignTypedMessageAction; export type AllowedEvents = | BridgeStatusControllerStateChangeEvent From 85898bfd6faaa3a7e6f44848cdb95367639f9645 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:04:06 +0100 Subject: [PATCH 2/5] Linting, Bugbot Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../relay/hyperliquid-withdraw.test.ts | 6 +-- .../strategy/relay/hyperliquid-withdraw.ts | 41 +++++++++++++------ .../src/strategy/relay/relay-submit.test.ts | 14 +++---- .../transaction-pay-controller/src/types.ts | 2 +- 4 files changed, 37 insertions(+), 26 deletions(-) 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 index 7f08c86224e..ff10d9b6b25 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts @@ -15,11 +15,7 @@ jest.mock('@metamask/controller-utils', () => ({ const FROM_MOCK = '0xabc123' as Hex; -const SIGNATURE_MOCK = - '0x' + - 'aa'.repeat(32) + - 'bb'.repeat(32) + - '1b'; +const SIGNATURE_MOCK = `0x${'aa'.repeat(32)}${'bb'.repeat(32)}1b`; const AUTHORIZE_STEP_MOCK: RelaySignatureStep = { id: 'authorize', diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index 2b64d986dde..a53d72b2b3b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -28,12 +28,15 @@ const DOMAIN_FIELD_MAP: Record = { * 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) - .filter((key) => key in DOMAIN_FIELD_MAP) + return Object.keys(DOMAIN_FIELD_MAP) + .filter((key) => key in domain) .map((key) => DOMAIN_FIELD_MAP[key]); } @@ -59,18 +62,18 @@ export async function submitHyperliquidWithdraw( log('Starting HyperLiquid withdrawal', { stepCount: steps.length, - stepIds: steps.map((s) => s.id), + stepIds: steps.map((step) => step.id), }); const authorizeStep = steps.find( - (s) => s.kind === 'signature' && s.id === 'authorize', + (step) => step.kind === 'signature' && step.id === 'authorize', ) as RelaySignatureStep | undefined; - const depositStep = steps.find((s) => s.id === 'deposit'); + 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((s) => `${s.id}(${s.kind})`).join(', ')}`, + `Expected authorize and deposit steps for HyperLiquid withdrawal, got: ${steps.map((step) => `${step.id}(${step.kind})`).join(', ')}`, ); } @@ -85,6 +88,10 @@ export async function submitHyperliquidWithdraw( /** * 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, @@ -139,6 +146,10 @@ async function executeAuthorizeStep( * * 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[] }, @@ -154,7 +165,10 @@ async function executeDepositStep( const { data } = item; - const action = data.action as { type: string; parameters: Record }; + 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; @@ -182,7 +196,10 @@ async function executeDepositStep( }, }; - log('Signing HyperLiquid deposit (sendAsset)', { nonce, action: action.type }); + log('Signing HyperLiquid deposit (sendAsset)', { + nonce, + action: action.type, + }); const signature = await messenger.call( 'KeyringController:signTypedMessage', @@ -193,9 +210,11 @@ async function executeDepositStep( SignTypedDataVersion.V4, ); - // Parse r, s, v from the 65-byte signature + // 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'); @@ -217,9 +236,7 @@ async function executeDepositStep( const result = await response.json(); if (result?.status !== 'ok') { - throw new Error( - `HyperLiquid deposit failed: ${JSON.stringify(result)}`, - ); + 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 db1fb07a000..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 @@ -1112,10 +1112,9 @@ describe('Relay Submit Utils', () => { describe('HyperLiquid source', () => { it('calls submitHyperliquidWithdraw instead of submitTransactions', async () => { - const { submitHyperliquidWithdraw: hlWithdrawMock } = - jest.requireMock('./hyperliquid-withdraw') as { - submitHyperliquidWithdraw: jest.Mock; - }; + const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( + './hyperliquid-withdraw', + ); request.quotes[0].request.isHyperliquidSource = true; request.quotes[0].original.steps[0].kind = 'transaction'; @@ -1149,10 +1148,9 @@ describe('Relay Submit Utils', () => { }); it('does not call submitHyperliquidWithdraw for non-HL source', async () => { - const { submitHyperliquidWithdraw: hlWithdrawMock } = - jest.requireMock('./hyperliquid-withdraw') as { - submitHyperliquidWithdraw: jest.Mock; - }; + const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( + './hyperliquid-withdraw', + ); request.quotes[0].request.isHyperliquidSource = false; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 8f0275048ad..ea3547fa6f8 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'; @@ -33,7 +34,6 @@ import type { TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; -import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Hex, Json } from '@metamask/utils'; import type { Draft } from 'immer'; From 08d785f3e87a70c478114ac8a12a1849b36228bb Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:15:13 +0100 Subject: [PATCH 3/5] Update changelog Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- packages/transaction-pay-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ad465f52854..e39bda0dbca 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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)) ### Changed From 8294005c7906cff202ffb961845769f8bbfc06a5 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:22:54 +0100 Subject: [PATCH 4/5] Resolve feedback (error handling, ordering, mark the change as breaking) Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../transaction-pay-controller/CHANGELOG.md | 2 + .../relay/hyperliquid-withdraw.test.ts | 54 ++++++++- .../strategy/relay/hyperliquid-withdraw.ts | 105 +++++++++++------- .../transaction-pay-controller/src/types.ts | 4 +- 4 files changed, 121 insertions(+), 44 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index e39bda0dbca..d3eb8556f80 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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)) 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 index ff10d9b6b25..a8d65e97af6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts @@ -249,7 +249,7 @@ describe('submitHyperliquidWithdraw', () => { await expect( submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), - ).rejects.toThrow('Authorize step has no items'); + ).rejects.toThrow('Expected exactly 1 authorize item, got 0'); }); it('throws if deposit step has no items', async () => { @@ -262,6 +262,56 @@ describe('submitHyperliquidWithdraw', () => { await expect( submitHyperliquidWithdraw(quote, FROM_MOCK, messenger), - ).rejects.toThrow('Deposit step has no items'); + ).rejects.toThrow('Expected exactly 1 deposit item, got 0'); + }); + + 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'); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index a53d72b2b3b..55eaf389835 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -25,23 +25,7 @@ const DOMAIN_FIELD_MAP: Record = { }; /** - * 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 HyperLiquid 2-step withdrawal via Relay. + * 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. @@ -86,6 +70,22 @@ export async function submitHyperliquidWithdraw( 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. * @@ -98,6 +98,12 @@ async function executeAuthorizeStep( 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'); @@ -130,15 +136,21 @@ async function executeAuthorizeStep( const authorizeUrl = `${RELAY_AUTHORIZE_URL}?signature=${signature}`; - const response = await successfulFetch(authorizeUrl, { - method: post.method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(post.body), - }); + try { + const response = await successfulFetch(authorizeUrl, { + method: post.method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(post.body), + }); - const result = await response.json(); + const result = await response.json(); - log('Authorize response', result); + log('Authorize response', result); + } catch (error) { + throw new Error( + `HyperLiquid authorize failed: ${(error as Error).message}`, + ); + } } /** @@ -156,9 +168,13 @@ async function executeDepositStep( from: Hex, messenger: TransactionPayControllerMessenger, ): Promise { - // The deposit step for HL has kind='transaction' but its data is not a - // standard on-chain tx. It contains action, nonce, eip712Types, eip712PrimaryType. - const item = (step.items as { data: Record }[])[0]; + 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'); } @@ -173,6 +189,9 @@ async function executeDepositStep( 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 = { @@ -219,19 +238,25 @@ async function executeDepositStep( log('Posting deposit to HyperLiquid exchange'); - 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 }, - }), - }); + let response: Response; + + try { + 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 }, + }), + }); + } catch (error) { + throw new Error(`HyperLiquid deposit failed: ${(error as Error).message}`); + } const result = await response.json(); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index ea3547fa6f8..320f07327dc 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -47,6 +47,7 @@ export type AllowedActions = | BridgeStatusControllerActions | CurrencyRateControllerActions | GasFeeControllerActions + | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | RampsControllerGetQuotesAction @@ -60,8 +61,7 @@ export type AllowedActions = | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction - | TransactionControllerUpdateTransactionAction - | KeyringControllerSignTypedMessageAction; + | TransactionControllerUpdateTransactionAction; export type AllowedEvents = | BridgeStatusControllerStateChangeEvent From 3240f3dfe2964fa5454b50e195e355e7b911de6b Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:35:49 +0100 Subject: [PATCH 5/5] Update changelog, add tests, Bugbot comment Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../transaction-pay-controller/CHANGELOG.md | 4 +- .../relay/hyperliquid-withdraw.test.ts | 44 +++++++++++++++++++ .../strategy/relay/hyperliquid-withdraw.ts | 10 ++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d3eb8556f80..8a7357c0c7f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,8 +7,6 @@ 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)) @@ -16,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/hyperliquid-withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts index a8d65e97af6..560bc8188fd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.test.ts @@ -265,6 +265,32 @@ describe('submitHyperliquidWithdraw', () => { ).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, @@ -314,4 +340,22 @@ describe('submitHyperliquidWithdraw', () => { 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 index 55eaf389835..dd20ced8458 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -238,10 +238,10 @@ async function executeDepositStep( log('Posting deposit to HyperLiquid exchange'); - let response: Response; + let result: unknown; try { - response = await successfulFetch(HYPERLIQUID_EXCHANGE_URL, { + const response = await successfulFetch(HYPERLIQUID_EXCHANGE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -254,13 +254,13 @@ async function executeDepositStep( signature: { r, s, v }, }), }); + + result = await response.json(); } catch (error) { throw new Error(`HyperLiquid deposit failed: ${(error as Error).message}`); } - const result = await response.json(); - - if (result?.status !== 'ok') { + if ((result as { status?: string })?.status !== 'ok') { throw new Error(`HyperLiquid deposit failed: ${JSON.stringify(result)}`); }