diff --git a/packages/gator-permissions-snap/src/clients/blockchainClient.ts b/packages/gator-permissions-snap/src/clients/blockchainClient.ts new file mode 100644 index 00000000..f1e4190f --- /dev/null +++ b/packages/gator-permissions-snap/src/clients/blockchainClient.ts @@ -0,0 +1,215 @@ +import { logger } from '@metamask/7715-permissions-shared/utils'; +import type { Hex } from '@metamask/delegation-core'; +import { + ChainDisconnectedError, + InvalidInputError, + ResourceUnavailableError, + type SnapsEthereumProvider, +} from '@metamask/snaps-sdk'; + +import type { RetryOptions } from './types'; +import { + callContract, + ensureChain, + getTransactionReceipt, +} from '../utils/blockchain'; + +/** + * Client that fetches blockchain data using the ethereum provider for on-chain checks. + */ +export class BlockchainClient { + readonly #ethereumProvider: SnapsEthereumProvider; + + // keccak256('disabledDelegations(bytes32)') + static readonly #disabledDelegationsCalldata = '0x2d40d052'; + + constructor({ + ethereumProvider, + }: { + ethereumProvider: SnapsEthereumProvider; + }) { + this.#ethereumProvider = ethereumProvider; + } + + /** + * Determines if an error is retryable. + * @param error - The error to check. + * @returns True if the error is retryable. + */ + #isRetryableError(error: unknown): boolean { + // Don't retry chain disconnection errors or invalid input errors + if ( + error instanceof ChainDisconnectedError || + error instanceof InvalidInputError + ) { + return false; + } + + // Retry other errors (network issues, temporary failures, etc.) + return true; + } + + /** + * Checks if a delegation is disabled on-chain by calling the DelegationManager contract. + * If the request fails, it will retry according to the retryOptions configuration. + * @param args - The parameters for checking delegation disabled status. + * @param args.delegationHash - The hash of the delegation to check. + * @param args.chainId - The chain ID in hex format. + * @param args.delegationManagerAddress - The address of the DelegationManager contract. + * @param args.retryOptions - Optional retry configuration. When not provided, defaults to 1 retry attempt with 1000ms delay. + * @returns True if the delegation is disabled, false if it is confirmed to be enabled. + * @throws InvalidInputError if input parameters are invalid. + * @throws ChainDisconnectedError if the provider is on the wrong chain. + * @throws ResourceUnavailableError if the on-chain check fails and we cannot determine the status. + */ + public async checkDelegationDisabledOnChain({ + delegationHash, + chainId, + delegationManagerAddress, + retryOptions, + }: { + delegationHash: Hex; + chainId: Hex; + delegationManagerAddress: Hex; + retryOptions?: RetryOptions; + }): Promise { + logger.debug( + 'BlockchainTokenMetadataClient:checkDelegationDisabledOnChain()', + { + delegationHash, + chainId, + delegationManagerAddress, + }, + ); + + if (!delegationHash) { + const message = 'No delegation hash provided'; + logger.error(message); + throw new InvalidInputError(message); + } + + if (!chainId) { + const message = 'No chain ID provided'; + logger.error(message); + throw new InvalidInputError(message); + } + + if (!delegationManagerAddress) { + const message = 'No delegation manager address provided'; + logger.error(message); + throw new InvalidInputError(message); + } + + // Ensure we're on the correct chain + // This can throw ChainDisconnectedError - we want it to propagate + await ensureChain(this.#ethereumProvider, chainId); + + // Encode the function call data for disabledDelegations(bytes32) + const encodedParams = delegationHash.slice(2).padStart(64, '0'); // Remove 0x and pad to 32 bytes + const callData = + `${BlockchainClient.#disabledDelegationsCalldata}${encodedParams}` as Hex; + + try { + const result = await callContract({ + ethereumProvider: this.#ethereumProvider, + contractAddress: delegationManagerAddress, + callData, + retryOptions, + isRetryableError: (error) => this.#isRetryableError(error), + }); + + // Parse the boolean result (32 bytes, last byte is the boolean value) + const isDisabled = + result !== + '0x0000000000000000000000000000000000000000000000000000000000000000'; + + logger.debug('Delegation disabled status result', { isDisabled }); + return isDisabled; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error( + `Failed to check delegation disabled status: ${errorMessage}`, + ); + + // Re-throw critical errors - they should propagate + if ( + error instanceof InvalidInputError || + error instanceof ChainDisconnectedError + ) { + throw error; + } + + // For other errors (network issues, contract call failures, etc.), + // we cannot determine the status, so throw an error instead of returning false + throw new ResourceUnavailableError( + `Unable to determine delegation disabled status: ${errorMessage}`, + ); + } + } + + /** + * Checks if a transaction was successful by calling the eth_getTransactionReceipt method. + * @param args - The parameters for checking the transaction receipt. + * @param args.txHash - The hash of the transaction to check. + * @param args.chainId - The chain ID in hex format. + * @returns True if the transaction receipt is valid, false if it is not. + * @throws InvalidInputError if `chainId` is not specified + * @throws ChainDisconnectedError if the provider is on the wrong chain. + * @throws ResourceUnavailableError if the on-chain check fails and we cannot determine the status. + */ + public async checkTransactionReceipt({ + txHash, + chainId, + }: { + txHash: Hex; + chainId: Hex; + }): Promise { + logger.debug('BlockchainMetadataClient:checkTransactionReceipt()', { + txHash, + chainId, + }); + + if (!chainId) { + const message = 'No chain ID provided'; + logger.error(message); + throw new InvalidInputError(message); + } + + if (!txHash) { + const message = 'No transaction hash provided'; + logger.error(message); + throw new InvalidInputError(message); + } + + await ensureChain(this.#ethereumProvider, chainId); + + try { + const result = await getTransactionReceipt({ + ethereumProvider: this.#ethereumProvider, + txHash, + isRetryableError: (error) => this.#isRetryableError(error), + }); + + return result.status === '0x1'; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error(`Failed to fetch transaction receipt: ${errorMessage}`); + + // Re-throw critical errors - they should propagate + if ( + error instanceof InvalidInputError || + error instanceof ChainDisconnectedError + ) { + throw error; + } + + // For other errors (network issues, contract call failures, etc.), + // we cannot determine the status, so throw an error instead of returning false + throw new ResourceUnavailableError( + `Failed to fetch transaction receipt: ${errorMessage}`, + ); + } + } +} diff --git a/packages/gator-permissions-snap/src/clients/blockchainMetadataClient.ts b/packages/gator-permissions-snap/src/clients/blockchainMetadataClient.ts index a39c99ad..0dbfdc7b 100644 --- a/packages/gator-permissions-snap/src/clients/blockchainMetadataClient.ts +++ b/packages/gator-permissions-snap/src/clients/blockchainMetadataClient.ts @@ -7,7 +7,6 @@ import { InvalidInputError, InternalError, ResourceNotFoundError, - ResourceUnavailableError, type SnapsEthereumProvider, } from '@metamask/snaps-sdk'; @@ -40,9 +39,6 @@ export class BlockchainTokenMetadataClient implements TokenMetadataClient { // keccak256('symbol()') static readonly #symbolCalldata = '0x95d89b41'; - // keccak256('disabledDelegations(bytes32)') - static readonly #disabledDelegationsCalldata = '0x2d40d052'; - constructor({ ethereumProvider, }: { @@ -228,103 +224,4 @@ export class BlockchainTokenMetadataClient implements TokenMetadataClient { // Retry other errors (network issues, temporary failures, etc.) return true; } - - /** - * Checks if a delegation is disabled on-chain by calling the DelegationManager contract. - * If the request fails, it will retry according to the retryOptions configuration. - * @param args - The parameters for checking delegation disabled status. - * @param args.delegationHash - The hash of the delegation to check. - * @param args.chainId - The chain ID in hex format. - * @param args.delegationManagerAddress - The address of the DelegationManager contract. - * @param args.retryOptions - Optional retry configuration. When not provided, defaults to 1 retry attempt with 1000ms delay. - * @returns True if the delegation is disabled, false if it is confirmed to be enabled. - * @throws InvalidInputError if input parameters are invalid. - * @throws ChainDisconnectedError if the provider is on the wrong chain. - * @throws ResourceUnavailableError if the on-chain check fails and we cannot determine the status. - */ - public async checkDelegationDisabledOnChain({ - delegationHash, - chainId, - delegationManagerAddress, - retryOptions, - }: { - delegationHash: Hex; - chainId: Hex; - delegationManagerAddress: Hex; - retryOptions?: RetryOptions; - }): Promise { - logger.debug( - 'BlockchainTokenMetadataClient:checkDelegationDisabledOnChain()', - { - delegationHash, - chainId, - delegationManagerAddress, - }, - ); - - if (!delegationHash) { - const message = 'No delegation hash provided'; - logger.error(message); - throw new InvalidInputError(message); - } - - if (!chainId) { - const message = 'No chain ID provided'; - logger.error(message); - throw new InvalidInputError(message); - } - - if (!delegationManagerAddress) { - const message = 'No delegation manager address provided'; - logger.error(message); - throw new InvalidInputError(message); - } - - // Ensure we're on the correct chain - // This can throw ChainDisconnectedError - we want it to propagate - await ensureChain(this.#ethereumProvider, chainId); - - // Encode the function call data for disabledDelegations(bytes32) - const encodedParams = delegationHash.slice(2).padStart(64, '0'); // Remove 0x and pad to 32 bytes - const callData = - `${BlockchainTokenMetadataClient.#disabledDelegationsCalldata}${encodedParams}` as Hex; - - try { - const result = await callContract({ - ethereumProvider: this.#ethereumProvider, - contractAddress: delegationManagerAddress, - callData, - retryOptions, - isRetryableError: (error) => this.#isRetryableError(error), - }); - - // Parse the boolean result (32 bytes, last byte is the boolean value) - const isDisabled = - result !== - '0x0000000000000000000000000000000000000000000000000000000000000000'; - - logger.debug('Delegation disabled status result', { isDisabled }); - return isDisabled; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error( - `Failed to check delegation disabled status: ${errorMessage}`, - ); - - // Re-throw critical errors - they should propagate - if ( - error instanceof InvalidInputError || - error instanceof ChainDisconnectedError - ) { - throw error; - } - - // For other errors (network issues, contract call failures, etc.), - // we cannot determine the status, so throw an error instead of returning false - throw new ResourceUnavailableError( - `Unable to determine delegation disabled status: ${errorMessage}`, - ); - } - } } diff --git a/packages/gator-permissions-snap/src/clients/types.ts b/packages/gator-permissions-snap/src/clients/types.ts index 4b92d7fa..ce41fa2c 100644 --- a/packages/gator-permissions-snap/src/clients/types.ts +++ b/packages/gator-permissions-snap/src/clients/types.ts @@ -1,5 +1,7 @@ +import { zHexStr } from '@metamask/7715-permissions-shared/types'; import type { Hex } from '@metamask/delegation-core'; import type { CaipAssetType } from '@metamask/utils'; +import { z } from 'zod'; /** * Options for configuring retry behavior. @@ -96,6 +98,42 @@ export type TokenBalanceAndMetadata = { iconUrl?: string; }; +// Zod schema for runtime validation of TransactionReceipt +export const zTransactionReceipt = z.object({ + blockHash: zHexStr, + blockNumber: zHexStr, + contractAddress: zHexStr.nullable(), + cumulativeGasUsed: zHexStr, + effectiveGasPrice: zHexStr, + from: zHexStr, + gasUsed: zHexStr, + logs: z.array( + z.object({ + address: zHexStr, + blockHash: zHexStr, + blockNumber: zHexStr, + data: zHexStr, + logIndex: zHexStr, + removed: z.boolean(), + topics: z.array(zHexStr), + transactionHash: zHexStr, + transactionIndex: zHexStr, + }), + ), + logsBloom: zHexStr, + status: zHexStr, + to: zHexStr.nullable(), + transactionHash: zHexStr, + transactionIndex: zHexStr, + type: zHexStr, +}); + +/** + * Represents a transaction receipt from the blockchain. + * As defined in the Ethereum JSON-RPC API(https://docs.metamask.io/services/reference/ethereum/json-rpc-methods/eth_gettransactionreceipt/) + */ +export type TransactionReceipt = z.infer; + /** * Interface for token metadata clients that can fetch token balance and metadata */ diff --git a/packages/gator-permissions-snap/src/index.ts b/packages/gator-permissions-snap/src/index.ts index bf8a615e..8d486151 100644 --- a/packages/gator-permissions-snap/src/index.ts +++ b/packages/gator-permissions-snap/src/index.ts @@ -20,6 +20,7 @@ import type { } from '@metamask/snaps-sdk'; import { AccountApiClient } from './clients/accountApiClient'; +import { BlockchainClient } from './clients/blockchainClient'; import { BlockchainTokenMetadataClient } from './clients/blockchainMetadataClient'; import { NonceCaveatClient } from './clients/nonceCaveatClient'; import { PriceApiClient } from './clients/priceApiClient'; @@ -104,6 +105,10 @@ const accountController = new AccountController({ ethereumProvider: ethereum, }); +const blockchainClient = new BlockchainClient({ + ethereumProvider: ethereum, +}); + const stateManager = createStateManager(snap); const profileSyncOptions = createProfileSyncOptions(stateManager, snap); @@ -187,7 +192,7 @@ const permissionHandlerFactory = new PermissionHandlerFactory({ const rpcHandler = createRpcHandler({ permissionHandlerFactory, profileSyncManager, - blockchainMetadataClient: tokenMetadataClient, + blockchainClient, }); // configure RPC methods bindings diff --git a/packages/gator-permissions-snap/src/profileSync/profileSync.ts b/packages/gator-permissions-snap/src/profileSync/profileSync.ts index fbbeba10..07de3c82 100644 --- a/packages/gator-permissions-snap/src/profileSync/profileSync.ts +++ b/packages/gator-permissions-snap/src/profileSync/profileSync.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-restricted-globals */ import type { PermissionResponse } from '@metamask/7715-permissions-shared/types'; -import { zPermissionResponse } from '@metamask/7715-permissions-shared/types'; +import { + zHexStr, + zPermissionResponse, +} from '@metamask/7715-permissions-shared/types'; import { logger, extractZodError, @@ -26,6 +29,10 @@ import { z } from 'zod'; import type { SnapsMetricsService } from '../services/snapsMetricsService'; +export type RevocationMetadata = { + txHash?: Hex | undefined; +}; + // Constants for validation const MAX_STORAGE_SIZE_BYTES = 400 * 1024; // 400kb limit as documented @@ -34,6 +41,11 @@ const zStoredGrantedPermission = z.object({ permissionResponse: zPermissionResponse, siteOrigin: z.string().min(1, 'Site origin cannot be empty'), isRevoked: z.boolean().default(false), + revocationMetadata: z + .object({ + txHash: zHexStr.optional(), + }) + .optional(), }); /** @@ -106,6 +118,7 @@ export type ProfileSyncManager = { updatePermissionRevocationStatus: ( permissionContext: Hex, isRevoked: boolean, + revocationMetadata: RevocationMetadata, ) => Promise; }; @@ -113,6 +126,7 @@ export type StoredGrantedPermission = { permissionResponse: PermissionResponse; siteOrigin: string; isRevoked: boolean; + revocationMetadata?: RevocationMetadata | undefined; }; /** @@ -166,7 +180,11 @@ export function createProfileSyncManager( 'unConfiguredProfileSyncManager.storeGrantedPermissionBatch()', ); }, - updatePermissionRevocationStatus: async (_: Hex, __: boolean) => { + updatePermissionRevocationStatus: async ( + _: Hex, + __: boolean, + ___: RevocationMetadata, + ) => { logger.debug( 'unConfiguredProfileSyncManager.updatePermissionRevocationStatus()', ); @@ -354,15 +372,16 @@ export function createProfileSyncManager( } /** - * Updates the revocation status of a granted permission when you already have the permission object. - * This is an optimized version that avoids re-fetching the permission. + * Updates the revocation status of a granted permission. * * @param permissionContext - The context of the granted permission to update. * @param isRevoked - The new revocation status. + * @param revocationMetadata - The revocation transaction metadata. */ async function updatePermissionRevocationStatus( permissionContext: Hex, isRevoked: boolean, + revocationMetadata: RevocationMetadata, ): Promise { try { const existingPermission = await getGrantedPermission(permissionContext); @@ -376,6 +395,7 @@ export function createProfileSyncManager( logger.debug('Profile Sync: Updating permission revocation status:', { existingPermission, isRevoked, + revocationMetadata, }); await authenticate(); @@ -385,6 +405,10 @@ export function createProfileSyncManager( isRevoked, }; + isRevoked + ? (updatedPermission.revocationMetadata = revocationMetadata) + : (updatedPermission.revocationMetadata = undefined); + await storeGrantedPermission(updatedPermission); logger.debug('Profile Sync: Successfully stored updated permission'); } catch (error) { diff --git a/packages/gator-permissions-snap/src/rpc/rpcHandler.ts b/packages/gator-permissions-snap/src/rpc/rpcHandler.ts index c539a255..8d33977c 100644 --- a/packages/gator-permissions-snap/src/rpc/rpcHandler.ts +++ b/packages/gator-permissions-snap/src/rpc/rpcHandler.ts @@ -12,7 +12,7 @@ import { import type { Json } from '@metamask/snaps-sdk'; import { numberToHex } from '@metamask/utils'; -import type { BlockchainTokenMetadataClient } from '../clients/blockchainMetadataClient'; +import type { BlockchainClient } from '../clients/blockchainClient'; import { nameAndExplorerUrlByChainId } from '../core/chainMetadata'; import type { PermissionHandlerFactory } from '../core/permissionHandlerFactory'; import { DEFAULT_GATOR_PERMISSION_TO_OFFER } from '../permissions/permissionOffers'; @@ -75,17 +75,17 @@ export type RpcHandler = { * @param config - The parameters for creating the RPC handler. * @param config.permissionHandlerFactory - The factory for creating permission handlers. * @param config.profileSyncManager - The profile sync manager. - * @param config.blockchainMetadataClient - The blockchain metadata client for on-chain checks. + * @param config.blockchainClient - The blockchain client for on-chain checks. * @returns An object with RPC handler methods. */ export function createRpcHandler({ permissionHandlerFactory, profileSyncManager, - blockchainMetadataClient, + blockchainClient, }: { permissionHandlerFactory: PermissionHandlerFactory; profileSyncManager: ProfileSyncManager; - blockchainMetadataClient: BlockchainTokenMetadataClient; + blockchainClient: BlockchainClient; }): RpcHandler { /** * Handles grant permission requests. @@ -204,7 +204,8 @@ export function createRpcHandler({ const submitRevocation = async (params: Json): Promise => { logger.debug('submitRevocation() called with params:', params); - const { permissionContext } = validateRevocationParams(params); + const { permissionContext, revocationMetadata } = + validateRevocationParams(params); // First, get the existing permission to validate it exists logger.debug( @@ -254,7 +255,7 @@ export function createRpcHandler({ const delegationHash = hashDelegation(firstDelegation); const isDelegationDisabled = - await blockchainMetadataClient.checkDelegationDisabledOnChain({ + await blockchainClient.checkDelegationDisabledOnChain({ delegationHash, chainId: permissionChainId, delegationManagerAddress: delegationManager, @@ -266,9 +267,26 @@ export function createRpcHandler({ ); } + // Check if the transaction is confirmed on-chain + const { txHash } = revocationMetadata; + if (txHash) { + const isTransactionSuccessful = + await blockchainClient.checkTransactionReceipt({ + txHash, + chainId: permissionChainId, + }); + + if (!isTransactionSuccessful) { + throw new InvalidInputError( + `Transaction ${txHash} was not successful. Cannot process revocation.`, + ); + } + } + await profileSyncManager.updatePermissionRevocationStatus( permissionContext, true, + revocationMetadata, ); return { success: true }; diff --git a/packages/gator-permissions-snap/src/utils/blockchain.ts b/packages/gator-permissions-snap/src/utils/blockchain.ts index 8a62accf..0625e893 100644 --- a/packages/gator-permissions-snap/src/utils/blockchain.ts +++ b/packages/gator-permissions-snap/src/utils/blockchain.ts @@ -8,7 +8,8 @@ import { import { hexToNumber, numberToHex } from '@metamask/utils'; import { sleep } from './httpClient'; -import type { RetryOptions } from '../clients/types'; +import { validateTransactionReceipt } from './validate'; +import type { RetryOptions, TransactionReceipt } from '../clients/types'; /** * Ensures the ethereum provider is connected to the specified chain. @@ -119,3 +120,67 @@ export async function callContract({ throw new InternalError(`Contract call failed after ${retries + 1} attempts`); } + +/** + * Gets a transaction receipt from the blockchain with retry logic. + * Note: The caller is responsible for ensuring the provider is on the correct chain using `ensureChain`. + * + * @param args - The parameters for the transaction receipt. + * @param args.ethereumProvider - The ethereum provider to use for the call. + * @param args.txHash - The hash of the transaction to get the receipt for. + * @param args.retryOptions - Optional retry configuration. When not provided, defaults to 1 retry attempt with 1000ms delay. + * @param args.isRetryableError - Optional function to determine if an error is retryable. Defaults to retrying all errors except ChainDisconnectedError. + * @returns The transaction receipt. + * @throws ResourceNotFoundError if the transaction receipt is not found after all retries. + * @throws The original error if it's not retryable. + */ +export async function getTransactionReceipt({ + ethereumProvider, + txHash, + retryOptions, + isRetryableError, +}: { + ethereumProvider: SnapsEthereumProvider; + txHash: Hex; + retryOptions?: RetryOptions | undefined; + isRetryableError?: ((error: unknown) => boolean) | undefined; +}): Promise { + const { retries = 1, delayMs = 1000 } = retryOptions ?? {}; + const defaultIsRetryableError = (error: unknown): boolean => { + return !(error instanceof ChainDisconnectedError); + }; + const shouldRetry = isRetryableError ?? defaultIsRetryableError; + + // Try up to initial attempt + retry attempts + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const result = await ethereumProvider.request({ + method: 'eth_getTransactionReceipt', + params: [txHash], + }); + + if (!result) { + throw new ResourceNotFoundError('Transaction receipt not found'); + } + + return validateTransactionReceipt(result); + } catch (error) { + // Check if this is a retryable error + if (shouldRetry(error)) { + if (attempt < retries) { + await sleep(delayMs); + continue; + } + throw new ResourceNotFoundError( + 'Transaction receipt not found after retries', + ); + } + // Re-throw non-retryable errors immediately + throw error; + } + } + + throw new InternalError( + `Transaction receipt not found after ${retries + 1} attempts`, + ); +} diff --git a/packages/gator-permissions-snap/src/utils/validate.ts b/packages/gator-permissions-snap/src/utils/validate.ts index 271f11b6..d07c3cca 100644 --- a/packages/gator-permissions-snap/src/utils/validate.ts +++ b/packages/gator-permissions-snap/src/utils/validate.ts @@ -10,6 +10,10 @@ import type { Hex } from '@metamask/delegation-core'; import { InvalidInputError, type Json } from '@metamask/snaps-sdk'; import { z } from 'zod'; +import type { TransactionReceipt } from '../clients/types'; +import { zTransactionReceipt } from '../clients/types'; +import type { RevocationMetadata } from '../profileSync'; + export const validateGetGrantedPermissionsParams = ( params: unknown, ): GetGrantedPermissionsParam => { @@ -40,6 +44,9 @@ export const validatePermissionRequestParam = ( // Validation schema for revocation parameters const zRevocationParams = z.object({ permissionContext: zHexStr, + revocationMetadata: z.object({ + txHash: zHexStr.optional(), + }), }); /** @@ -50,17 +57,14 @@ const zRevocationParams = z.object({ */ export function validateRevocationParams(params: Json): { permissionContext: Hex; + revocationMetadata: RevocationMetadata; } { try { if (!params || typeof params !== 'object') { throw new InvalidInputError('Parameters are required'); } - const validated = zRevocationParams.parse(params); - - return { - permissionContext: validated.permissionContext, - }; + return zRevocationParams.parse(params); } catch (error) { if (error instanceof z.ZodError) { throw new InvalidInputError(extractZodError(error.errors)); @@ -68,3 +72,23 @@ export function validateRevocationParams(params: Json): { throw error; } } + +/** + * Validates the transaction receipt. + * @param transactionReceipt - The transaction receipt to validate. + * @returns The validated transaction receipt. + * @throws InvalidInputError if validation fails. + */ +export const validateTransactionReceipt = ( + transactionReceipt: unknown, +): TransactionReceipt => { + const validatedTransactionReceipt = + zTransactionReceipt.safeParse(transactionReceipt); + if (!validatedTransactionReceipt.success) { + throw new InvalidInputError( + extractZodError(validatedTransactionReceipt.error.errors), + ); + } + + return validatedTransactionReceipt.data; +}; diff --git a/packages/gator-permissions-snap/test/client/blockchainClient.test.ts b/packages/gator-permissions-snap/test/client/blockchainClient.test.ts new file mode 100644 index 00000000..91e53026 --- /dev/null +++ b/packages/gator-permissions-snap/test/client/blockchainClient.test.ts @@ -0,0 +1,453 @@ +import { + ChainDisconnectedError, + InvalidInputError, + ResourceUnavailableError, +} from '@metamask/snaps-sdk'; + +import { BlockchainClient } from '../../src/clients/blockchainClient'; +import type { TransactionReceipt } from '../../src/clients/types'; + +describe('BlockchainClient', () => { + const mockEthereumProvider = { + request: jest.fn(), + }; + let client = new BlockchainClient({ + ethereumProvider: mockEthereumProvider, + }); + + beforeEach(() => { + mockEthereumProvider.request.mockClear(); + mockEthereumProvider.request.mockReset(); + mockEthereumProvider.request.mockImplementation(() => { + throw new Error('Unexpected mock call'); + }); + client = new BlockchainClient({ + ethereumProvider: mockEthereumProvider, + }); + }); + + describe('checkDelegationDisabledOnChain', () => { + const mockDelegationHash = + '0x1234567890123456789012345678901234567890123456789012345678901234' as const; + const mockChainId = '0xaa36a7' as const; + const mockDelegationManagerAddress = + '0x1234567890123456789012345678901234567890'; + + beforeEach(() => { + mockEthereumProvider.request.mockClear(); + }); + + it('should return true when delegation is disabled on-chain', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // Result: 0x0000000000000000000000000000000000000000000000000000000000000001 (true) + mockEthereumProvider.request.mockResolvedValueOnce( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ); // disabledDelegations call + + const result = await client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }); + + expect(result).toBe(true); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should return false when delegation is not disabled on-chain', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // Result: 0x0000000000000000000000000000000000000000000000000000000000000000 (false) + mockEthereumProvider.request.mockResolvedValueOnce( + '0x0000000000000000000000000000000000000000000000000000000000000000', + ); // disabledDelegations call + + const result = await client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }); + + expect(result).toBe(false); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should throw InvalidInputError when delegationHash is missing', async () => { + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: '' as any, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow('No delegation hash provided'); + + expect(mockEthereumProvider.request).not.toHaveBeenCalled(); + }); + + it('should throw InvalidInputError when chainId is missing', async () => { + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: '' as any, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow('No chain ID provided'); + + expect(mockEthereumProvider.request).not.toHaveBeenCalled(); + }); + + it('should throw InvalidInputError when delegationManagerAddress is missing', async () => { + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: '' as any, + }), + ).rejects.toThrow('No delegation manager address provided'); + + expect(mockEthereumProvider.request).not.toHaveBeenCalled(); + }); + + it('should propagate ChainDisconnectedError from ensureChain', async () => { + mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Wrong chain + mockEthereumProvider.request.mockResolvedValueOnce('OK'); // switch chain + mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Still wrong chain + + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow('Selected chain does not match the requested chain'); + }); + + it('should throw ResourceUnavailableError when contract call fails after retries', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_call (attempt 1) + // Retry also fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_call (retry) + + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow(ResourceUnavailableError); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); + }); + + it('should throw ResourceUnavailableError when contract call returns null', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt returns null + mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_call (attempt 1) + // Retry also returns null + mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_call (retry) + + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow(ResourceUnavailableError); + }); + + it('should use custom retry options', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_call (attempt 1) + // Retry succeeds + mockEthereumProvider.request.mockResolvedValueOnce( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ); // eth_call (retry) + + const result = await client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + retryOptions: { + retries: 2, + delayMs: 500, + }, + }); + + expect(result).toBe(true); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); + }); + + it('should not retry on InvalidInputError from callContract', async () => { + const invalidInputError = new InvalidInputError('Invalid input'); + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockRejectedValueOnce(invalidInputError); // eth_call + + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow('Invalid input'); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should not retry on ChainDisconnectedError from callContract', async () => { + const chainDisconnectedError = new ChainDisconnectedError( + 'Chain disconnected', + ); + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockRejectedValueOnce( + chainDisconnectedError, + ); // eth_call + + await expect( + client.checkDelegationDisabledOnChain({ + delegationHash: mockDelegationHash, + chainId: mockChainId, + delegationManagerAddress: mockDelegationManagerAddress, + }), + ).rejects.toThrow('Chain disconnected'); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + }); + + describe('checkTransactionReceipt', () => { + const mockTxHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as const; + const mockChainId = '0xaa36a7' as const; + const mockTransactionReceipt: TransactionReceipt = { + blockHash: + '0x1234567890123456789012345678901234567890123456789012345678901234', + blockNumber: '0x0', + contractAddress: '0x1234567890123456789012345678901234567890', + cumulativeGasUsed: '0x1', + effectiveGasPrice: '0x1', + from: '0x1234567890123456789012345678901234567895', + gasUsed: + '0x1234567890123456789012345678901234567890123456789012345678901234', + status: '0x1', + transactionHash: mockTxHash, + transactionIndex: + '0x1234567890123456789012345678901234567890123456789012345678901234', + type: '0x2', + logs: [], + logsBloom: + '0x1234567890123456789012345678901234567890123456789012345678901234', + to: '0x1234567890123456789012345678901234567892', + }; + + beforeEach(() => { + mockEthereumProvider.request.mockClear(); + }); + + it('should return true when transaction receipt status is 0x1 (success)', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockResolvedValueOnce( + mockTransactionReceipt, + ); // eth_getTransactionReceipt + + const result = await client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }); + + expect(result).toBe(true); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + expect(mockEthereumProvider.request).toHaveBeenNthCalledWith(1, { + method: 'eth_chainId', + params: [], + }); + expect(mockEthereumProvider.request).toHaveBeenNthCalledWith(2, { + method: 'eth_getTransactionReceipt', + params: [mockTxHash], + }); + }); + + it('should return false when transaction receipt status is 0x0 (failure)', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockResolvedValueOnce({ + ...mockTransactionReceipt, + status: '0x0', + }); // eth_getTransactionReceipt + + const result = await client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }); + + expect(result).toBe(false); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should throw ResourceNotFoundError when transaction receipt is not found', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_getTransactionReceipt + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow('Transaction receipt not found'); + }); + + it('should throw InvalidInputError when chainId is missing', async () => { + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: '' as any, + }), + ).rejects.toThrow('No chain ID provided'); + + expect(mockEthereumProvider.request).not.toHaveBeenCalled(); + }); + + it('should throw InvalidInputError when transaction receipt schema is invalid', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockResolvedValueOnce({ + extraField: 'extraField', + }); // eth_getTransactionReceipt + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow( + 'Failed type validation: blockHash: Required, blockNumber: Required, contractAddress: Required, cumulativeGasUsed: Required, effectiveGasPrice: Required, from: Required, gasUsed: Required, logs: Required, logsBloom: Required, status: Required, to: Required, transactionHash: Required, transactionIndex: Required, type: Required', + ); + }); + + it('should propagate ChainDisconnectedError from ensureChain', async () => { + mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Wrong chain + mockEthereumProvider.request.mockResolvedValueOnce('OK'); // switch chain + mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Still wrong chain + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow('Selected chain does not match the requested chain'); + }); + + it('should switch chain if selected chain does not match requested chain', async () => { + mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Wrong chain + mockEthereumProvider.request.mockResolvedValueOnce('OK'); // switch chain + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // Correct chain after switch + mockEthereumProvider.request.mockResolvedValueOnce( + mockTransactionReceipt, + ); // eth_getTransactionReceipt + + const result = await client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }); + + expect(result).toBe(true); + expect(mockEthereumProvider.request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: mockChainId }], + }); + }); + + it('should throw ResourceUnavailableError when getTransactionReceipt fails after retries', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_getTransactionReceipt (attempt 1) + // Retry also fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_getTransactionReceipt (retry) + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow(ResourceUnavailableError); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); + }); + + it('should throw ResourceUnavailableError when transaction receipt is not found', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt returns null + mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_getTransactionReceipt (attempt 1) + // Retry also returns null + mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_getTransactionReceipt (retry) + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow(ResourceUnavailableError); + }); + + it('should not retry on InvalidInputError', async () => { + const invalidInputError = new InvalidInputError('Invalid input'); + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockRejectedValueOnce(invalidInputError); // eth_getTransactionReceipt + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow('Invalid input'); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should not retry on ChainDisconnectedError from getTransactionReceipt', async () => { + const chainDisconnectedError = new ChainDisconnectedError( + 'Chain disconnected', + ); + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + mockEthereumProvider.request.mockRejectedValueOnce( + chainDisconnectedError, + ); // eth_getTransactionReceipt + + await expect( + client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }), + ).rejects.toThrow('Chain disconnected'); + + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); + }); + + it('should retry on RPC error and succeed', async () => { + mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain + // First attempt fails + mockEthereumProvider.request.mockRejectedValueOnce( + new Error('RPC error'), + ); // eth_getTransactionReceipt (attempt 1) + // Retry succeeds + mockEthereumProvider.request.mockResolvedValueOnce( + mockTransactionReceipt, + ); // eth_getTransactionReceipt (retry) + + const result = await client.checkTransactionReceipt({ + txHash: mockTxHash, + chainId: mockChainId, + }); + + expect(result).toBe(true); + expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/gator-permissions-snap/test/client/blockchainMetadataClient.test.ts b/packages/gator-permissions-snap/test/client/blockchainMetadataClient.test.ts index b35a40e0..ba379d15 100644 --- a/packages/gator-permissions-snap/test/client/blockchainMetadataClient.test.ts +++ b/packages/gator-permissions-snap/test/client/blockchainMetadataClient.test.ts @@ -1,8 +1,4 @@ -import { - ChainDisconnectedError, - InvalidInputError, - ResourceUnavailableError, -} from '@metamask/snaps-sdk'; +import { ChainDisconnectedError, InvalidInputError } from '@metamask/snaps-sdk'; import { numberToHex } from '@metamask/utils'; import { BlockchainTokenMetadataClient } from '../../src/clients/blockchainMetadataClient'; @@ -477,199 +473,4 @@ describe('BlockchainTokenMetadataClient', () => { }); }); }); - - describe('checkDelegationDisabledOnChain', () => { - const mockDelegationHash = - '0x1234567890123456789012345678901234567890123456789012345678901234' as const; - const mockChainId = '0xaa36a7' as const; - const mockDelegationManagerAddress = - '0x1234567890123456789012345678901234567890'; - - beforeEach(() => { - mockEthereumProvider.request.mockClear(); - }); - - it('should return true when delegation is disabled on-chain', async () => { - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - // Result: 0x0000000000000000000000000000000000000000000000000000000000000001 (true) - mockEthereumProvider.request.mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // disabledDelegations call - - const result = await client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }); - - expect(result).toBe(true); - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); - }); - - it('should return false when delegation is not disabled on-chain', async () => { - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - // Result: 0x0000000000000000000000000000000000000000000000000000000000000000 (false) - mockEthereumProvider.request.mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000000', - ); // disabledDelegations call - - const result = await client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }); - - expect(result).toBe(false); - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); - }); - - it('should throw InvalidInputError when delegationHash is missing', async () => { - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: '' as any, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow('No delegation hash provided'); - - expect(mockEthereumProvider.request).not.toHaveBeenCalled(); - }); - - it('should throw InvalidInputError when chainId is missing', async () => { - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: '' as any, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow('No chain ID provided'); - - expect(mockEthereumProvider.request).not.toHaveBeenCalled(); - }); - - it('should throw InvalidInputError when delegationManagerAddress is missing', async () => { - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: '' as any, - }), - ).rejects.toThrow('No delegation manager address provided'); - - expect(mockEthereumProvider.request).not.toHaveBeenCalled(); - }); - - it('should propagate ChainDisconnectedError from ensureChain', async () => { - mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Wrong chain - mockEthereumProvider.request.mockResolvedValueOnce('OK'); // switch chain - mockEthereumProvider.request.mockResolvedValueOnce('0x1'); // Still wrong chain - - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow('Selected chain does not match the requested chain'); - }); - - it('should throw ResourceUnavailableError when contract call fails after retries', async () => { - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - // First attempt fails - mockEthereumProvider.request.mockRejectedValueOnce( - new Error('RPC error'), - ); // eth_call (attempt 1) - // Retry also fails - mockEthereumProvider.request.mockRejectedValueOnce( - new Error('RPC error'), - ); // eth_call (retry) - - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow(ResourceUnavailableError); - - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); - }); - - it('should throw ResourceUnavailableError when contract call returns null', async () => { - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - // First attempt returns null - mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_call (attempt 1) - // Retry also returns null - mockEthereumProvider.request.mockResolvedValueOnce(null); // eth_call (retry) - - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow(ResourceUnavailableError); - }); - - it('should use custom retry options', async () => { - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - // First attempt fails - mockEthereumProvider.request.mockRejectedValueOnce( - new Error('RPC error'), - ); // eth_call (attempt 1) - // Retry succeeds - mockEthereumProvider.request.mockResolvedValueOnce( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ); // eth_call (retry) - - const result = await client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - retryOptions: { - retries: 2, - delayMs: 500, - }, - }); - - expect(result).toBe(true); - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(3); - }); - - it('should not retry on InvalidInputError from callContract', async () => { - const invalidInputError = new InvalidInputError('Invalid input'); - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - mockEthereumProvider.request.mockRejectedValueOnce(invalidInputError); // eth_call - - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow('Invalid input'); - - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); - }); - - it('should not retry on ChainDisconnectedError from callContract', async () => { - const chainDisconnectedError = new ChainDisconnectedError( - 'Chain disconnected', - ); - mockEthereumProvider.request.mockResolvedValueOnce(mockChainId); // eth_chainId from ensureChain - mockEthereumProvider.request.mockRejectedValueOnce( - chainDisconnectedError, - ); // eth_call - - await expect( - client.checkDelegationDisabledOnChain({ - delegationHash: mockDelegationHash, - chainId: mockChainId, - delegationManagerAddress: mockDelegationManagerAddress, - }), - ).rejects.toThrow('Chain disconnected'); - - expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2); - }); - }); }); diff --git a/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts b/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts index aec9089b..94a833b6 100644 --- a/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts +++ b/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts @@ -469,7 +469,8 @@ describe('profileSync', () => { describe('updatePermissionRevocationStatus', () => { it('should update permission revocation status successfully', async () => { - // Mock getting the existing permission + const mockTxHash = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; userStorageMock.getItem.mockResolvedValueOnce( JSON.stringify(mockStoredGrantedPermission), ); @@ -478,27 +479,28 @@ describe('profileSync', () => { await profileSyncManager.updatePermissionRevocationStatus( mockStoredGrantedPermission.permissionResponse.context, true, + { + txHash: mockTxHash, + }, ); - // Should first get the existing permission expect(userStorageMock.getItem).toHaveBeenCalledWith( `gator_7715_permissions.${mockDelegationHash}`, ); - // Should then store the updated permission with isRevoked=true expect(userStorageMock.setItem).toHaveBeenCalledWith( `gator_7715_permissions.${mockDelegationHash}`, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":true\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":true,"revocationMetadata":\{"txHash":"0x[a-f0-9]+"\}\}$/u, ), ); - // Verify the stored data has isRevoked=true const storedData = userStorageMock.setItem.mock.calls[0]?.[1]; expect(storedData).toBeDefined(); const parsed = JSON.parse(storedData as string); expect(parsed.isRevoked).toBe(true); expect(parsed.siteOrigin).toBe('https://example.com'); + expect(parsed.revocationMetadata.txHash).toBe(mockTxHash); }); it('should set permission revocation status to false', async () => { @@ -517,6 +519,7 @@ describe('profileSync', () => { await profileSyncManager.updatePermissionRevocationStatus( mockStoredGrantedPermission.permissionResponse.context, false, + {}, ); // Should store the updated permission with isRevoked=false @@ -552,6 +555,9 @@ describe('profileSync', () => { profileSyncManager.updatePermissionRevocationStatus( nonExistentDelegationHash, true, + { + txHash: '0xMocked-tx-hash', + }, ), ).rejects.toThrow( `Permission not found for permission context: ${nonExistentDelegationHash}`, @@ -575,6 +581,9 @@ describe('profileSync', () => { profileSyncManager.updatePermissionRevocationStatus( mockStoredGrantedPermission.permissionResponse.context, true, + { + txHash: '0xMocked-tx-hash', + }, ), ).rejects.toThrow('Auth failed'); }); @@ -632,6 +641,9 @@ describe('profileSync', () => { await profileSyncManager.updatePermissionRevocationStatus( mockStoredGrantedPermission.permissionResponse.context, true, + { + txHash: '0xMocked-tx-hash', + }, ); expect(userStorageMock.getItem).not.toHaveBeenCalled(); expect(userStorageMock.setItem).not.toHaveBeenCalled(); diff --git a/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts b/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts index 0a4157a6..8310999e 100644 --- a/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts +++ b/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts @@ -3,7 +3,11 @@ import type { PermissionRequest, PermissionResponse, } from '@metamask/7715-permissions-shared/types'; -import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; +import { + decodeDelegations, + hashDelegation, + type Hex, +} from '@metamask/delegation-core'; import { ChainDisconnectedError, InvalidInputError, @@ -11,10 +15,13 @@ import { type Json, } from '@metamask/snaps-sdk'; -import type { BlockchainTokenMetadataClient } from '../../src/clients/blockchainMetadataClient'; +import type { BlockchainClient } from '../../src/clients/blockchainClient'; import type { PermissionHandlerFactory } from '../../src/core/permissionHandlerFactory'; import type { PermissionHandlerType } from '../../src/core/types'; -import type { ProfileSyncManager } from '../../src/profileSync'; +import type { + ProfileSyncManager, + StoredGrantedPermission, +} from '../../src/profileSync'; import { createRpcHandler, type RpcHandler } from '../../src/rpc/rpcHandler'; // Mock the delegation-core functions @@ -28,6 +35,8 @@ const TEST_SITE_ORIGIN = 'https://example.com'; const TEST_CHAIN_ID = '0x1' as const; const TEST_EXPIRY = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now const TEST_CONTEXT = '0xabcd' as const; +const TEST_VALID_TX_HASH = + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hex; const VALID_PERMISSION_REQUEST: PermissionRequest = { chainId: TEST_CHAIN_ID, @@ -85,7 +94,7 @@ describe('RpcHandler', () => { let mockHandler: jest.Mocked; let mockHandlerFactory: jest.Mocked; let mockProfileSyncManager: jest.Mocked; - let mockBlockchainMetadataClient: jest.Mocked; + let mockBlockchainClient: jest.Mocked; beforeEach(() => { // Reset mocks @@ -122,15 +131,15 @@ describe('RpcHandler', () => { updatePermissionRevocationStatus: jest.fn(), } as unknown as jest.Mocked; - mockBlockchainMetadataClient = { + mockBlockchainClient = { checkDelegationDisabledOnChain: jest.fn(), - getTokenBalanceAndMetadata: jest.fn(), - } as unknown as jest.Mocked; + checkTransactionReceipt: jest.fn(), + } as unknown as jest.Mocked; handler = createRpcHandler({ permissionHandlerFactory: mockHandlerFactory, profileSyncManager: mockProfileSyncManager, - blockchainMetadataClient: mockBlockchainMetadataClient, + blockchainClient: mockBlockchainClient, }); }); @@ -588,7 +597,7 @@ describe('RpcHandler', () => { describe('getGrantedPermissions', () => { it('should return all granted permissions successfully', async () => { - const mockGrantedPermissions = [ + const mockGrantedPermissions: StoredGrantedPermission[] = [ { permissionResponse: { chainId: TEST_CHAIN_ID, @@ -616,6 +625,14 @@ describe('RpcHandler', () => { { permissionResponse: { chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY + 1000, + }, + }, + ], to: '0x0987654321098765432109876543210987654321' as const, permission: { type: 'different-permission', @@ -719,6 +736,9 @@ describe('RpcHandler', () => { }, siteOrigin: 'https://another-example.com', isRevoked: true, + revocationMetadata: { + txHash: TEST_VALID_TX_HASH, + }, }, { permissionResponse: { @@ -969,6 +989,7 @@ describe('RpcHandler', () => { describe('submitRevocation', () => { const validRevocationParams = { permissionContext: TEST_CONTEXT, + revocationMetadata: { txHash: TEST_VALID_TX_HASH }, }; it('should successfully submit revocation with valid parameters', async () => { @@ -1000,9 +1021,10 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( true, ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(true); const result = await handler.submitRevocation(validRevocationParams); @@ -1011,11 +1033,54 @@ describe('RpcHandler', () => { validRevocationParams.permissionContext, ); expect( - mockBlockchainMetadataClient.checkDelegationDisabledOnChain, + mockBlockchainClient.checkDelegationDisabledOnChain, ).toHaveBeenCalled(); expect( mockProfileSyncManager.updatePermissionRevocationStatus, - ).toHaveBeenCalledWith(TEST_CONTEXT, true); + ).toHaveBeenCalledWith(TEST_CONTEXT, true, { + txHash: TEST_VALID_TX_HASH, + }); + }); + + it('should throw InvalidInputError when revocation Metadata transaction hash is invalid', async () => { + const mockPermission = { + permissionResponse: { + chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + }, + ], + to: TEST_ADDRESS, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencies: [], + delegationManager: TEST_ADDRESS, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }; + + mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( + mockPermission, + ); + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + true, + ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(false); + + await expect( + handler.submitRevocation(validRevocationParams), + ).rejects.toThrow( + `Transaction ${TEST_VALID_TX_HASH} was not successful. Cannot process revocation.`, + ); }); it('should throw InvalidInputError when permissionContext is invalid', async () => { @@ -1096,12 +1161,13 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( true, ); mockProfileSyncManager.updatePermissionRevocationStatus.mockRejectedValueOnce( profileSyncError, ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(true); await expect( handler.submitRevocation(validRevocationParams), @@ -1109,12 +1175,17 @@ describe('RpcHandler', () => { expect( mockProfileSyncManager.updatePermissionRevocationStatus, - ).toHaveBeenCalledWith(TEST_CONTEXT, true); + ).toHaveBeenCalledWith(TEST_CONTEXT, true, { + txHash: TEST_VALID_TX_HASH, + }); }); it('should handle hex values with uppercase letters', async () => { const upperCaseParams = { permissionContext: '0x1234567890ABCDEF1234567890ABCDEF', + revocationMetadata: { + txHash: TEST_VALID_TX_HASH, + }, }; const mockPermission = { @@ -1145,12 +1216,13 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( true, ); mockProfileSyncManager.updatePermissionRevocationStatus.mockResolvedValueOnce( undefined, ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(true); const result = await handler.submitRevocation(upperCaseParams); @@ -1193,12 +1265,13 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( true, ); mockProfileSyncManager.updatePermissionRevocationStatus.mockResolvedValueOnce( undefined, ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(true); const result = await handler.submitRevocation(testParams); @@ -1237,16 +1310,17 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockResolvedValueOnce( false, ); + mockBlockchainClient.checkTransactionReceipt.mockResolvedValueOnce(true); await expect( handler.submitRevocation(validRevocationParams), ).rejects.toThrow('is not disabled on-chain'); expect( - mockBlockchainMetadataClient.checkDelegationDisabledOnChain, + mockBlockchainClient.checkDelegationDisabledOnChain, ).toHaveBeenCalled(); expect( mockProfileSyncManager.updatePermissionRevocationStatus, @@ -1286,7 +1360,7 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( resourceUnavailableError, ); @@ -1295,7 +1369,7 @@ describe('RpcHandler', () => { ).rejects.toThrow('Unable to determine delegation disabled status'); expect( - mockBlockchainMetadataClient.checkDelegationDisabledOnChain, + mockBlockchainClient.checkDelegationDisabledOnChain, ).toHaveBeenCalled(); expect( mockProfileSyncManager.updatePermissionRevocationStatus, @@ -1335,7 +1409,7 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( chainDisconnectedError, ); @@ -1344,7 +1418,7 @@ describe('RpcHandler', () => { ).rejects.toThrow('Selected chain does not match the requested chain'); expect( - mockBlockchainMetadataClient.checkDelegationDisabledOnChain, + mockBlockchainClient.checkDelegationDisabledOnChain, ).toHaveBeenCalled(); expect( mockProfileSyncManager.updatePermissionRevocationStatus, @@ -1384,7 +1458,7 @@ describe('RpcHandler', () => { mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( mockPermission, ); - mockBlockchainMetadataClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( + mockBlockchainClient.checkDelegationDisabledOnChain.mockRejectedValueOnce( invalidInputError, ); @@ -1393,7 +1467,7 @@ describe('RpcHandler', () => { ).rejects.toThrow('No delegation hash provided'); expect( - mockBlockchainMetadataClient.checkDelegationDisabledOnChain, + mockBlockchainClient.checkDelegationDisabledOnChain, ).toHaveBeenCalled(); expect( mockProfileSyncManager.updatePermissionRevocationStatus,