Skip to content

Commit

Permalink
feat(abigen-plugin): add support for auto-generating bindings for fou…
Browse files Browse the repository at this point in the history
…ndry projects (#156)

* feat(abigen-plugin): add support for auto-generating bindings for foundry projects

* fix lints

* ignore contracts without external/public functions

* add glob pattern filtering

* add instructions for locally testing gradle plugin
  • Loading branch information
ArtificialPB committed Aug 11, 2024
1 parent 8dc4959 commit 8863601
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 4 deletions.
32 changes: 31 additions & 1 deletion ethers-abigen-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ It supports the following configuration options:

```kotlin
plugins {
id("io.kriptal.ethers.abigen-plugin") version "0.5.0"
id("io.kriptal.ethers.abigen-plugin") version "1.1.0"
}

ethersAbigen {
Expand All @@ -36,3 +36,33 @@ ethersAbigen {
}
```

### Local testing of the plugin

To test the plugin locally, you must first build the shadow jar of the plugin. This can be done by running the following
Gradle command:

```shell
./gradlew :ethers-abigen-plugin:shadow
```

Then, in your project, you must apply the plugin using legacy `buildscript` syntax, and add the shadow jar as a
dependency:

```kts
buildscript {
repositories {
mavenLocal()
}

dependencies {
classpath(files("/absolute/path/ethers-abigen-plugin/build/libs/ethers-abigen-plugin-1.1.0-SNAPSHOT.jar"))
}
}

// apply the plugin by its id
apply(plugin = "io.kriptal.ethers.abigen-plugin")

configure<EthersAbigenExtension> {
// configure the plugin here
}
```
3 changes: 3 additions & 0 deletions ethers-abigen-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ plugins {
dependencies {
implementation(project(":ethers-abigen"))
implementation(libs.kotlin.gradle)
implementation(libs.hoplite.core)
implementation(libs.hoplite.toml)
implementation(libs.bundles.jackson)

testImplementation(libs.bundles.kotest)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.ethers.abigen.plugin

import io.ethers.abigen.plugin.source.AbiSourceProvider
import io.ethers.abigen.plugin.source.DirectorySourceProvider
import io.ethers.abigen.plugin.source.FoundrySourceProvider
import io.ethers.abigen.reader.JsonAbiReader
import io.ethers.abigen.reader.JsonAbiReaderRegistry
import org.gradle.api.Action
Expand Down Expand Up @@ -58,4 +59,11 @@ abstract class EthersAbigenExtension(private val project: Project) {
action?.execute(source)
sourceProviders.add(source)
}

@JvmOverloads
fun foundrySource(foundryRoot: String, destinationPackage: String, action: Action<in FoundrySourceProvider>? = null) {
val source = FoundrySourceProvider(project, foundryRoot, destinationPackage)
action?.execute(source)
sourceProviders.add(source)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package io.ethers.abigen.plugin.source

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.sksamuel.hoplite.ConfigLoaderBuilder
import com.sksamuel.hoplite.addFileSource
import com.sksamuel.hoplite.defaultDecoders
import com.sksamuel.hoplite.toml.TomlParser
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFile
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SkipWhenEmpty
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.FileSystems
import java.nio.file.PathMatcher
import java.util.stream.Collectors

/**
* A Foundry [AbiSourceProvider] that builds the foundry project and reads the ABI files from the output directory.
* Only contracts in from the source directory will be included. The contracts will be placed into [destinationPackage]
* package, replicating the foundry source directory structure.
*
* @param foundryRoot the root directory of the foundry project, which contains the `foundry.toml` file.
* @param destinationPackage the parent package name of the generated Kotlin files.
* */
open class FoundrySourceProvider(
private val project: Project,
private val foundryRoot: String,
@get:Input internal val destinationPackage: String,
) : AbiSourceProvider {
private val LOG = project.logger

// load the config file
private val config = project.provider { getFoundryConfig(foundryConfigFile) }
private val srcDirProvider = project.provider { config.get().src }
private val outDirProvider = project.provider { config.get().out }

/**
* Foundry profile to use when building the project. Defaults to `default`. Will be passed to the `FOUNDRY_PROFILE`
* environment variable.
* */
@get:Optional
@get:Input
var foundryProfile: String = "default"

/**
* List of glob patterns to filter out contracts that are not part of the source directory. Defaults to empty
* which includes all contracts.
*
* Filters are evaluated relative to the [srcDir] directory. E.g. if the contract is `contracts/erc/ERC20.sol`,
* the glob pattern evaluates path without the `contracts` prefix, so only `erc/ERC20.sol` is being matched.
* */
@get:Optional
@get:Input
var contractGlobFilters: List<String> = emptyList()

@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFile
internal val foundryConfigFile: RegularFile = project.layout.projectDirectory.dir(foundryRoot).file("foundry.toml")

/**
* Directory containing the source files.
* */
@get:SkipWhenEmpty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory
internal val srcDir: DirectoryProperty = project.objects.directoryProperty().convention(
project.layout.projectDirectory.dir(foundryRoot).dir(srcDirProvider),
)

/**
* Output directory containing the ABI files.
* */
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory
internal val outDir: DirectoryProperty = project.objects.directoryProperty().convention(
project.layout.projectDirectory.dir(foundryRoot).dir(outDirProvider),
)

override fun getSources(): List<AbiSource> {
val ret = ArrayList<AbiSource>()

forgeBuild()

val jackson = ObjectMapper()
val srcDir = srcDir.asFile.get()
val outDir = outDir.asFile.get()
val config = config.get()
val globMatchers = contractGlobFilters.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
outDir.walkTopDown()
.filter(File::isFile)
.filter(::isMainContractJson)
.forEach {
LOG.info("Found ABI file: ${it.absolutePath}")

val json = jackson.readTree(it)
if (json.get("abi").isEmpty) {
LOG.info("Skipping, no external/public ABI functions: ${it.absolutePath}")
return@forEach
}

// make sure metadata is present so we can replicate the package structure from the compilation target
val compilationTarget = json.get("metadata")?.get("settings")?.get("compilationTarget")
?: throw IllegalStateException("Compilation target not found in ${it.absolutePath}")

val relativePaths = ArrayList<String>()
(compilationTarget as ObjectNode).fields().iterator().forEach { entry -> relativePaths.add(entry.key) }

// find the relative path of the contract in the src dir
val relativePath = relativePaths
.firstOrNull { p -> p.startsWith("${config.src}/") && p.endsWith("${it.nameWithoutExtension}.sol") }

// TODO if no relative path it means that the contract inside the file has a different name than the
// file name. We should probably generate all the contracts in the file in that case.
if (relativePath == null) {
return@forEach
}

val sourceFile = File(srcDir, relativePath.substringAfter("/"))
if (!matchesGlobPatterns(sourceFile, srcDir, globMatchers)) {
LOG.info("Skipping, does not match any glob pattern: ${sourceFile.absolutePath}")
return@forEach
}

var destinationPackage = destinationPackage

// if contract is not in root of src dir, replicate the package structure from the compilation target
if (relativePath.count { c -> c == '/' } > 1) {
destinationPackage += "." + relativePath.substringAfter("/")
.substringBeforeLast("/")
.replace("/", ".")
}

val contractName = it.nameWithoutExtension
ret.add(AbiSource(contractName, destinationPackage, it.toURI().toURL()))
}

return ret
}

private fun matchesGlobPatterns(file: File, srcDir: File, matchers: List<PathMatcher>): Boolean {
return matchers.isEmpty() || matchers.any { it.matches(file.relativeTo(srcDir).toPath()) }
}

/**
* Filter out extra data files like `ContactName.metadata.json` and take only the main contract json file, which
* has the same name as the directory it's in. The directory contains also other inherited contract files e.g.
* `Ownable.json`. If the directory is `../ERC20.sol/`, take only the file named `ERC20.json`.
* */
private fun isMainContractJson(file: File): Boolean {
return file.name.count { c -> c == '.' } == 1 &&
file.nameWithoutExtension == file.parentFile.nameWithoutExtension &&
file.extension == "json"
}

private fun forgeBuild() {
val errorOutput = ByteArrayOutputStream()
val commands = listOf("forge", "build", "--extra-output", "abi", "metadata", "evm.bytecode")
val result = project.exec {
it.commandLine(commands)
it.environment("FOUNDRY_PROFILE", foundryProfile)
it.workingDir = project.layout.projectDirectory.dir(foundryRoot).asFile
it.errorOutput = errorOutput
}

val cmd = commands.joinToString(" ")
if (result.exitValue != 0) {
val errorReader = ByteArrayInputStream(errorOutput.toByteArray()).bufferedReader()
val error = errorReader.lines().collect(Collectors.toList()).last()

LOG.error("Foundry build failed for command `$cmd` and profile `$foundryProfile`: $error")
result.rethrowFailure()
}

LOG.info("Foundry build succeeded for command `$cmd` and profile `$foundryProfile`")
}

private fun getFoundryConfig(configFile: RegularFile): ProfileConfig {
LOG.info("Loading foundry config from ${configFile.asFile.absolutePath}")

data class FoundryConfig(private val profile: Map<String, ProfileConfig>) {
fun getProfile(name: String): ProfileConfig {
return profile[name] ?: profile["default"] ?: ProfileConfig()
}
}

val loader = ConfigLoaderBuilder.empty()
.addDecoders(defaultDecoders)
.addDefaultPreprocessors()
.addDefaultParamMappers()
.addDefaultPropertySources()
.addDefaultParsers()
.addFileExtensionMapping("toml", TomlParser())
.addFileSource(configFile.asFile, optional = false, allowEmpty = true)
.build()

val result = loader.loadConfig<FoundryConfig>()
result.onFailure {
throw IllegalStateException("Failed to load foundry config: ${it.description()}")
}

return result.getUnsafe().getProfile(foundryProfile)
}

internal data class ProfileConfig(
val src: String = "src",
val out: String = "out",
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.workers.WorkerExecutor
import java.io.File
import java.util.Collections
import java.util.UUID
import java.util.*
import javax.inject.Inject

@CacheableTask
Expand Down Expand Up @@ -89,7 +88,8 @@ abstract class EthersAbigenTask @Inject constructor(private val executor: Worker
val resultsDir = File(project.layout.buildDirectory.get().asFile, "tmp/abigen-results/").apply { mkdirs() }
val queue = executor.noIsolation()

sourceProviders.get().parallelStream().forEach { provider ->
// TODO parallelize this
sourceProviders.get().forEach { provider ->
provider.getSources().forEach { source ->
val resultFile = File(resultsDir, UUID.randomUUID().toString())
results.add(resultFile)
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ slf4j = "2.0.13"
ktlint-tool = "1.3.1"
web3j = "4.10.3"
ethers = "0.5.0"
hoplite = "2.7.5"

[libraries]
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
Expand Down Expand Up @@ -46,6 +47,9 @@ gcp-kms = { module = "com.google.cloud:google-cloud-kms", version = "2.50.0" }
ens-normalise = { module = "io.github.adraffy:ens-normalize", version = "0.2.0" }
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version = "0.3.6" }

hoplite-core = { module = "com.sksamuel.hoplite:hoplite-core", version.ref = "hoplite" }
hoplite-toml = { module = "com.sksamuel.hoplite:hoplite-toml", version.ref = "hoplite" }

##################### TEST #####################
junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
Expand Down

0 comments on commit 8863601

Please sign in to comment.