Skip to content
78 changes: 78 additions & 0 deletions packages/gator-permissions-snap/src/core/accountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger } from '@metamask/7715-permissions-shared/utils';
import { type Hex, type Delegation } from '@metamask/delegation-core';
import {
ChainDisconnectedError,
InternalError,
ResourceNotFoundError,
ResourceUnavailableError,
type SnapsEthereumProvider,
Expand All @@ -12,6 +13,19 @@ import { bigIntToHex, hexToNumber, numberToHex } from '@metamask/utils';
import { getChainMetadata } from './chainMetadata';
import type { SignDelegationOptions } from './types';

export type AccountUpgradeStatus = {
isUpgraded: boolean;
};

export type AccountUpgradeResult = {
transactionHash: string;
};

export type AccountUpgradeParams = {
account: string;
chainId: number;
};

/**
* Controls EOA account operations including address retrieval, delegation signing, and balance queries.
*/
Expand Down Expand Up @@ -168,4 +182,68 @@ export class AccountController {

return { domain, types, primaryType, message, metadata };
}

/**
* Checks if the account is already upgraded to a smart account.
* @param params - The account and chain ID to check.
* @returns Promise resolving to the upgrade status.
*/
public async getAccountUpgradeStatus(
params: AccountUpgradeParams,
): Promise<AccountUpgradeStatus> {
logger.debug('AccountController:getAccountUpgradeStatus()', params);

try {
const result = await this.#ethereumProvider.request({
method: 'wallet_getAccountUpgradeStatus',
params: [params],
});

logger.debug('Account upgrade status result', result);

return {
isUpgraded: (result as { isUpgraded?: boolean })?.isUpgraded ?? false,
};
} catch (error) {
logger.error('Failed to check account upgrade status', error);
throw new InternalError('Failed to check account upgrade status');
}
}

/**
* Upgrades the account to a smart account.
* @param params - The account and chain ID to upgrade.
* @returns Promise resolving to the upgrade result with transaction hash.
*/
public async upgradeAccount(
params: AccountUpgradeParams,
): Promise<AccountUpgradeResult> {
logger.debug('AccountController:upgradeAccount()', params);

try {
const result = await this.#ethereumProvider.request({
method: 'wallet_upgradeAccount',
params: [params],
});

logger.debug('Account upgrade result', result);

// The result should contain a transaction hash
if (
typeof result === 'object' &&
result !== null &&
'transactionHash' in result
) {
return {
transactionHash: (result as { transactionHash: string })
.transactionHash,
};
}

throw new Error('Invalid upgrade result: missing transaction hash');
} catch (error) {
logger.error('Failed to upgrade account', error);
throw new InternalError('Failed to upgrade account');
}
}
}
10 changes: 10 additions & 0 deletions packages/gator-permissions-snap/src/core/permissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,17 @@ export class PermissionHandler<
const {
justification,
tokenMetadata: { symbol: tokenSymbol },
accountAddressCaip10,
} = context;

// Check account upgrade status
const { address } = parseCaipAccountId(accountAddressCaip10);
const accountUpgradeStatus =
await this.#accountController.getAccountUpgradeStatus({
account: address,
chainId,
});

