Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add live on-chain balance validation for pay transactions ([#7935](https://github.com/MetaMask/core/pull/7935))
- Refresh payment token balance via chain before each quote update.
- Validate source token balance via chain before submitting Relay deposits.

## [15.0.0]

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/contracts": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
"@metamask/assets-controllers": "^99.3.2",
"@metamask/base-controller": "^9.0.0",
"@metamask/bridge-controller": "^66.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export class TransactionPayController extends BaseController<
fn(current);

const isPaymentTokenUpdated =
current.paymentToken !== originalPaymentToken;
current.paymentToken?.address?.toLowerCase() !==
originalPaymentToken?.address?.toLowerCase() ||
current.paymentToken?.chainId !== originalPaymentToken?.chainId;

const isTokensUpdated = current.tokens !== originalTokens;
const isIsMaxUpdated = current.isMaxAmount !== originalIsMaxAmount;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import {
} from '../utils/token';
import { getTransaction } from '../utils/transaction';

jest.mock('../utils/token');
jest.mock('../utils/token', () => ({
...jest.createMockFromModule<typeof import('../utils/token')>(
'../utils/token',
),
computeTokenAmounts:
jest.requireActual<typeof import('../utils/token')>('../utils/token')
.computeTokenAmounts,
}));
jest.mock('../utils/transaction');

const TOKEN_ADDRESS_MOCK = '0x123';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createModuleLogger } from '@metamask/utils';
import type { Hex } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

import type { TransactionPayControllerMessenger } from '..';
import { projectLogger } from '../logger';
Expand All @@ -10,6 +9,7 @@ import type {
UpdateTransactionDataCallback,
} from '../types';
import {
computeTokenAmounts,
getTokenBalance,
getTokenFiatRate,
getTokenInfo,
Expand Down Expand Up @@ -95,18 +95,13 @@ function getPaymentToken({
}

const balance = getTokenBalance(messenger, from, chainId, tokenAddress);
const balanceRawValue = new BigNumber(balance);
const balanceHumanValue = new BigNumber(balance).shiftedBy(-decimals);
const balanceRaw = balanceRawValue.toFixed(0);
const balanceHuman = balanceHumanValue.toString(10);

const balanceFiat = balanceHumanValue
.multipliedBy(tokenFiatRate.fiatRate)
.toString(10);

const balanceUsd = balanceHumanValue
.multipliedBy(tokenFiatRate.usdRate)
.toString(10);

const {
raw: balanceRaw,
human: balanceHuman,
usd: balanceUsd,
fiat: balanceFiat,
} = computeTokenAmounts(balance, decimals, tokenFiatRate);

return {
address: tokenAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import type {
} from '../../types';
import type { FeatureFlags } from '../../utils/feature-flags';
import { getFeatureFlags } from '../../utils/feature-flags';
import { getLiveTokenBalance } from '../../utils/token';
import {
collectTransactionIds,
getTransaction,
updateTransaction,
waitForTransactionConfirmed,
} from '../../utils/transaction';

jest.mock('../../utils/token');
jest.mock('../../utils/transaction');
jest.mock('../../utils/feature-flags');

Expand Down Expand Up @@ -89,6 +91,8 @@ const STATUS_RESPONSE_MOCK = {
txHashes: [TRANSACTION_HASH_MOCK],
};

const SOURCE_AMOUNT_RAW_MOCK = '1000000';

const REQUEST_MOCK: PayStrategyExecuteRequest<RelayQuote> = {
quotes: [
{
Expand All @@ -101,6 +105,12 @@ const REQUEST_MOCK: PayStrategyExecuteRequest<RelayQuote> = {
sourceChainId: CHAIN_ID_MOCK,
sourceTokenAddress: TOKEN_ADDRESS_MOCK,
},
sourceAmount: {
raw: SOURCE_AMOUNT_RAW_MOCK,
human: '1',
fiat: '1',
usd: '1',
},
} as TransactionPayQuote<RelayQuote>,
],
messenger: {} as TransactionPayControllerMessenger,
Expand All @@ -116,6 +126,7 @@ describe('Relay Submit Utils', () => {
const getTransactionMock = jest.mocked(getTransaction);
const collectTransactionIdsMock = jest.mocked(collectTransactionIds);
const getFeatureFlagsMock = jest.mocked(getFeatureFlags);
const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance);

const {
addTransactionMock,
Expand All @@ -133,6 +144,7 @@ describe('Relay Submit Utils', () => {
beforeEach(() => {
jest.resetAllMocks();

getLiveTokenBalanceMock.mockResolvedValue('9999999999');
findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK);

addTransactionMock.mockResolvedValue({
Expand Down Expand Up @@ -690,5 +702,81 @@ describe('Relay Submit Utils', () => {
}),
);
});

it('validates source balance before submitting single transaction', async () => {
await submitRelayQuotes(request);

expect(getLiveTokenBalanceMock).toHaveBeenCalledWith(
messenger,
FROM_MOCK,
CHAIN_ID_MOCK,
TOKEN_ADDRESS_MOCK,
);
});

it('validates source balance before submitting batch transactions', async () => {
request.quotes[0].original.steps[0].items.push({
...request.quotes[0].original.steps[0].items[0],
});

await submitRelayQuotes(request);

expect(getLiveTokenBalanceMock).toHaveBeenCalledWith(
messenger,
FROM_MOCK,
CHAIN_ID_MOCK,
TOKEN_ADDRESS_MOCK,
);
});

it('throws if source balance is insufficient for single transaction', async () => {
getLiveTokenBalanceMock.mockResolvedValue('500000');

await expect(submitRelayQuotes(request)).rejects.toThrow(
'Insufficient source token balance for relay deposit. Required: 1000000, Available: 500000',
);

expect(addTransactionMock).not.toHaveBeenCalled();
});

it('throws if source balance is insufficient for batch transactions', async () => {
request.quotes[0].original.steps[0].items.push({
...request.quotes[0].original.steps[0].items[0],
});

getLiveTokenBalanceMock.mockResolvedValue('500000');

await expect(submitRelayQuotes(request)).rejects.toThrow(
'Insufficient source token balance for relay deposit. Required: 1000000, Available: 500000',
);

expect(addTransactionBatchMock).not.toHaveBeenCalled();
});

it('throws if source balance is zero', async () => {
getLiveTokenBalanceMock.mockResolvedValue('0');

await expect(submitRelayQuotes(request)).rejects.toThrow(
'Insufficient source token balance for relay deposit. Required: 1000000, Available: 0',
);

expect(addTransactionMock).not.toHaveBeenCalled();
});

it('proceeds if source balance exactly equals required amount', async () => {
getLiveTokenBalanceMock.mockResolvedValue(SOURCE_AMOUNT_RAW_MOCK);

await submitRelayQuotes(request);

expect(addTransactionMock).toHaveBeenCalledTimes(1);
});

it('proceeds if source balance exceeds required amount', async () => {
getLiveTokenBalanceMock.mockResolvedValue('2000000');

await submitRelayQuotes(request);

expect(addTransactionMock).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';
import { createModuleLogger } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

import { RELAY_POLLING_INTERVAL, RELAY_STATUS_URL } from './constants';
import type { RelayQuote, RelayStatusResponse } from './types';
Expand All @@ -21,6 +22,7 @@ import type {
TransactionPayQuote,
} from '../../types';
import { getFeatureFlags } from '../../utils/feature-flags';
import { getLiveTokenBalance } from '../../utils/token';
import {
collectTransactionIds,
getTransaction,
Expand Down Expand Up @@ -202,6 +204,49 @@ function normalizeParams(
};
}

/**
* Validate the source token balance is sufficient for the relay deposit.
*
* Reads the live balance from TokenBalancesController and compares it against
* the quote's required source amount to prevent submitting transactions that
* will revert on-chain due to insufficient balance.
*
* @param quote - Relay quote containing the required source amount.
* @param messenger - Controller messenger.
*/
async function validateSourceBalance(
quote: TransactionPayQuote<RelayQuote>,
messenger: TransactionPayControllerMessenger,
): Promise<void> {
const { from, sourceChainId, sourceTokenAddress } = quote.request;

const currentBalance = await getLiveTokenBalance(
messenger,
from,
sourceChainId,
sourceTokenAddress,
);

const requiredAmount = new BigNumber(quote.sourceAmount.raw);
const balance = new BigNumber(currentBalance);

log('Validating source balance', {
from,
sourceChainId,
sourceTokenAddress,
currentBalance,
requiredAmount: requiredAmount.toString(10),
});

if (balance.isLessThan(requiredAmount)) {
throw new Error(
`Insufficient source token balance for relay deposit. ` +
`Required: ${requiredAmount.toString(10)}, ` +
`Available: ${balance.toString(10)}`,
);
}
}

/**
* Submit transactions for a relay quote.
*
Expand All @@ -223,6 +268,8 @@ async function submitTransactions(
throw new Error(`Unsupported step kind: ${invalidKind}`);
}

await validateSourceBalance(quote, messenger);

const normalizedParams = params.map((singleParams) =>
normalizeParams(singleParams, messenger),
);
Expand Down
Loading