Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7b25870
feat(transaction-pay-controller): flat signature steps in server stra…
matthewwalsh0 Jun 6, 2026
71ec27a
feat(transaction-pay-controller): parity improvements to server strategy
matthewwalsh0 Jun 7, 2026
82c998e
refactor(transaction-pay-controller): consolidate transaction step bu…
matthewwalsh0 Jun 7, 2026
7d91110
fix(transaction-pay-controller): fix server strategy tests for generi…
matthewwalsh0 Jun 7, 2026
051f446
fix(transaction-pay-controller): cast batch transaction type to Trans…
matthewwalsh0 Jun 7, 2026
f5a1bdf
feat(transaction-pay-controller): trigger quote refresh on txParams.t…
matthewwalsh0 Jun 9, 2026
a103c74
chore(transaction-pay-controller): update changelog
matthewwalsh0 Jun 9, 2026
9235aa2
docs(transaction-pay-controller): restore JSDoc on perps.ts exports
matthewwalsh0 Jun 9, 2026
2c2e75f
test(transaction-pay-controller): add coverage for signature steps, p…
matthewwalsh0 Jun 9, 2026
765205a
fix: apply prettier formatting
matthewwalsh0 Jun 9, 2026
dd0baad
Fix ESLint errors in server strategy files
matthewwalsh0 Jun 9, 2026
4b47690
fix: apply Prettier formatting
matthewwalsh0 Jun 9, 2026
098c6ac
fix: replace Object.hasOwn with hasOwnProperty for ES2021 compat
matthewwalsh0 Jun 9, 2026
d86af0a
chore: simplify changelog entry
matthewwalsh0 Jun 9, 2026
071779b
chore: simplify changelog entry
matthewwalsh0 Jun 9, 2026
e08c290
chore: expand changelog entry
matthewwalsh0 Jun 9, 2026
7996ccf
chore: update changelog entry
matthewwalsh0 Jun 9, 2026
a2b0965
fix(transaction-pay-controller): remove id/signatureKind from ServerS…
matthewwalsh0 Jun 9, 2026
c75fcc1
fix(transaction-pay-controller): add fee caps to prepended post-quote…
matthewwalsh0 Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows ([#9051](https://github.com/MetaMask/core/pull/9051))
- Trigger quote refresh when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger
- Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090))

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,25 @@ describe('strategy/server/perps', () => {

expect(result).toBe(baseRequest);
});