return PermissionHandlerContent({
origin,
justification,
Expand All @@ -227,6 +236,7 @@ export class PermissionHandler<
tokenBalanceFiat: this.#tokenBalanceFiat,
chainId,
explorerUrl,
isAccountUpgraded: accountUpgradeStatus.isUpgraded,
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type PermissionHandlerContentProps = {
tokenBalanceFiat: string | null;
chainId: number;
explorerUrl: string;
isAccountUpgraded: boolean;
};

/**
Expand All @@ -65,6 +66,7 @@ export type PermissionHandlerContentProps = {
* @param options.tokenBalanceFiat - The formatted fiat balance of the token.
* @param options.chainId - The chain ID of the network.
* @param options.explorerUrl - The URL of the block explorer for the token.
* @param options.isAccountUpgraded - Whether the account is upgraded to a smart account.
* @returns The confirmation content.
*/
export const PermissionHandlerContent = ({
Expand All @@ -81,8 +83,11 @@ export const PermissionHandlerContent = ({
tokenBalanceFiat,
chainId,
explorerUrl,
isAccountUpgraded,
}: PermissionHandlerContentProps): SnapElement => {
const tokenBalanceComponent = TokenBalanceField({ tokenBalance });
const tokenBalanceComponent = TokenBalanceField({
tokenBalance: tokenBalance ?? undefined,
});

const fiatBalanceComponent = tokenBalanceFiat ? (
<Text>{tokenBalanceFiat}</Text>
Expand Down Expand Up @@ -152,6 +157,12 @@ export const PermissionHandlerContent = ({
switchGlobalAccount={false}
value={context.accountAddressCaip10}
/>
{!isAccountUpgraded && (
<Text size="sm" color="warning">
This account will be upgraded to a smart account to complete
this permission.
</Text>
)}
<Box direction="horizontal" alignment="end">
{fiatBalanceComponent}
{tokenBalanceComponent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
bytesToHex,
hexToNumber,
numberToHex,
parseCaipAccountId,
} from '@metamask/utils';
import type { NonceCaveatService } from 'src/services/nonceCaveatService';

Expand Down Expand Up @@ -246,6 +247,24 @@ export class PermissionRequestLifecycleOrchestrator {
// all user input has been processed
await this.#userEventDispatcher.waitForPendingHandlers();

// Check if account needs to be upgraded before processing the permission
// We check again because the account could have been upgraded in the time since permission request was created
// especially if we consider a scenario where we have a permission batch with the same account.
const { address } = parseCaipAccountId(context.accountAddressCaip10);
const upgradeStatus =
await this.#accountController.getAccountUpgradeStatus({
account: address,
chainId,
});

if (!upgradeStatus.isUpgraded) {
// Trigger account upgrade
await this.#accountController.upgradeAccount({
account: address,
chainId,
});
}

const response = await this.#resolveResponse({
originalRequest: validatedPermissionRequest,
modifiedContext: context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,40 @@ describe('AccountController', () => {
});
});

describe('getAccountUpgradeStatus()', () => {
it('should return upgrade status', async () => {
mockEthereumProvider.request.mockResolvedValueOnce({
isUpgraded: true,
});

const result = await accountController.getAccountUpgradeStatus({
account: mockAddress,
chainId: sepolia,
});

expect(result).toStrictEqual({ isUpgraded: true });
});
});

describe('upgradeAccount()', () => {
it('should upgrade account and return transaction hash', async () => {
const mockTransactionHash =
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
mockEthereumProvider.request.mockResolvedValueOnce({
transactionHash: mockTransactionHash,
});

const result = await accountController.upgradeAccount({
account: mockAddress,
chainId: sepolia,
});

expect(result).toStrictEqual({
transactionHash: mockTransactionHash,
});
});
});

describe('signDelegation()', () => {
const unsignedDelegation: Omit<Delegation, 'signature'> = {
delegate: '0x1234567890abcdef1234567890abcdef12345678',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ type TestLifecycleHandlersType = LifecycleOrchestrationHandlers<

const mockPermissionRequest: PermissionRequest = {
chainId: '0x1',
expiry: 1234567890,
signer: {
type: 'account',
data: {
Expand All @@ -69,7 +68,10 @@ const mockContext: TestContextType = {
},
accountAddressCaip10: `eip155:1:${mockAddress}`,
tokenAddressCaip19: `eip155:1/erc20:${mockAssetAddress}`,
expiry: '1234567890',
expiry: {
timestamp: 1234567890,
isAdjustmentAllowed: false,
},
isAdjustmentAllowed: false,
};

Expand Down Expand Up @@ -135,8 +137,14 @@ const setupTest = () => {
const accountController = {
signDelegation: jest.fn(),
getAccountAddresses: jest.fn(),
getAccountUpgradeStatus: jest.fn(),
upgradeAccount: jest.fn(),
} as unknown as jest.Mocked<AccountController>;

accountController.getAccountUpgradeStatus.mockResolvedValue({
isUpgraded: false,
});

userEventDispatcher = {
on: jest.fn(bindEvent),
off: jest.fn(),
Expand Down Expand Up @@ -1031,6 +1039,15 @@ describe('PermissionHandler', () => {
},
"type": "AccountSelector",
},
{
"key": null,
"props": {
"children": "This account will be upgraded to a smart account to complete this permission.",
"color": "warning",
"size": "sm",
},
"type": "Text",
},
{
"key": null,
"props": {
Expand Down Expand Up @@ -1539,6 +1556,15 @@ describe('PermissionHandler', () => {
},
"type": "AccountSelector",
},
{
"key": null,
"props": {
"children": "This account will be upgraded to a smart account to complete this permission.",
"color": "warning",
"size": "sm",
},
"type": "Text",
},
{
"key": null,
"props": {
Expand Down Expand Up @@ -2085,6 +2111,15 @@ describe('PermissionHandler', () => {
},
"type": "AccountSelector",
},
{
"key": null,
"props": {
"children": "This account will be upgraded to a smart account to complete this permission.",
"color": "warning",
"size": "sm",
},
"type": "Text",
},
{
"key": null,
"props": {
Expand Down Expand Up @@ -2559,6 +2594,15 @@ describe('PermissionHandler', () => {
},
"type": "AccountSelector",
},
{
"key": null,
"props": {
"children": "This account will be upgraded to a smart account to complete this permission.",
"color": "warning",
"size": "sm",
},
"type": "Text",
},
{
"key": null,
"props": {
Expand Down Expand Up @@ -3038,6 +3082,15 @@ describe('PermissionHandler', () => {
},
"type": "AccountSelector",
},
{
"key": null,
"props": {
"children": "This account will be upgraded to a smart account to complete this permission.",
"color": "warning",
"size": "sm",
},
"type": "Text",
},
{
"key": null,
"props": {
Expand Down
Loading
Loading