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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rpcErrors } from '@metamask/rpc-errors'
import { rpcErrors, providerErrors } from '@metamask/rpc-errors'
import {
Module,
RpcMethod as VmModuleRpcMethod
Expand All @@ -11,8 +11,14 @@ import { mapToVmNetwork } from 'vmModule/utils/mapToVmNetwork'
import { getChainIdFromCaip2 } from 'utils/caip2ChainIds'
import { Avalanche } from '@avalabs/core-wallets-sdk'
import { getAddressByVM } from 'store/account/utils'
import { Account, selectActiveAccount } from 'store/account'
import {
Account,
selectActiveAccount,
selectAccountByIndex
} from 'store/account'
import { selectActiveWallet } from 'store/wallet/slice'
import WalletConnectService from 'services/walletconnectv2/WalletConnectService'
import { isAvalancheSignMessageAuthorized } from 'store/rpc/utils/isAvalancheSignMessageAuthorized/isAvalancheSignMessageAuthorized'
import { WalletType } from 'services/wallet/types'
import { selectIsDeveloperMode } from 'store/settings/advanced'
import {
Expand Down Expand Up @@ -100,6 +106,33 @@ export const handleRequestViaVMModule = async ({
const params = request.data.params.request.params
const method = request.method as unknown as VmModuleRpcMethod

// avalanche_signMessage picks its signer by a dApp-supplied account index
// (params [message, accountIndex]), not a signingData.account address, so it
// slips past the ApprovalController grant check. Enforce the WalletConnect
// session grant here — on the same account the approval screen will sign with
// (selectAccountByIndex, or the active account when no index is given). No-op
// for every other method and for in-app / injected-browser requests; fails
// closed on a missing session. CP-14604.
if (
!isAvalancheSignMessageAuthorized({
method,
isInAppRequest: request.data.topic === CORE_MOBILE_TOPIC,
params,
caip2ChainId,
activeAccount,
getAccountByIndex: index =>
selectAccountByIndex(activeWallet.id, index)(state),
getSession: () => WalletConnectService.getSession(request.data.topic)
})
) {
rpcProvider.onError({
request,
error: providerErrors.unauthorized('Requested address is not authorized'),
listenerApi
})
return
}

// Merge, don't fallback: a non-empty `request.context` from the caller must
// not suppress the per-method auto-injected context (e.g. Avalanche `account`
// for AVALANCHE_SEND/SIGN_TRANSACTION). Caller wins on key conflicts.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,104 @@
import { isAccountApproved } from './isAccountApproved'
import { isAccountApproved, isAddressApproved } from './isAccountApproved'

describe('isAddressApproved', () => {
const namespaces = {
eip155: {
accounts: [
'eip155:43113:0xC7E5ffBd7843EdB88cCB2ebaECAa07EC55c65318',
'eip155:43114:0xcA0E993876152ccA6053eeDFC753092c8cE712D0'
],
methods: ['eth_sendTransaction'],
events: ['chainChanged', 'accountsChanged']
},
solana: {
accounts: [
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:9gQmZ7fTTgv5hVScrr9QqT6SpBs7i4cKLDdj4tuae3sW'
],
methods: ['solana_signTransaction'],
events: []
}
}

it('returns true when the address is granted in the namespace', () => {
expect(
isAddressApproved(
'0xcA0E993876152ccA6053eeDFC753092c8cE712D0',
'eip155:43114',
namespaces
)
).toBe(true)
})

it('matches addresses granted under another chain of the same namespace', () => {
expect(
isAddressApproved(
'0xC7E5ffBd7843EdB88cCB2ebaECAa07EC55c65318',
'eip155:43114',
namespaces
)
).toBe(true)
})

it('matches EVM addresses case-insensitively (EIP-55 checksum casing)', () => {
expect(
isAddressApproved(
'0xca0e993876152cca6053eedfc753092c8ce712d0',
'eip155:43114',
namespaces
)
).toBe(true)
})

it('returns false when the address is not granted', () => {
expect(
isAddressApproved(
'0x341b0073b66bfc19FCB54308861f604F5Eb8f51b',
'eip155:43114',
namespaces
)
).toBe(false)
})

it('returns true for a granted Solana account', () => {
expect(
isAddressApproved(
'9gQmZ7fTTgv5hVScrr9QqT6SpBs7i4cKLDdj4tuae3sW',
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
namespaces
)
).toBe(true)
})
Comment thread
bogdandobritoiu marked this conversation as resolved.

it('returns false for a Solana account that was never granted', () => {
expect(
isAddressApproved(
'4Nd1mYQZ6X8jY6nWn6iVrDpcLzJq9z2NKV1S1nGiqNz1',
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
namespaces
)
).toBe(false)
})

it('returns false for a case variant of a granted Solana account (base58 is case-sensitive)', () => {
expect(
isAddressApproved(
'9gqmz7fttgv5hvscrr9qqt6spbs7i4cklddj4tuae3sw',
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
namespaces
)
).toBe(false)
})

it('returns false when the session has no namespace for the chain', () => {
expect(
isAddressApproved(
'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
'bip122:000000000019d6689c085ae165831e93',
namespaces
)
).toBe(false)
})
})

describe('isAccountApproved', () => {
it('should return true if address is approved', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,38 @@ import {
getAddressForChainId
} from 'store/rpc/handlers/wc_sessionRequest/utils'

export const isAccountApproved = (
account: CoreAccountAddresses,
// A granted account under ANY chain of the request's namespace authorizes the
// address — sessions grant per-namespace account access, not per-chain.
export const isAddressApproved = (
address: string,
caip2ChainId: string,
namespaces: SessionTypes.Namespaces
): boolean => {
const address = getAddressForChainId(caip2ChainId, account)
const namespace = caip2ChainId.split(':')[0]

if (!namespace || !namespaces[namespace]) {
return false
}

// Only EVM hex addresses are case-insensitive (EIP-55 checksums vary the
// casing); other namespaces (e.g. base58 Solana) must match exactly.
const matches =
namespace === 'eip155'
? (acc: string): boolean =>
acc.split(':')[2]?.toLowerCase() === address.toLowerCase()
: (acc: string): boolean => acc.split(':')[2] === address

return Boolean(namespaces[namespace]?.accounts.some(matches))
}

export const isAccountApproved = (
account: CoreAccountAddresses,
caip2ChainId: string,
namespaces: SessionTypes.Namespaces
): boolean => {
const address = getAddressForChainId(caip2ChainId, account)

return Boolean(
address &&
namespaces[namespace]?.accounts.some(
acc => acc.split(':')[2]?.toLowerCase() === address.toLowerCase()
)
address && isAddressApproved(address, caip2ChainId, namespaces)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { AvalancheCaip2ChainId } from '@avalabs/core-chains-sdk'
import { RpcMethod } from '@avalabs/vm-module-types'
import { SessionTypes } from '@walletconnect/types'
import { CoreAccountAddresses } from 'store/rpc/handlers/wc_sessionRequest/utils'
import { isAvalancheSignMessageAuthorized } from './isAvalancheSignMessageAuthorized'

const X = AvalancheCaip2ChainId.X

const GRANTED_AVM = 'X-fuji1granted00000000000000000000000000000'
const UNGRANTED_AVM = 'X-fuji1ungranted000000000000000000000000000'

const account = (addressAVM: string): CoreAccountAddresses => ({
addressC: '0xc0000000000000000000000000000000000000c0',
addressBTC: 'bc1qbtc',
addressCoreEth: '0xcoreeth',
addressAVM,
addressPVM: 'P-fuji1pvm',
addressSVM: 'svmAddr'
})

const sessionGranting = (addresses: string[]): SessionTypes.Struct =>
({
namespaces: {
avax: {
accounts: addresses.map(a => `${X}:${a}`),
methods: ['avalanche_signMessage'],
events: []
}
}
} as unknown as SessionTypes.Struct)

// Explicit index 1 → an ungranted account; anything else → a granted account.
const getAccountByIndex = (index: number): CoreAccountAddresses | undefined =>
index === 1 ? account(UNGRANTED_AVM) : account(GRANTED_AVM)

const base = {
method: RpcMethod.AVALANCHE_SIGN_MESSAGE as string,
isInAppRequest: false,
params: ['hello', 1] as unknown,
caip2ChainId: X as string,
activeAccount: account(GRANTED_AVM),
getAccountByIndex,
getSession: (): SessionTypes.Struct | undefined =>
sessionGranting([GRANTED_AVM])
}

describe('isAvalancheSignMessageAuthorized', () => {
it('is a no-op (authorized) for any method other than avalanche_signMessage', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
method: RpcMethod.ETH_SIGN,
// would fail closed if the guard applied, proving it is skipped
getSession: () => {
throw new Error('should not be consulted')
}
})
).toBe(true)
})

it('is a no-op (authorized) for in-app / injected-browser requests', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
isInAppRequest: true,
getSession: () => {
throw new Error('should not be consulted')
}
})
).toBe(true)
})

it('rejects when the dApp-supplied account index resolves to an ungranted account', () => {
// params [message, 1] → getAccountByIndex(1) → UNGRANTED_AVM
expect(isAvalancheSignMessageAuthorized(base)).toBe(false)
})

it('allows when the dApp-supplied account index resolves to a granted account', () => {
expect(
isAvalancheSignMessageAuthorized({ ...base, params: ['hello', 0] })
).toBe(true)
})

it('falls back to the active account when no index is supplied (active granted → allow)', () => {
expect(
isAvalancheSignMessageAuthorized({ ...base, params: ['hello'] })
).toBe(true)
})

it('falls back to the active account when no index is supplied (active ungranted → reject)', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello'],
activeAccount: account(UNGRANTED_AVM)
})
).toBe(false)
})

