Repository: l2beat/l2beat
Pull request #7816: New Contracts Section design
Author: login: adamiak
Created: 2025-05-19T10:53:21Z | Merged: 2025-05-23T10:52:31Z

Resolves L2B-9657, L2B-10323

Uses the new ".role" field on permissions to render Contracts section according to the spec from https://docs.l2beat.com/l2b_specs/contracts.html

Example:

![image](https://github.com/user-attachments/assets/c803c33a-9401-4f2d-8df5-01c7fd6cbbb7)



Diff:
diff --git a/packages/config/src/discovery/PermissionRegistry.ts b/packages/config/src/discovery/PermissionRegistry.ts
index 701581f464bf..b5c12b9ce984 100644
--- a/packages/config/src/discovery/PermissionRegistry.ts
+++ b/packages/config/src/discovery/PermissionRegistry.ts
@@ -7,6 +7,6 @@ export interface PermissionRegistry {
   describePermissions(
     contractOrEoa: EntryParameters,
     includeDirectPermissions: boolean,
-  ): string[]
+  ): string
   getUpgradableBy(contract: EntryParameters): { name: string; delay: string }[]
 }
diff --git a/packages/config/src/discovery/PermissionsFromDiscovery.ts b/packages/config/src/discovery/PermissionsFromDiscovery.ts
index 3bf257423563..301d353a9b45 100644
--- a/packages/config/src/discovery/PermissionsFromDiscovery.ts
+++ b/packages/config/src/discovery/PermissionsFromDiscovery.ts
@@ -1,21 +1,13 @@
-import type {
-  EntryParameters,
-  Permission,
-  ReceivedPermission,
-} from '@l2beat/discovery'
+import type { EntryParameters, ReceivedPermission } from '@l2beat/discovery'
 import { type EthereumAddress, formatSeconds } from '@l2beat/shared-pure'
 import groupBy from 'lodash/groupBy'
 import sum from 'lodash/sum'
 import type { PermissionRegistry } from './PermissionRegistry'
 import type { ProjectDiscovery } from './ProjectDiscovery'
-import {
-  DirectPermissionToPrefix,
-  UltimatePermissionToPrefix,
-} from './descriptions'
+import { UltimatePermissionToPrefix } from './descriptions'
 import {
   formatPermissionCondition,
   formatPermissionDelay,
-  formatPermissionDescription,
   isMultisigLike,
   trimTrailingDots,
 } from './utils'
@@ -76,7 +68,7 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
           .map((d) => formatPermissionDelay(Number(d)))
           .join(' or ')
         return [
-          `Can upgrade **${delaysString}**`,
+          `* Can upgrade **${delaysString}**`,
           ...Object.values(permissionsByDelay).map((p) => {
             const name = this.projectDiscovery.getContract(p.from).name
             const vias = Object.values(p.permissionsByDelay).map((p) =>
@@ -117,7 +109,7 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
           return result.join(' ')
         })
         .join('\n')
-      return `Can interact with ${name}\n${permissionsString}`
+      return `* Can interact with ${name}\n${permissionsString}`
     })
   }
 
@@ -128,81 +120,73 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
       'interact',
       'member',
     ] satisfies ReceivedPermission['permission'][]
-    return (contractOrEoa.receivedPermissions ?? [])
+    const legacyPermissions = (contractOrEoa.receivedPermissions ?? [])
       .filter((p) => !excludedPermissions.includes(p.permission))
       .map((p) => {
         const prefix = UltimatePermissionToPrefix[p.permission]
         const via = this.formatPermissionVia(p)
-        return `${prefix} ${via}`
+        return `* ${prefix} ${via}`
       })
