Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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(),
};
} 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