it('fails closed when no WC session exists for the topic', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', 0],
getSession: () => undefined
})
).toBe(false)
})

it('fails closed when getSession throws (WC client not initialized)', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', 0],
getSession: () => {
throw new Error('WalletConnect client is not initialized')
}
})
).toBe(false)
})

it('rejects a fractional index whose accounts[0] fallback is ungranted (schema allows non-integers)', () => {
// 0.5 passes the module schema (nonnegative, not .int()); selectAccountByIndex
// finds no match and signs with accounts[0]. The guard must check that same
// resolved account, not the (granted) active account — else the bypass reopens.
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', 0.5],
getAccountByIndex: () => account(UNGRANTED_AVM)
})
).toBe(false)
})

it('allows a fractional index only when its resolved account is granted', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', 0.5],
getAccountByIndex: () => account(GRANTED_AVM)
})
).toBe(true)
})

it('fails closed when the account index resolves to no account', () => {
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', 5],
getAccountByIndex: () => undefined
})
).toBe(false)
})

it('treats an invalid (negative) index as absent and checks the active account', () => {
// module schema rejects negatives; our resolution mirrors the "no index"
// path (active account) rather than trusting a bogus dApp value
expect(
isAvalancheSignMessageAuthorized({
...base,
params: ['hello', -1],
activeAccount: account(GRANTED_AVM)
})
).toBe(true)
})
})
Loading
Loading