Skip to content
85 changes: 85 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: Hex;
};

/**
* Controls EOA account operations including address retrieval, delegation signing, and balance queries.
*/
Expand Down Expand Up @@ -168,4 +182,75 @@ 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],
})) as { isUpgraded: boolean; upgradedAddress: Hex | null };

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

const {
contracts: { eip7702StatelessDeleGatorImpl },
} = getChainMetadata({ chainId: hexToNumber(params.chainId) });

return {
isUpgraded:
result.isUpgraded &&
result.upgradedAddress?.toLowerCase() ===
eip7702StatelessDeleGatorImpl.toLowerCase(),
Comment on lines +211 to +212
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the wallet is making this check. If that is the case should we be duplicating the check here?

On the one hand, the result should always be the eip7702StatelessDeleGatorImpl address, so this probably isn't harmful.

On the other hand, the wallet is the arbiter of which account should be upgraded to, so if for some reason the wallet is upgrading to a different account (maybe we deploy an updated version of the Eip7702Stateless contract that is otherwise backwards compatible), the snap will need to be updated also. Presently the wallet receives the contract address to delegate to via over-the-air configuration, so this delegation address could be changed without a software update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wallet is not checking the address to which is upgraded. Also I'm thinking there can be multiple versions of the stateless contract and we would need to know to which contracts its upgraded and suggest and upgrade if its not to the one we need?

};
} 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');
}
}
}
2 changes: 2 additions & 0 deletions packages/gator-permissions-snap/src/core/chainMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { numberToHex } from '@metamask/utils';

export type DelegationContracts = {
delegationManager: Hex;
eip7702StatelessDeleGatorImpl: Hex;

// Enforcers:
limitedCallsEnforcer: Hex;
Expand All @@ -18,6 +19,7 @@ export type DelegationContracts = {

const contracts: DelegationContracts = {
delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3',
eip7702StatelessDeleGatorImpl: '0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B',
limitedCallsEnforcer: '0x04658B29F6b82ed55274221a06Fc97D318E25416',
erc20StreamingEnforcer: '0x56c97aE02f233B29fa03502Ecc0457266d9be00e',
erc20PeriodTransferEnforcer: '0x474e3Ae7E169e940607cC624Da8A15Eb120139aB',
Expand Down
11 changes: 11 additions & 0 deletions packages/gator-permissions-snap/src/core/permissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Hex } from '@metamask/utils';
import {
bigIntToHex,
isStrictHexString,
numberToHex,
parseCaipAccountId,
parseCaipAssetType,
} from '@metamask/utils';
Expand Down Expand Up @@ -211,8 +212,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: numberToHex(chainId),
});

return PermissionHandlerContent({
origin,
justification,
Expand All @@ -227,6 +237,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 | undefined;
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 @@ -155,6 +160,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 @@ -20,6 +20,7 @@ import {
bytesToHex,
hexToNumber,
numberToHex,
parseCaipAccountId,
} from '@metamask/utils';
import type { NonceCaveatService } from 'src/services/nonceCaveatService';

Expand Down Expand Up @@ -220,6 +221,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: numberToHex(chainId),
});

if (!upgradeStatus.isUpgraded) {
// Trigger account upgrade
await this.#accountController.upgradeAccount({
account: address,
chainId: numberToHex(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,41 @@ describe('AccountController', () => {
});
});

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

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 @@ -22,6 +22,7 @@ describe('chainMetadata', () => {
const metadataSchema = {
contracts: {
delegationManager: expect.any(String),
eip7702StatelessDeleGatorImpl: expect.any(String),
limitedCallsEnforcer: expect.any(String),
erc20StreamingEnforcer: expect.any(String),
erc20PeriodTransferEnforcer: expect.any(String),
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