Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00f9018
Extend StoredGrantedPermission in profile sync to allow storing revoc…
V00D00-child Nov 12, 2025
7bae408
Merge branch 'main' of github.com:MetaMask/snap-7715-permissions into…
V00D00-child Dec 8, 2025
0fda362
Wire up fetching a transaction receipt given a transaction hash
V00D00-child Dec 9, 2025
81e96d4
Expand revocation metadata to have a blockTimestamp value
V00D00-child Dec 15, 2025
f60ca10
Ensure revocation transaction has not failed before storing metadata …
V00D00-child Dec 17, 2025
7b1ce40
Update comments
V00D00-child Dec 19, 2025
637de8c
Add test for checkTransactionReceipt function
V00D00-child Dec 19, 2025
632169f
Revocation metadata now defaults to undefined
V00D00-child Dec 19, 2025
9b306d2
Make revocationMetadata required on StoredGrantedPermission type, but…
V00D00-child Jan 5, 2026
b89a9ca
Update StoredGrantedPermission zod scheme to have a default for revoc…
V00D00-child Jan 5, 2026
3fb85d4
Add Zod schema for runtime validation of TransactionReceipt. Move del…
V00D00-child Jan 6, 2026
81e61df
Bugfix: Unconfigured profile sync manager missing third parameter
V00D00-child Jan 6, 2026
7093480
Rename BlockchainMetadataClient to BlockchainClient. Make revocationM…
V00D00-child Jan 9, 2026
5dbb6e6
Add missing txHash validation in checkTransactionReceipt method
V00D00-child Jan 9, 2026
9bde018
Merge branch 'main' into feat/store-metadata-when-revoking-permission
V00D00-child Jan 16, 2026
cd86102
Fix failing test
V00D00-child Jan 16, 2026
3e7d238
Update revocation metadata and processing
jeffsmale90 Jan 21, 2026
513dd16
Merge branch 'main' into feat/store-metadata-when-revoking-permission
jeffsmale90 Jan 21, 2026
c42eb7b
Fix linting errors - just upgraded linting rules
jeffsmale90 Jan 21, 2026
4d2e396
Minor fixes from review:
jeffsmale90 Jan 21, 2026
bab8e38
Update blockchainClient to reference the correct classname in log sta…
jeffsmale90 Jan 21, 2026
c16a254
Fix linting and test that was incorrectly passing revocationMetadata
jeffsmale90 Jan 21, 2026
ca86387
Fix invalid params shape in test
jeffsmale90 Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import type {
TokenBalanceAndMetadata,
TokenMetadataClient,
} from './types';
import { callContract, ensureChain } from '../utils/blockchain';
import {
callContract,
ensureChain,
getTransactionReceipt,
} from '../utils/blockchain';
import { sleep } from '../utils/httpClient';

/**
Expand Down Expand Up @@ -327,4 +331,68 @@ export class BlockchainTokenMetadataClient implements TokenMetadataClient {
);
}
}

/**
* Checks if a transaction receipt is valid 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 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 checkTransactionReceipt({
txHash,
chainId,
}: {
txHash: Hex;
chainId: Hex;
}): Promise<boolean> {
logger.debug('BlockchainTokenMetadataClient:checkTransactionReceipt()', {
txHash,
chainId,
});

if (!chainId) {
const message = 'No chain ID 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),
});

// Either 1 (success) or 0 (failure)
if (result.status === '0x0') {
return false;
}

return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(`Failed to check 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 check transaction receipt: ${errorMessage}`,
);
}
}
}
31 changes: 31 additions & 0 deletions packages/gator-permissions-snap/src/clients/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,37 @@ export type TokenBalanceAndMetadata = {
iconUrl?: string;
};

/**
* Represents a transaction receipt from the blockchain.
* As defined in the Ethereum JSON-RPC API(https://docs.metamask.io/services/reference/zksync/json-rpc-methods/eth_gettransactionreceipt/)
*/
export type TransactionReceipt = {
blockHash: Hex;
blockNumber: Hex;
contractAddress: Hex | null;
cumulativeGasUsed: Hex;
effectiveGasPrice: Hex;
from: Hex;
gasUsed: Hex;
logs: {
address: Hex;
blockHash: Hex;
blockNumber: Hex;
data: Hex;
logIndex: Hex;
removed: boolean;
topics: Hex[];
transactionHash: Hex;
transactionIndex: Hex;
}[];
logsBloom: Hex;
status: Hex;
to: Hex;
transactionHash: Hex;
transactionIndex: Hex;
type: Hex;
};

/**
* Interface for token metadata clients that can fetch token balance and metadata
*/
Expand Down
18 changes: 18 additions & 0 deletions packages/gator-permissions-snap/src/profileSync/profileSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import { z } from 'zod';

import type { SnapsMetricsService } from '../services/snapsMetricsService';

export type RevocationMetadata = {
txHash: Hex;
blockTimestamp: string;
};

// Constants for validation
const MAX_STORAGE_SIZE_BYTES = 400 * 1024; // 400kb limit as documented

