Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
215 changes: 215 additions & 0 deletions packages/gator-permissions-snap/src/clients/blockchainClient.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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}`,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
InvalidInputError,
InternalError,
ResourceNotFoundError,
ResourceUnavailableError,
type SnapsEthereumProvider,
} from '@metamask/snaps-sdk';

Expand Down Expand Up @@ -40,9 +39,6 @@ export class BlockchainTokenMetadataClient implements TokenMetadataClient {
// keccak256('symbol()')
static readonly #symbolCalldata = '0x95d89b41';

// keccak256('disabledDelegations(bytes32)')
static readonly #disabledDelegationsCalldata = '0x2d40d052';

constructor({
ethereumProvider,
}: {
Expand Down Expand Up @@ -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<boolean> {
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}`,
);
}
}
}
38 changes: 38 additions & 0 deletions packages/gator-permissions-snap/src/clients/types.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<typeof zTransactionReceipt>;

/**
* Interface for token metadata clients that can fetch token balance and metadata
*/
Expand Down
Loading
Loading