it('rewrites source chain, token, and amount when isHyperliquidSource is true', () => {
const withdrawRequest: QuoteRequest = {
...baseRequest,
isHyperliquidSource: true,
sourceChainId: '0xa4b1',
sourceTokenAmount: '100000000',
};

const result = normalizeServerPerpsRequest(
withdrawRequest,
innocuousTransaction,
);

expect(result.sourceChainId).toBe(CHAIN_ID_HYPERCORE);
expect(result.sourceTokenAddress).toBe(
SERVER_HYPERCORE_USDC_PERPS_ADDRESS,
);
expect(result.sourceTokenAmount).toBe('1000000');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,22 @@ export function isServerPerpsDepositRequest(
* 6 decimals); HyperCore expects an 8-decimal amount, so the target amount is
* shifted accordingly.
*
* Also handles the perps-withdraw direction: when `request.isHyperliquidSource`
* is set the source is rewritten to the HyperCore sentinel and the amount is
* shifted from 8 to 6 decimals.
*
* @param request - Quote request from the transaction-pay controller.
* @param transaction - Parent transaction whose calldata is inspected.
* @returns Normalized request, or the original request if not a perps deposit.
* @returns Normalized request, or the original request if not a perps flow.
*/
export function normalizeServerPerpsRequest(
request: QuoteRequest,
transaction: Pick<TransactionMeta, 'txParams' | 'nestedTransactions'>,
): QuoteRequest {
if (request.isHyperliquidSource) {
return normalizePerpsWithdrawRequest(request);
}

if (!isServerPerpsDepositRequest(request, transaction)) {
return request;
}
Expand All @@ -87,6 +95,17 @@ export function normalizeServerPerpsRequest(
};
}

function normalizePerpsWithdrawRequest(request: QuoteRequest): QuoteRequest {
return {
...request,
sourceChainId: CHAIN_ID_HYPERCORE,
sourceTokenAddress: SERVER_HYPERCORE_USDC_PERPS_ADDRESS,
sourceTokenAmount: new BigNumber(request.sourceTokenAmount)
.shiftedBy(USDC_DECIMALS - HYPERCORE_USDC_DECIMALS)
.toFixed(0),
};
}

function transactionDataReferencesBridge(
transaction: Pick<TransactionMeta, 'txParams' | 'nestedTransactions'>,
): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { TransactionMeta } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';

import { CHAIN_ID_HYPERCORE, TransactionPayStrategy } from '../../constants';
import {
CHAIN_ID_HYPERCORE,
PaymentOverride,
TransactionPayStrategy,
} from '../../constants';
import { getMessengerMock } from '../../tests/messenger-mock';
import type {
GetDelegationTransactionCallback,
Expand Down Expand Up @@ -90,6 +94,7 @@ const FULFILLED_RESULT_MOCK = {
},
steps: [
{
type: 'transaction' as const,
chainId: 1,
data: '0xdef' as Hex,
to: '0x4560000000000000000000000000000000000000' as Hex,
Expand Down Expand Up @@ -124,7 +129,12 @@ describe('server-quotes', () => {
const fetchServerQuoteMock = jest.mocked(fetchServerQuote);
const getSlippageMock = jest.mocked(getSlippage);
const isEIP7702ChainMock = jest.mocked(isEIP7702Chain);
const { getDelegationTransactionMock, messenger } = getMessengerMock();
const {
getControllerStateMock,
getDelegationTransactionMock,
getPaymentOverrideDataMock,
messenger,
} = getMessengerMock();

beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -497,6 +507,7 @@ describe('server-quotes', () => {
gasless: false,
steps: [
{
type: 'transaction' as const,
chainId: 1,
data: '0xdef' as Hex,
maxFeePerGas: '0x1',
Expand Down Expand Up @@ -627,6 +638,7 @@ describe('server-quotes', () => {
...NON_GASLESS_RESULT_MOCK.quote,
steps: [
{
type: 'transaction' as const,
chainId: 1,
data: '0xdef' as Hex,
to: '0x4560000000000000000000000000000000000000' as Hex,
Expand Down Expand Up @@ -685,6 +697,7 @@ describe('server-quotes', () => {
...NON_GASLESS_RESULT_MOCK.quote,
steps: [
{
type: 'transaction' as const,
chainId: 1,
data: '0xdef' as Hex,
to: '0x4560000000000000000000000000000000000000' as Hex,
Expand All @@ -711,5 +724,249 @@ describe('server-quotes', () => {
}),
);
});

it('zeroes source network fees when quote has no steps and is not gasless', async () => {
fetchServerQuoteMock.mockResolvedValue({
results: [
{
...FULFILLED_RESULT_MOCK,
quote: {
...FULFILLED_RESULT_MOCK.quote,
gasless: false,
steps: [],
},
},
],
});

const result = await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(result[0].fees.sourceNetwork.estimate).toStrictEqual(
expect.objectContaining({ raw: '0' }),
);
});
});

describe('processMoneyAccountPostQuote', () => {
const OVERRIDE_CALL_MOCK = {
data: '0xoverride' as Hex,
to: '0xcccc000000000000000000000000000000000000' as Hex,
value: '0x0' as Hex,
};

beforeEach(() => {
getControllerStateMock.mockReturnValue({
transactionData: {
[TRANSACTION_META_MOCK.id]: {
tokens: [{ amountHuman: '1.5' }],
},
},
} as never);

getPaymentOverrideDataMock.mockResolvedValue({
calls: [OVERRIDE_CALL_MOCK],
recipient: TOKEN_TRANSFER_RECIPIENT_MOCK,
authorizationList: undefined,
} as never);
});

it('adds override calls and transfer call to server quote body when isPostQuote + MoneyAccount', async () => {
await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(fetchServerQuoteMock).toHaveBeenCalledWith(
messenger,
expect.objectContaining({
calls: expect.arrayContaining([
expect.objectContaining({ to: OVERRIDE_CALL_MOCK.to }),
]),
}),
undefined,
);
});

it('falls back to request.from as recipient when getPaymentOverrideData returns no recipient', async () => {
getPaymentOverrideDataMock.mockResolvedValue({
calls: [OVERRIDE_CALL_MOCK],
recipient: undefined,
authorizationList: undefined,
} as never);

await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

// The transfer call targets the source token address with FROM_MOCK as recipient.
expect(fetchServerQuoteMock).toHaveBeenCalledWith(
messenger,
expect.objectContaining({
calls: expect.arrayContaining([
expect.objectContaining({
to: TARGET_TOKEN_ADDRESS_MOCK,
// data encodes FROM_MOCK (lower-cased, no 0x prefix) as the recipient
data: expect.stringContaining(
FROM_MOCK.slice(2).toLowerCase(),
) as string,
}),
]),
}),
undefined,
);
});

it('attaches authorizationList when getPaymentOverrideData returns one', async () => {
getPaymentOverrideDataMock.mockResolvedValue({
calls: [OVERRIDE_CALL_MOCK],
recipient: TOKEN_TRANSFER_RECIPIENT_MOCK,
authorizationList: [
{
address: '0xaaaa000000000000000000000000000000000000' as Hex,
chainId: '0x1' as Hex,
nonce: '0x1' as Hex,
r: '0xr' as Hex,
s: '0xs' as Hex,
yParity: '0x1' as Hex,
},
],
} as never);

await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(fetchServerQuoteMock).toHaveBeenCalledWith(
messenger,
expect.objectContaining({
authorizationList: expect.arrayContaining([
expect.objectContaining({
address: '0xaaaa000000000000000000000000000000000000',
}),
]),
}),
undefined,
);
});

it('falls back to 0 amount when transactionData has no tokens', async () => {
getControllerStateMock.mockReturnValue({
transactionData: {},
} as never);

await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(getPaymentOverrideDataMock).toHaveBeenCalledWith(
expect.objectContaining({ amount: '0' }),
);
});

it('defaults override call value to 0x0 when call.value is undefined', async () => {
getPaymentOverrideDataMock.mockResolvedValue({
calls: [
{
to: '0xcccc000000000000000000000000000000000000' as Hex,
data: '0xdata' as Hex,
},
],
recipient: TOKEN_TRANSFER_RECIPIENT_MOCK,
authorizationList: undefined,
} as never);

await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(fetchServerQuoteMock).toHaveBeenCalledWith(
messenger,
expect.objectContaining({
calls: expect.arrayContaining([
expect.objectContaining({
to: '0xcccc000000000000000000000000000000000000',
value: '0x0',
}),
]),
}),
undefined,
);
});

it('skips override when getPaymentOverrideData returns empty calls', async () => {
getPaymentOverrideDataMock.mockResolvedValue({
calls: [],
recipient: undefined,
authorizationList: undefined,
} as never);

await getServerQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(fetchServerQuoteMock).toHaveBeenCalledWith(
messenger,
expect.not.objectContaining({ calls: expect.anything() }),
undefined,
);
});
});
});
Loading
Loading