Skip to content
Merged
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
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1337,7 +1337,7 @@
},
"packages/tron-wallet-snap/src/services/send/SendService.test.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 3
"count": 4
},
"@typescript-eslint/no-explicit-any": {
"count": 15
Expand Down
1 change: 1 addition & 0 deletions packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add a shared `ActionHeader` Snaps JSX component for the centered header at the top of transaction confirmations, rendering a large `title`, an optional muted `subtitle`, and an optional extra-large asset icon (`iconUrl`) ([#431](https://github.com/MetaMask/internal-snaps/pull/431))
- Add `resolveFlowTransactionType` for classifying unified swap and bridge transactions from source and destination ([#434](https://github.com/MetaMask/internal-snaps/pull/434))
- Support importing `.svg` files as strings in the package source, like in a Snap ([#423](https://github.com/MetaMask/internal-snaps/pull/423))
- The files are copied to `dist/` as-is, so consumers must bundle the package with `mm-snap` (or another bundler that loads `.svg` files as source)
- Add shared `Asset` and `AssetIcon` Snaps JSX components for displaying an asset inline in transaction confirmations ([#418](https://github.com/MetaMask/internal-snaps/pull/418))
Expand Down
1 change: 1 addition & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
WebSocketConnectionClosedEventProperties,
} from './services/analytics/AnalyticsService';
export { safeMerge } from './utils/safeMerge/safeMerge';
export { resolveFlowTransactionType } from './utils/transactionType/transactionType';
export { buildUrl } from './utils/buildUrl/buildUrl';
export type { BuildUrlParams } from './utils/buildUrl/buildUrl';
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { TransactionType } from '@metamask/keyring-api';
import type { CaipAssetType } from '@metamask/utils';

import { resolveFlowTransactionType } from './transactionType';

describe('resolveFlowTransactionType', () => {
const TRON_MAINNET_TRX: CaipAssetType = 'tron:728126428/slip44:195';
const TRON_MAINNET_USDT: CaipAssetType =
'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const ETHEREUM_MAINNET_USDC: CaipAssetType =
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';

it('returns swap for assets on the same chain', () => {
expect(
resolveFlowTransactionType({
sourceAssetId: TRON_MAINNET_TRX,
destAssetId: TRON_MAINNET_USDT,
}),
).toBe(TransactionType.Swap);
});

it('returns bridgeSend for assets on different chains', () => {
expect(
resolveFlowTransactionType({
sourceAssetId: TRON_MAINNET_USDT,
destAssetId: ETHEREUM_MAINNET_USDC,
}),
).toBe(TransactionType.BridgeSend);
});

it.each([
{ sourceAssetId: undefined, destAssetId: ETHEREUM_MAINNET_USDC },
{ sourceAssetId: TRON_MAINNET_TRX, destAssetId: undefined },
])('returns undefined when an asset id is missing', (options) => {
expect(resolveFlowTransactionType(options)).toBeUndefined();
});

it('returns undefined when an asset id is malformed', () => {
expect(
resolveFlowTransactionType({
sourceAssetId: 'not-an-asset-id' as never,
destAssetId: ETHEREUM_MAINNET_USDC,
}),
).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { TransactionType } from '@metamask/keyring-api';
import type { CaipAssetType } from '@metamask/utils';
import { parseCaipAssetType } from '@metamask/utils';

/**
* Resolves the transaction type for a unified swap or bridge flow.
*
* @param options - The source and destination asset ids.
* @param options.sourceAssetId - The source asset id.
* @param options.destAssetId - The destination asset id.
* @returns `swap` for assets on the same chain, `bridgeSend` for assets on
* different chains, or `undefined` when either asset id is missing or invalid.
*/
export function resolveFlowTransactionType({
sourceAssetId,
destAssetId,
}: {
sourceAssetId?: CaipAssetType;
destAssetId?: CaipAssetType;
}): TransactionType | undefined {
if (sourceAssetId === undefined || destAssetId === undefined) {
return undefined;
}

try {
const { chainId: sourceChainId } = parseCaipAssetType(sourceAssetId);
const { chainId: destChainId } = parseCaipAssetType(destAssetId);

return sourceChainId === destChainId
? TransactionType.Swap
: TransactionType.BridgeSend;
} catch {
return undefined;
}
}
1 change: 1 addition & 0 deletions packages/tron-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Classify `transaction_type` for the unified swap/bridge flow with the new optional `sourceAssetId`/`destAssetId` request options. Classify TRC20 smart-contract calls by function selector (so a `transfer` reports `send` and an `approve` reports `tokenApprove` instead of `unknown`) ([#434](https://github.com/MetaMask/internal-snaps/pull/434))
- Change the last behaviours missing for the `AssetsController` migration to happen ([#424](https://github.com/MetaMask/internal-snaps/pull/424))
- When the assets migration feature flag is active, the snap no longer fetches, persists, or publishes assets: the periodic sync cronjob and the synchronization triggered by selected account changes no longer touch assets or emit `AccountAssetListUpdated` and `AccountBalancesUpdated` events, leaving asset syncing to the `AssetsController`
- When the live assets and balances are fetched by `getAccountAssets` and `getAccountBalances`, they are persisted to local state without emitting `AccountAssetListUpdated` and `AccountBalancesUpdated` events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,149 @@ describe('ClientRequestHandler - signAndSendTransaction', () => {
.invocationCallOrder[0] as number,
);
});

it.each([
[
'a same-chain swap',
'tron:728126428/slip44:195',
'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
TransactionType.Swap,
],
[
'a cross-chain bridge',
'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
TransactionType.BridgeSend,
],
])(
'classifies the transaction as %s when swap asset ids are provided',
async (_label, sourceAssetId, destAssetId, expectedType) => {
const scope = Network.Mainnet;
const request = {
jsonrpc: '2.0' as const,
id: '1',
method: ClientRequestMethod.SignAndSendTransaction,
params: {
accountId: TEST_ACCOUNT_ID,
transaction: TEST_TRANSACTION_BASE64,
scope,
options: {
visible: false,
type: 'TriggerSmartContract',
sourceAssetId,
destAssetId,
},
},
};

mockAccountsService.findByIdOrThrow.mockResolvedValue(
createMockExtendedKeyringAccount({
type: 'tron:eoa',
scopes: [scope],
}),
);

mockAccountsService.deriveTronKeypair.mockResolvedValue({
privateKeyHex: 'test-private-key',
address: CORRECT_OWNER_ADDRESS_BASE58,
privateKeyBytes: new Uint8Array(),
publicKeyBytes: new Uint8Array(),
});

mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({
contract: [
{
type: 'TriggerSmartContract',
parameter: {
value: {
owner_address: CORRECT_OWNER_ADDRESS_HEX,
},
},
},
],
});

await clientRequestHandler.handle(request as JsonRpcRequest);

expect(
mockAnalyticsService.trackTransactionSubmitted,
).toHaveBeenCalledWith({
origin: METAMASK_ORIGIN,
accountType: 'tron:eoa',
chainIdCaip: scope,
transactionType: expectedType,
});

expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({
method: BackgroundEventMethod.TrackTransaction,
params: {
txId: transactionId,
scope,
accountIds: [TEST_ACCOUNT_ID],
attempt: 0,
transactionType: expectedType,
},
duration: TRACK_TX_INTERVAL,
});
},
);

it('classifies a TRC20 transfer dApp call as send when no asset ids are provided', async () => {
const scope = Network.Mainnet;
const request = {
jsonrpc: '2.0' as const,
id: '1',
method: ClientRequestMethod.SignAndSendTransaction,
params: {
accountId: TEST_ACCOUNT_ID,
transaction: TEST_TRANSACTION_BASE64,
scope,
options: {
visible: false,
type: 'TriggerSmartContract',
},
},
};

mockAccountsService.findByIdOrThrow.mockResolvedValue(
createMockExtendedKeyringAccount({
type: 'tron:eoa',
scopes: [scope],
}),
);

mockAccountsService.deriveTronKeypair.mockResolvedValue({
privateKeyHex: 'test-private-key',
address: CORRECT_OWNER_ADDRESS_BASE58,
privateKeyBytes: new Uint8Array(),
publicKeyBytes: new Uint8Array(),
});

mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({
contract: [
{
type: 'TriggerSmartContract',
parameter: {
value: {
owner_address: CORRECT_OWNER_ADDRESS_HEX,
data: 'a9059cbb000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c0000000000000000000000000000000000000000000000000000000000000000',
},
},
},
],
});

await clientRequestHandler.handle(request as JsonRpcRequest);

expect(mockAnalyticsService.trackTransactionSubmitted).toHaveBeenCalledWith(
{
origin: METAMASK_ORIGIN,
accountType: 'tron:eoa',
chainIdCaip: scope,
transactionType: TransactionType.Send,
},
);
});
});

describe('ClientRequestHandler - onAmountInput', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type { TransactionsService } from '../../services/transactions/Transactio
import { assertOrThrow } from '../../utils/assertOrThrow';
import { parseTronCaipAssetType } from '../../utils/caip';
import { trxToSun } from '../../utils/conversion';
import { mapRawTransactionType } from '../../utils/transactionType';
import { resolveTransactionType } from '../../utils/transactionType';
import {
assertTransactionSignerConsistency,
assertTransactionStructure,
Expand Down Expand Up @@ -343,7 +343,7 @@ export class ClientRequestHandler {
transaction: transactionBase64,
accountId,
scope,
options: { type },
options: { type, sourceAssetId, destAssetId },
} = request.params;

const account = await this.#accountsService.findByIdOrThrow(accountId);
Expand Down Expand Up @@ -410,8 +410,17 @@ export class ClientRequestHandler {
* unified send path and the background transaction tracker. The origin is
* lowercased so it is recognized as MetaMask by the security alerts scan
* and stays consistent with the other non-EVM snaps.
*
* The swap/bridge flow also passes the source and destination asset ids,
* which let the transaction be classified as a same-chain swap or a
* cross-chain bridge. Without them (unified send, dApp calls) the
* classification falls back to the contract type.
*/
const transactionType = mapRawTransactionType(rawData);
const transactionType = resolveTransactionType({
rawData,
sourceAssetId,
destAssetId,
});

await this.#analyticsService.trackTransactionSubmitted({
origin: METAMASK_ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const SignAndSendTransactionRequestParamsStruct = object({
options: object({
visible: optional(boolean()),
type: string(),
sourceAssetId: optional(CaipAssetTypeStruct),
destAssetId: optional(CaipAssetTypeStruct),
}),
});

Expand Down
62 changes: 62 additions & 0 deletions packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,68 @@ describe('CronHandler', () => {
);
});

it('carries a swap classification through to the finalized event', async () => {
await withTrackTransactionCronHandler(
async ({ cronHandler, mockAccountsService, mockTronHttpClient }) => {
const mockAccount = { id: ACCOUNT_ID, type: 'tron:eoa' };
mockTronHttpClient.getTransactionInfoById.mockResolvedValue({
blockNumber: 100,
receipt: { result: 'SUCCESS' },
} as never);
mockAccountsService.findByIds.mockResolvedValue([mockAccount]);

await cronHandler.trackTransaction({
txId: TX_ID,
scope: Network.Mainnet,
accountIds: ACCOUNT_IDS,
attempt: 0,
transactionType: TransactionType.Swap,
});

expect(
mockAnalyticsService.trackTransactionFinalized,
).toHaveBeenCalledWith({
origin: METAMASK_ORIGIN,
accountType: mockAccount.type,
chainIdCaip: Network.Mainnet,
transactionStatus: TransactionStatus.Confirmed,
transactionType: TransactionType.Swap,
});
},
);
});

it('carries a bridgeSend classification through to the finalized event', async () => {
await withTrackTransactionCronHandler(
async ({ cronHandler, mockAccountsService, mockTronHttpClient }) => {
const mockAccount = { id: ACCOUNT_ID, type: 'tron:eoa' };
mockTronHttpClient.getTransactionInfoById.mockResolvedValue({
blockNumber: 100,
receipt: { result: 'SUCCESS' },
} as never);
mockAccountsService.findByIds.mockResolvedValue([mockAccount]);

await cronHandler.trackTransaction({
txId: TX_ID,
scope: Network.Mainnet,
accountIds: ACCOUNT_IDS,
attempt: 0,
transactionType: TransactionType.BridgeSend,
});

expect(
mockAnalyticsService.trackTransactionFinalized,
).toHaveBeenCalledWith({
origin: METAMASK_ORIGIN,
accountType: mockAccount.type,
chainIdCaip: Network.Mainnet,
transactionStatus: TransactionStatus.Confirmed,
transactionType: TransactionType.BridgeSend,
});
},
);
});

it('reports a failed status when the transaction reverted on-chain', async () => {
await withTrackTransactionCronHandler(
async ({ cronHandler, mockAccountsService, mockTronHttpClient }) => {
Expand Down
Loading
Loading