+    return [...new Set(legacyPermissions)].sort()
+  }
+
+  describeRoles(contractOrEoa: EntryParameters) {
+    const issued = this.getIssuedPermissions(contractOrEoa.address)
+    const roles: Record<string, Record<'direct' | 'ultimate', Set<string>>> = {}
+
+    const result = []
+    for (const p of issued) {
+      const receiverName = this.projectDiscovery.getName(p.to)
+      if (p.role) {
+        let text = receiverName
+        if (p.condition) {
+          text += ` ${formatPermissionCondition(p.condition)}`
+        }
+        const prettyfiedRole = prettifyRole(p.role)
+        roles[prettyfiedRole] ??= {
+          direct: new Set(),
+          ultimate: new Set(),
+        }
+
+        if ((p.via ?? []).length === 0) {
+          roles[prettyfiedRole].direct.add(text)
+        } else {
+          roles[prettyfiedRole].ultimate.add(text)
+        }
+      }
+    }
+
+    const roleNames = Object.keys(roles).sort()
+    for (const roleName of roleNames) {
+      const direct = Array.from(roles[roleName].direct).sort().join(', ')
+      const ultimate = Array.from(roles[roleName].ultimate).sort().join(', ')
+      const value = [`  * **${roleName}**: ${direct}`]
+      if (ultimate.length > 0) {
+        value.push(`; ultimately ${ultimate}`)
+      }
+      result.push(value.join(''))
+    }
+    if (result.length > 0) {
+      result.unshift('* Roles:')
+    }
+    return [result.join('\n')]
   }
 
   describePermissions(
     contractOrEoa: EntryParameters,
-    includeDirectPermissions: boolean = true,
-  ) {
+    describeRoles: boolean = true,
+  ): string {
     const upgrade = this.describeUpgradePermissions(contractOrEoa)
     const interact = this.describeInteractPermissions(contractOrEoa)
     const legacy = this.describeLegacyPermissions(contractOrEoa)
-    const direct = includeDirectPermissions
-      ? this.describeDirectlyReceivedPermissions(contractOrEoa)
-      : []
-    return [...direct, ...upgrade, ...interact, ...legacy].filter(
-      (s) => s !== '',
-    )
-  }
-
-  describeDirectlyReceivedPermissions(
-    contractOrEoa: EntryParameters,
-  ): string[] {
-    return Object.entries(
-      groupBy(
-        contractOrEoa.directlyReceivedPermissions ?? [],
-        (value: ReceivedPermission) =>
-          [
-            value.permission,
-            value.description ?? '',
-            value.condition ?? '',
-            value.delay?.toString() ?? '',
-          ].join('►'),
-      ),
-    ).map(([key, entries]) => {
-      const permission = key.split('►')[0] as Permission
-      const description = key.split('►')[1] ?? ''
-      const condition = key.split('►')[2] ?? ''
-      const delay = key.split('►')[3] ?? ''
-      const permissionsRequiringTarget: Permission[] = [
-        'interact',
-        'upgrade',
-        'act',
-      ]
-      const showTargets = permissionsRequiringTarget.includes(permission)
-      const addressesString = showTargets
-        ? entries
-            .map(
-              (entry) =>
-                this.projectDiscovery.getContract(entry.from.toString()).name,
-            )
-            .join(', ')
-        : ''
-
-      return `${[
-        DirectPermissionToPrefix[permission],
-        addressesString,
-        formatPermissionDescription(description),
-        formatPermissionCondition(condition),
-        delay === '' ? '' : formatPermissionDelay(Number(delay)),
-      ]
-        .filter((s) => s !== '')
-        .join(' ')
-        .trim()}.`
-    })
+    const roles = describeRoles ? this.describeRoles(contractOrEoa) : []
+    return [...upgrade, ...interact, ...legacy, ...roles]
+      .filter((s) => s !== '')
+      .join('\n')
   }
 
