Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e170949
feat(transaction-pay-controller): flat signature steps in server stra…
matthewwalsh0 Jun 6, 2026
2b9febd
feat(transaction-pay-controller): parity improvements to server strategy
matthewwalsh0 Jun 7, 2026
7752439
refactor(transaction-pay-controller): consolidate transaction step bu…
matthewwalsh0 Jun 7, 2026
57ce04b
fix(transaction-pay-controller): fix server strategy tests for generi…
matthewwalsh0 Jun 7, 2026
7c978d4
fix(transaction-pay-controller): cast batch transaction type to Trans…
matthewwalsh0 Jun 7, 2026
4360b72
feat(transaction-pay-controller): trigger quote refresh on txParams.t…
matthewwalsh0 Jun 9, 2026
89bd789
chore(transaction-pay-controller): update changelog
matthewwalsh0 Jun 9, 2026
87c61ea
docs(transaction-pay-controller): restore JSDoc on perps.ts exports
matthewwalsh0 Jun 9, 2026
989edbb
test(transaction-pay-controller): add coverage for signature steps, p…
matthewwalsh0 Jun 9, 2026
4f5bf93
fix: apply prettier formatting
matthewwalsh0 Jun 9, 2026
0772795
Fix ESLint errors in server strategy files
matthewwalsh0 Jun 9, 2026
e65f5e8
fix: apply Prettier formatting
matthewwalsh0 Jun 9, 2026
087a098
fix: replace Object.hasOwn with hasOwnProperty for ES2021 compat
matthewwalsh0 Jun 9, 2026
dba5263
chore: simplify changelog entry
matthewwalsh0 Jun 9, 2026
e012382
chore: simplify changelog entry
matthewwalsh0 Jun 9, 2026
d725c86
chore: expand changelog entry
matthewwalsh0 Jun 9, 2026
23601c6
chore: update changelog entry
matthewwalsh0 Jun 9, 2026
d943fa8
fix(transaction-pay-controller): remove id/signatureKind from ServerS…
matthewwalsh0 Jun 9, 2026
016a5c3
fix(transaction-pay-controller): add fee caps to prepended post-quote…
matthewwalsh0 Jun 11, 2026
aa88e3e
Fix: move changelog entry to Unreleased; replace resolves.not.toThrow…
matthewwalsh0 Jun 15, 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
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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

### Changed

- Bump `@metamask/assets-controllers` from `^109.0.0` to `^109.1.0` ([#9110](https://github.com/MetaMask/core/pull/9110))
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