Expand All @@ -34,6 +39,9 @@ const zStoredGrantedPermission = z.object({
permissionResponse: zPermissionResponse,
siteOrigin: z.string().min(1, 'Site origin cannot be empty'),
isRevoked: z.boolean().default(false),
metadata: z
.custom<RevocationMetadata>()
.default({ txHash: '0x', blockTimestamp: '' }),
});

/**
Expand Down Expand Up @@ -106,13 +114,15 @@ export type ProfileSyncManager = {
updatePermissionRevocationStatus: (
permissionContext: Hex,
isRevoked: boolean,
metadata?: RevocationMetadata,
) => Promise<void>;
};

export type StoredGrantedPermission = {
permissionResponse: PermissionResponse;
siteOrigin: string;
isRevoked: boolean;
metadata?: RevocationMetadata;
};

/**
Expand Down Expand Up @@ -359,10 +369,12 @@ export function createProfileSyncManager(
*
* @param permissionContext - The context of the granted permission to update.
* @param isRevoked - The new revocation status.
* @param metadata - The revocation transaction metadata.
*/
async function updatePermissionRevocationStatus(
permissionContext: Hex,
isRevoked: boolean,
metadata?: RevocationMetadata,
): Promise<void> {
try {
const existingPermission = await getGrantedPermission(permissionContext);
Expand All @@ -376,6 +388,7 @@ export function createProfileSyncManager(
logger.debug('Profile Sync: Updating permission revocation status:', {
existingPermission,
isRevoked,
metadata,
});

await authenticate();
Expand All @@ -385,6 +398,11 @@ export function createProfileSyncManager(
isRevoked,
};

// Attach metadata when a transaction hash is given
if (metadata) {
updatedPermission.metadata = metadata;
}

await storeGrantedPermission(updatedPermission);
logger.debug('Profile Sync: Successfully stored updated permission');
} catch (error) {
Expand Down
19 changes: 18 additions & 1 deletion packages/gator-permissions-snap/src/rpc/rpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function createRpcHandler({
const submitRevocation = async (params: Json): Promise<Json> => {
logger.debug('submitRevocation() called with params:', params);

const { permissionContext } = validateRevocationParams(params);
const { permissionContext, metadata } = validateRevocationParams(params);

// First, get the existing permission to validate it exists
logger.debug(
Expand Down Expand Up @@ -255,9 +255,26 @@ export function createRpcHandler({
);
}

// Check if the transaction have if confirmed on-chain
if (metadata) {
const { txHash } = metadata;
const isTransactionValid =
await blockchainMetadataClient.checkTransactionReceipt({
txHash,
chainId: permissionChainId,
});

if (!isTransactionValid) {
throw new InvalidInputError(
`Transaction ${txHash} is not valid. Cannot process revocation.`,
);
}
}

await profileSyncManager.updatePermissionRevocationStatus(
permissionContext,
true,
metadata,
);

return { success: true };
Expand Down
66 changes: 65 additions & 1 deletion packages/gator-permissions-snap/src/utils/blockchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { hexToNumber, numberToHex } from '@metamask/utils';

import { sleep } from './httpClient';
import type { RetryOptions } from '../clients/types';
import type { RetryOptions, TransactionReceipt } from '../clients/types';

/**
* Ensures the ethereum provider is connected to the specified chain.
Expand Down Expand Up @@ -119,3 +119,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<TransactionReceipt> {
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<TransactionReceipt>({
method: 'eth_getTransactionReceipt',
params: [txHash],
});

if (!result) {
throw new ResourceNotFoundError('Transaction receipt not found');
}

return result as TransactionReceipt;
} 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`,
);
}
4 changes: 4 additions & 0 deletions packages/gator-permissions-snap/src/utils/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { extractZodError } from '@metamask/7715-permissions-shared/utils';
import type { Hex } from '@metamask/delegation-core';
import { InvalidInputError, type Json } from '@metamask/snaps-sdk';
import type { RevocationMetadata } from 'src/profileSync';
import { z } from 'zod';

export const validateGetGrantedPermissionsParams = (
Expand Down Expand Up @@ -40,6 +41,7 @@ export const validatePermissionRequestParam = (
// Validation schema for revocation parameters
const zRevocationParams = z.object({
permissionContext: zHexStr,
metadata: z.custom<RevocationMetadata>(),
});

/**
Expand All @@ -50,6 +52,7 @@ const zRevocationParams = z.object({
*/
export function validateRevocationParams(params: Json): {
permissionContext: Hex;
metadata?: RevocationMetadata;
} {
try {
if (!params || typeof params !== 'object') {
Expand All @@ -60,6 +63,7 @@ export function validateRevocationParams(params: Json): {

return {
permissionContext: validated.permissionContext,
metadata: validated.metadata,
};
} catch (error) {
if (error instanceof z.ZodError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,4 +672,6 @@ describe('BlockchainTokenMetadataClient', () => {
expect(mockEthereumProvider.request).toHaveBeenCalledTimes(2);
});
});

// TODO: Add test cases for checkTransactionReceipt
});
Loading
Loading