-  getUpgradableBy(
-    contract: EntryParameters,
-  ): { name: string; delay: string }[] {
-    const issuedPermissions = this.projectDiscovery
+  getUltimatelyIssuedPermissions(fromAddress: EthereumAddress) {
+    return this.projectDiscovery
       .getEntries()
       .flatMap((c) =>
         (c.receivedPermissions ?? []).map((p) => ({
@@ -210,20 +194,37 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
           ...p,
         })),
       )
-      .filter(
-        (receivedPermission) => receivedPermission.from === contract.address,
-      )
+      .filter((receivedPermission) => receivedPermission.from === fromAddress)
+  }
+
+  getIssuedPermissions(fromAddress: EthereumAddress) {
+    return this.projectDiscovery
+      .getEntries()
+      .flatMap((c) => {
+        const allPermissions = [
+          ...(c.receivedPermissions ?? []),
+          ...(c.directlyReceivedPermissions ?? []),
+        ]
+        return allPermissions.map((p) => ({
+          to: c.address,
+          ...p,
+        }))
+      })
+      .filter((receivedPermission) => receivedPermission.from === fromAddress)
+  }
+
+  getUpgradableBy(
+    contract: EntryParameters,
+  ): { name: string; delay: string }[] {
+    const issuedPermissions = this.getUltimatelyIssuedPermissions(
+      contract.address,
+    )
 
     const upgradersWithDelay: Record<string, number> = Object.fromEntries(
       issuedPermissions
         ?.filter((p) => p.permission === 'upgrade')
         .map((p) => {
-          const entry = this.projectDiscovery.getEntryByAddress(p.to)
-          const address =
-            entry?.name ??
-            (this.projectDiscovery.isEOA(p.to)
-              ? this.projectDiscovery.getEOAName(p.to)
-              : p.to.toString())
+          const address = this.projectDiscovery.getName(p.to)
           const delay =
             (p.delay ?? 0) + sum(p.via?.map((v) => v.delay ?? 0) ?? [])
           return [address, delay]
@@ -278,3 +279,15 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
 function totalPermissionDelay(p: ReceivedPermission): number {
   return (p.delay ?? 0) + sum((p.via ?? []).map((v) => v.delay ?? 0))
 }
+
+function prettifyRole(role: string): string {
+  const trimmed = role.replace(/^[.$]+/, '')
+  const withoutAcPrefix = trimmed.replace(/^ac/, '')
+  const isAllCaps = (s: string) => s === s.toUpperCase()
+  if (isAllCaps(withoutAcPrefix)) {
+    return withoutAcPrefix.toLowerCase()
+  }
+  const withoutACSuffix = withoutAcPrefix.replace(/AC$/, '')
+  const decapitalized = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
+  return decapitalized(withoutACSuffix)
+}
diff --git a/packages/config/src/discovery/ProjectDiscovery.test.ts b/packages/config/src/discovery/ProjectDiscovery.test.ts
index 2eed4e57cf4d..969f1fa67c4e 100644
--- a/packages/config/src/discovery/ProjectDiscovery.test.ts
+++ b/packages/config/src/discovery/ProjectDiscovery.test.ts
@@ -7,7 +7,7 @@ import {
   type DiscoveryConfig,
 } from '@l2beat/discovery'
 import { contractStub, discoveredJsonStub } from '../test/stubs/discoveredJson'
-import { ProjectDiscovery, formatAsBulletPoints } from './ProjectDiscovery'
+import { ProjectDiscovery } from './ProjectDiscovery'
 
 describe(ProjectDiscovery.name, () => {
   const projectName = 'ExampleProject'
@@ -130,26 +130,6 @@ describe(ProjectDiscovery.name, () => {
   })
 })
 
-describe(formatAsBulletPoints.name, () => {
-  it('should format description as bullet points', () => {
-    const description = ['First point', 'Second point', 'Third point']
-    const formatted = formatAsBulletPoints(description)
-    expect(formatted).toEqual('* First point\n* Second point\n* Third point\n')
-  })
-
-  it('should format single point as string is skipForSingle is true', () => {
-    const description = ['Single point']
-    const formatted = formatAsBulletPoints(description, { skipForSingle: true })
-    expect(formatted).toEqual('Single point')
-  })
-
-  it('should format single point as bullet point if no skipForSingle', () => {
-    const description = ['Single point']
-    const formatted = formatAsBulletPoints(description)
-    expect(formatted).toEqual('* Single point\n')
-  })
-})
-
 function mockConfig(
   name: string,
   chain = 'ethereum',
diff --git a/packages/config/src/discovery/ProjectDiscovery.ts b/packages/config/src/discovery/ProjectDiscovery.ts
index b82413df3bdc..0f1f16489e39 100644
--- a/packages/config/src/discovery/ProjectDiscovery.ts
+++ b/packages/config/src/discovery/ProjectDiscovery.ts
@@ -64,6 +64,13 @@ export class ProjectDiscovery {
     this.permissionRegistry = new PermissionsFromDiscovery(this)
   }
 
+  getName(address: EthereumAddress): string {
+    return (
+      this.getEntryByAddress(address)?.name ??
+      (this.isEOA(address) ? this.getEOAName(address) : address.toString())
+    )
+  }
+
   getEOAName(address: EthereumAddress): string {
     if (!(address in this.eoaIDMap)) {
       this.eoaIDMap[address] = `EOA ${Object.keys(this.eoaIDMap).length + 1}`
@@ -208,7 +215,7 @@ export class ProjectDiscovery {
         : `It uses the following modules: ${modulesDescriptions.join(', ')}.`
 
     return [
-      `A Multisig with ${this.getMultisigStats(identifier)} threshold. ` +
+      `* A Multisig with ${this.getMultisigStats(identifier)} threshold. ` +
         fullModulesDescription,
     ]
   }
@@ -217,7 +224,6 @@ export class ProjectDiscovery {
     identifier: string,
     description: string | string[],
     userReferences?: ReferenceLink[],
-    useBulletPoints: boolean = false,
   ): ProjectPermission {
     const contract = this.getContract(identifier)
 
@@ -227,11 +233,11 @@ export class ProjectDiscovery {
 
     const multisigDesc = this.getMultisigDescription(identifier)
 
-    const combinedDescriptions = [...multisigDesc, ...passedDescription]
+    const combinedDescriptions = [...multisigDesc, ...passedDescription].filter(
+      (s) => s !== undefined && s !== '',
+    )
 
-    const formattedDesc = useBulletPoints
-      ? formatAsBulletPoints(combinedDescriptions)
-      : combinedDescriptions.join(' ')
+    const formattedDesc = combinedDescriptions.join('\n')
 
     const descriptionWithContractNames =
       this.replaceAddressesWithNames(formattedDesc)
@@ -635,7 +641,9 @@ export class ProjectDiscovery {
     return this.formatPermissionedAccounts(addresses)
   }
 
-  describeGnosisSafeMembership(contractOrEoa: EntryParameters): string[] {
+  describeGnosisSafeMembership(
+    contractOrEoa: EntryParameters,
+  ): string | undefined {
     const safesWithThisMember = this.discoveries
       .flatMap((discovery) => discovery.entries)
       .filter((contract) => isMultisigLike(contract))
@@ -646,8 +654,8 @@ export class ProjectDiscovery {
       )
       .map((contract) => contract.name)
     return safesWithThisMember.length === 0
-      ? []
-      : ['Member of ' + safesWithThisMember.join(', ') + '.']
+      ? undefined
+      : `* Member of ${safesWithThisMember.join(', ')}.`
   }
 
   describeRolePermissions(
@@ -743,16 +751,15 @@ export class ProjectDiscovery {
 
   describeContractOrEoa(
     contractOrEoa: EntryParameters,
-    includeDirectPermissions: boolean = true,
-  ): string[] {
+    describeRoles: boolean = true,
+  ): string {
     return [
       contractOrEoa.description,
-      ...this.describeGnosisSafeMembership(contractOrEoa),
-      ...this.permissionRegistry.describePermissions(
-        contractOrEoa,
-        includeDirectPermissions,
-      ),
-    ].filter(notUndefined)
+      this.describeGnosisSafeMembership(contractOrEoa),
+      this.permissionRegistry.describePermissions(contractOrEoa, describeRoles),
+    ]
+      .filter(notUndefined)
+      .join('\n')
   }
 
   replaceAddressesWithNames(s: string): string {
@@ -808,23 +815,15 @@ export class ProjectDiscovery {
             contract.address.toString(),
             descriptions,
             [],
-            true,
           ),
         )
       } else {
-        allActors.push(
-          this.contractAsPermissioned(
-            contract,
-            formatAsBulletPoints(descriptions),
-          ),
-        )
+        allActors.push(this.contractAsPermissioned(contract, descriptions))
       }
     }
 
     for (const eoa of permissionedEoas) {
-      const description = formatAsBulletPoints(
-        this.describeContractOrEoa(eoa, false),
-      )
+      const description = this.describeContractOrEoa(eoa, false)
       allActors.push({
         name: eoa.name ?? this.getEOAName(eoa.address),
         accounts: this.formatPermissionedAccounts([eoa.address]),
@@ -951,21 +950,14 @@ export class ProjectDiscovery {
         return (b.category?.priority ?? 0) - (a.category?.priority ?? 0)
       })
 
-    const gnosisModules = contracts.flatMap((contract) =>
-      toAddressArray(contract.values?.GnosisSafe_modules),
-    )
     const result = contracts
-      .filter((contract) => !gnosisModules.includes(contract.address))
       .filter((contract) => contract.receivedPermissions === undefined)
       .filter((contract) => !isMultisigLike(contract))
       .map((contract) => {
         const upgradableBy = this.permissionRegistry.getUpgradableBy(contract)
 
         return this.getContractDetails(contract.address.toString(), {
-          description: formatAsBulletPoints(
-            this.describeContractOrEoa(contract, true),
-            { skipForSingle: true },
-          ),
+          description: this.describeContractOrEoa(contract, true),
           ...(upgradableBy.length > 0 ? { upgradableBy } : {}),
           discoveryDrivenData: true,
         })
@@ -1006,15 +998,6 @@ function isNonNullable<T>(
   return value !== null && value !== undefined
 }
 
-export function formatAsBulletPoints(
-  description: string[],
-  options: { skipForSingle?: boolean } = {},
-): string {
-  return description.length > 1 || !options.skipForSingle
-    ? description.map((s) => `* ${s}\n`).join('')
-    : description.join(' ')
-}
-
 function isEntryVerified(entry: EntryParameters): boolean {
   if ('unverified' in entry) {
     return entry.unverified !== true
diff --git a/packages/config/src/discovery/descriptions.ts b/packages/config/src/discovery/descriptions.ts
index d56cddb5e01f..814e209151ba 100644
--- a/packages/config/src/discovery/descriptions.ts
+++ b/packages/config/src/discovery/descriptions.ts
@@ -1,30 +1,5 @@
 import type { Permission, RolePermissionEntries } from '@l2beat/discovery'
 
-export const DirectPermissionToPrefix: {
-  [key in Permission]: string | undefined
-} = {
-  interact: 'Can be used to interact with',
-  upgrade: 'Can be used to upgrade implementation of',
-  act: 'Can act on behalf of',
-  guard: 'Can act as a Guardian',
-  challenge: 'Can act as a Challenger',
-  propose: 'Can act as a Proposer',
-  sequence: 'Can act as a Sequencer',
-  validate: 'Can act as a Validator',
-  operateLinea: 'Can act as an Operator',
-  fastconfirm: 'Can act as a FastConfirmer',
-  validateZkStack: 'Can act as a Validator',
-  relay: 'Can act as a Relayer',
-  validateBridge: 'Can act as a Validator',
-  validateBridge2: 'Can act as a Validator',
-  validateBridge3: 'Can act as a Validator',
-  aggregatePolygon: 'Can act as a trusted Aggregator',
-  operateStarknet: 'Can act as an Operator',
-  operateStarkEx: 'Can act as an Operator',
-  governStarknet: 'Can act as a Governor',
-  member: 'Is a member of',
-}
-
 export const UltimatePermissionToPrefix: {
   [key in Permission]: string | undefined
 } = {
diff --git a/packages/config/src/processing/getProjects.test.ts b/packages/config/src/processing/getProjects.test.ts
index 7c365948d4e4..0b8560f284cd 100644
--- a/packages/config/src/processing/getProjects.test.ts
+++ b/packages/config/src/processing/getProjects.test.ts
@@ -178,15 +178,6 @@ describe('getProjects', () => {
         const contracts = project.contracts?.addresses ?? {}
         for (const [chain, perChain] of Object.entries(contracts)) {
           for (const [i, contract] of perChain.entries()) {
-            const description = contract.description
-            if (description) {
-              it(`contracts[${i}].description - each line ends with a dot`, () => {
-                for (const descLine of description.trimEnd().split('\n')) {
-                  expect(descLine.trimEnd().endsWith('.')).toEqual(true)
-                }
-              })
-            }
-
             it(`contract [${chain}:${contract.address}] name isn't empty`, () => {
               assert(
                 contract.name.trim().length > 0,
diff --git a/packages/discovery/src/discovery/output/types.ts b/packages/discovery/src/discovery/output/types.ts
index 9003966e74ff..65a790359fe0 100644
--- a/packages/discovery/src/discovery/output/types.ts
+++ b/packages/discovery/src/discovery/output/types.ts
@@ -54,6 +54,7 @@ export interface ResolvedPermissionDetails {
   permission: Permission
   delay?: number
   description?: string
+  role?: string
   condition?: string
   via?: ResolvedPermissionPath[]
 }
diff --git a/packages/frontend/src/utils/project/contracts-and-permissions/get-contracts-section.ts b/packages/frontend/src/utils/project/contracts-and-permissions/get-contracts-section.ts
index 0b37f71ddacf..5cda4ea837ef 100644
--- a/packages/frontend/src/utils/project/contracts-and-permissions/get-contracts-section.ts
+++ b/packages/frontend/src/utils/project/contracts-and-permissions/get-contracts-section.ts
@@ -178,7 +178,7 @@ function makeTechnologyContract(
     if (!description) {
       description = tokenText
     } else {
-      description += ' ' + tokenText
+      description += '\n* ' + tokenText
     }
   }
 


Reviews:
user: login: mateuszradomski
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T10:32:20Z
user: login: adamiak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T10:38:51Z
user: login: adamiak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T10:40:28Z
user: login: adamiak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T10:44:55Z

Review comments:
diff_hunk: @@ -178,15 +178,6 @@ describe('getProjects', () => {
         const contracts = project.contracts?.addresses ?? {}
         for (const [chain, perChain] of Object.entries(contracts)) {
           for (const [i, contract] of perChain.entries()) {
-            const description = contract.description
path: packages/config/src/processing/getProjects.test.ts
user: login: mateuszradomski
body: Why are we deleting this test?
created_at: 2025-05-23T10:25:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -646,8 +654,8 @@ export class ProjectDiscovery {
       )
       .map((contract) => contract.name)
     return safesWithThisMember.length === 0
-      ? []
-      : ['Member of ' + safesWithThisMember.join(', ') + '.']
+      ? undefined
+      : '* Member of ' + safesWithThisMember.join(', ') + '.'
path: packages/config/src/discovery/ProjectDiscovery.ts
user: login: mateuszradomski
body: It's better to use the format string instead of the addition operator
created_at: 2025-05-23T10:29:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,7 +68,7 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
           .map((d) => formatPermissionDelay(Number(d)))
           .join(' or ')
         return [
-          `Can upgrade **${delaysString}**`,
+          `* Can upgrade **${delaysString}**`,
path: packages/config/src/discovery/PermissionsFromDiscovery.ts
user: login: mateuszradomski
body: Some descriptions have dots at the end and some don't. I don't know if it's intentional
created_at: 2025-05-23T10:31:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,15 +178,6 @@ describe('getProjects', () => {
         const contracts = project.contracts?.addresses ?? {}
         for (const [chain, perChain] of Object.entries(contracts)) {
           for (const [i, contract] of perChain.entries()) {
-            const description = contract.description
path: packages/config/src/processing/getProjects.test.ts
user: login: adamiak
body: Previously descriptions in contracts section were simply paragraphs of text, so it made sense to check if they end with a period (dot) - although it seems it was checking the combined description, so only the last sentence was actually checked. But now we also have bulletpoints, like '* Roles" and sub-points, which don't need to end with a period.
created_at: 2025-05-23T10:38:51Z
author_association: MEMBER
diff_hunk: @@ -646,8 +654,8 @@ export class ProjectDiscovery {
       )
       .map((contract) => contract.name)
     return safesWithThisMember.length === 0
-      ? []
-      : ['Member of ' + safesWithThisMember.join(', ') + '.']
+      ? undefined
+      : '* Member of ' + safesWithThisMember.join(', ') + '.'
path: packages/config/src/discovery/ProjectDiscovery.ts
user: login: adamiak
body: fixed
created_at: 2025-05-23T10:40:28Z
author_association: MEMBER
diff_hunk: @@ -76,7 +68,7 @@ export class PermissionsFromDiscovery implements PermissionRegistry {
           .map((d) => formatPermissionDelay(Number(d)))
           .join(' or ')
         return [
-          `Can upgrade **${delaysString}**`,
+          `* Can upgrade **${delaysString}**`,
path: packages/config/src/discovery/PermissionsFromDiscovery.ts
user: login: adamiak
body: Yes, it's intentional. A checked with Claude 4 and when there are bullet-points listing things you're not supposed to end them with a period (dot). 
created_at: 2025-05-23T10:44:55Z
author_association: MEMBER

PR comments:
user: login: linear[bot]
created_at: 2025-05-19T10:53:23Z
body: <p><a href="https://linear.app/l2beat/issue/L2B-9657/improve-the-smart-contracts-section-structure">L2B-9657 Improve the Smart contracts section structure</a></p>
author_association: NONE
user: login: vercel[bot]
created_at: 2025-05-19T10:53:25Z
body: [vc]: #MUlwHGCQdzszSJgt2fvvflABhd5nO9eLZjhXpiW0VX8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJsMmJlYXQtc3RhZ2luZyIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9mcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvbDJiZWF0LXN0YWdpbmcvOVp5enJrYVZydmZRZG54ZFRINGlFdnNpZ3VzbSIsInByZXZpZXdVcmwiOiJsMmJlYXQtc3RhZ2luZy1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJsMmJlYXQtc3RhZ2luZy1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJsMmJlYXQtcHJvZHVjdGlvbiIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9mcm9udGVuZCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvbDJiZWF0LXByb2R1Y3Rpb24vQ3RhWVhKdFY2dmNMZnpmQWhCQ1hCb0VRUVV2NiIsInByZXZpZXdVcmwiOiJsMmJlYXQtcHJvZHVjdGlvbi1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJsMmJlYXQtcHJvZHVjdGlvbi1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJwcm90b2NvbGJlYXQiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvcHJvdG9jb2xiZWF0IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InByb3RvY29sYmVhdC1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbDJiZWF0L3Byb3RvY29sYmVhdC80Y3ltYzFFUTlzM0szZjN2Um5HYXNuOWtnUVRpIiwicHJldmlld1VybCI6InByb3RvY29sYmVhdC1naXQtbmV3LWNvbnRyYWN0cy1zZWN0aW9uLWRlc2lnbi1sMmJlYXQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIn0seyJuYW1lIjoidG9vbHMiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvdG9vbHMiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidG9vbHMtZ2l0LW5ldy1jb250cmFjdHMtc2VjdGlvbi1kZXNpZ24tbDJiZWF0LnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2wyYmVhdC90b29scy9DQll1ZTVkMlpjNTNtTGFFR0RKdlptb2JlM3hKIiwicHJldmlld1VybCI6InRvb2xzLWdpdC1uZXctY29udHJhY3RzLXNlY3Rpb24tZGVzaWduLWwyYmVhdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoidW9wcy1kYXNoYm9hcmQiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvdW9wcy1kYXNoYm9hcmQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9sMmJlYXQvdW9wcy1kYXNoYm9hcmQvRUdXSHFWOGV0cVE1OFhEUnVCOWlEMVZ0cTZKRSIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiU0tJUFBFRCJ9LHsibmFtZSI6ImRvY3MiLCJyb290RGlyZWN0b3J5IjoiZG9jcy9tZGJvb2siLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbDJiZWF0L2RvY3MvQTZ5U1haUm5QMkpBTWpNZDlMRnp5QVJ0RWoyQiIsInByZXZpZXdVcmwiOiJkb2NzLWdpdC1uZXctY29udHJhY3RzLXNlY3Rpb24tZGVzaWduLWwyYmVhdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImRvY3MtZ2l0LW5ldy1jb250cmFjdHMtc2VjdGlvbi1kZXNpZ24tbDJiZWF0LnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **docs** | ✅ Ready ([Inspect](https://vercel.com/l2beat/docs/A6ySXZRnP2JAMjMd9LFzyARtEj2B)) | [Visit Preview](https://docs-git-new-contracts-section-design-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/docs-git-new-contracts-section-design-l2beat.vercel.app?via=pr-comment-feedback-link) | May 23, 2025 10:45am |
| **l2beat-production** | ✅ Ready ([Inspect](https://vercel.com/l2beat/l2beat-production/CtaYXJtV6vcLfzfAhBCXBoEQQUv6)) | [Visit Preview](https://l2beat-production-git-new-contracts-section-design-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/l2beat-production-git-new-contracts-section-design-l2beat.vercel.app?via=pr-comment-feedback-link) | May 23, 2025 10:45am |
| **l2beat-staging** | ✅ Ready ([Inspect](https://vercel.com/l2beat/l2beat-staging/9ZyzrkaVrvfQdnxdTH4iEvsigusm)) | [Visit Preview](https://l2beat-staging-git-new-contracts-section-design-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/l2beat-staging-git-new-contracts-section-design-l2beat.vercel.app?via=pr-comment-feedback-link) | May 23, 2025 10:45am |
| **tools** | ✅ Ready ([Inspect](https://vercel.com/l2beat/tools/CBYue5d2Zc53mLaEGDJvZmobe3xJ)) | [Visit Preview](https://tools-git-new-contracts-section-design-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/tools-git-new-contracts-section-design-l2beat.vercel.app?via=pr-comment-feedback-link) | May 23, 2025 10:45am |

<details><summary>2 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **protocolbeat** | ⬜️ Ignored ([Inspect](https://vercel.com/l2beat/protocolbeat/4cymc1EQ9s3K3f3vRnGasn9kgQTi)) | [Visit Preview](https://protocolbeat-git-new-contracts-section-design-l2beat.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/protocolbeat-git-new-contracts-section-design-l2beat.vercel.app?via=pr-comment-feedback-link) | May 23, 2025 10:45am |
| **uops-dashboard** | ⬜️ Skipped ([Inspect](https://vercel.com/l2beat/uops-dashboard/EGWHqV8etqQ58XDRuB9iD1Vtq6JE)) |  |  | May 23, 2025 10:45am |
</details>







author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T10:54:16Z
body: ## Config Changes Report

Config changes have been detected between main and this PR.

[View Full Report](https://pr-7816-l2beat.surge.sh)

*Last updated: 2025-05-23 10:41:10 UTC*
author_association: CONTRIBUTOR
user: login: linear[bot]
created_at: 2025-05-23T10:20:27Z
body: <p><a href="https://linear.app/l2beat/issue/L2B-10323/fix-duplicate-permission">L2B-10323 Fix duplicate permission</a></p>
author_association: NONE