diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1fcd216 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,20 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.google.ksp) apply false +} + +subprojects { + afterEvaluate { + // Load configuration file (if it exists) + val jacocoFile = file("../jacoco.gradle") + if (jacocoFile.exists()) { + apply(from = jacocoFile) + println("jacoco applied.") + } else { + println("jacoco file not found.") + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8bf23d7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,49 @@ +[versions] +agp = "8.5.1" +kotlin = "1.9.21" +coreKtx = "1.13.1" +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +appcompat = "1.7.0" +material = "1.12.0" +mockitoCore = "5.14.2" +web3j = "4.8.9-android" +gson = "2.10" +retrofit2 = "2.9.0" +okhttp3-logging-interceptor = "4.11.0" +kotlinx-coroutines-test = "1.8.1" +moshi = "1.15.1" +ksp = "1.9.21-1.0.15" +androidx-credentials = "1.3.0-alpha01" +dokka = "1.9.20" + + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoCore" } +mockito-core = { module = "org.mockito:mockito-core", version = "5.14.2" } +web3j = { group = "org.web3j", name = "core", version.ref = "web3j" } +retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" } +retrofit2-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit2" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp3-logging-interceptor" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } +moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } +moshi-ksp = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } +retrofit2-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit2" } +androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidx-credentials" } +androidx-credentials-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidx-credentials" } + + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } +google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e26cf30 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Sep 02 21:26:25 CST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..b85b27e --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,198 @@ +import org.jetbrains.dokka.DokkaConfiguration.Visibility +import org.jetbrains.dokka.gradle.DokkaTask +import java.io.FileInputStream +import java.net.URI +import java.util.Properties + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.google.ksp) + alias(libs.plugins.jetbrains.dokka) + id("maven-publish") + id("com.github.ben-manes.versions") version "0.46.0" +} + +extra.apply { + set("versionMajor", 0) + set("versionMedium", 0) + set("versionMinorPublished", 204) // should increment after public release + set("libraryId", libraryId()) + set("libraryGroupId", libraryId()) + set("libraryArtifactId", libraryArtifactId()) + set("defaultRepo", "") +} + +fun libraryArtifactId(): String { + return if (project.hasProperty("LIB_ART_ID")) { + project.property("LIB_ART_ID") as String + } else { + "core" + } +} + +fun libraryId(): String { + return if (project.hasProperty("LIB_ID")) { + project.property("LIB_ID") as String + } else { + "circle.modularwallets" + } +} + +fun buildNum(): Int { + return if (project.hasProperty("BUILD_NUM")) { + project.property("BUILD_NUM").toString().toInt() + } else { + 0 + } +} + +fun majorNumber(): Int { + return if (project.hasProperty("MAJOR_NUMBER")) { + project.property("MAJOR_NUMBER").toString().toInt() + } else { + extra["versionMajor"] as Int + } +} + +fun isInternalBuild(): Boolean { + return majorNumber() == 0 + +} + +fun apiVersion(): String { + return if (isInternalBuild()) { + "${majorNumber()}.${extra["versionMedium"]}.${extra["versionMinorPublished"]}-${buildNum()}" // internal build's buildNum is action build number + } else { + "${majorNumber()}.${extra["versionMedium"]}.${buildNum()}" // release build's buildNum is extracted from the git tag + } +} + +fun libraryVersion(): String { + val ver = apiVersion() + return if (project.hasProperty("SNAPSHOT")) { + "${ver}-SNAPSHOT" + } else { + ver + } +} + +fun nexusRepo(): String { + return if (project.hasProperty("NEXUS_REPO")) { + project.property("NEXUS_REPO") as String + } else { + extra["defaultRepo"] as String + } +} + +fun nexusUsername(): String { + return if (project.hasProperty("NEXUS_USERNAME")) { + project.property("NEXUS_USERNAME") as String + } else { + "" + } +} + +fun nexusPassword(): String { + return if (project.hasProperty("NEXUS_PASSWORD")) { + project.property("NEXUS_PASSWORD") as String + } else { + "" + } +} + +android { + namespace = "com.circle.modularwallets.core" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + version = libraryVersion() + buildConfigField("String", "version", "\"${version}\"") + buildConfigField("boolean", "INTERNAL_BUILD", "${isInternalBuild()}") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments += mapOf( + "notPackage" to "com.circle.modularwallets.core.manual" + ) + } + + buildTypes { + debug { + enableAndroidTestCoverage = true + enableUnitTestCoverage = true + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + ksp(libs.moshi.ksp) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.auth) + implementation(libs.retrofit2.converter.moshi) + implementation(libs.moshi.kotlin) + implementation(libs.web3j) + implementation(libs.retrofit2.retrofit) + implementation(libs.retrofit2.converter.gson) + implementation(libs.gson) + implementation(libs.okhttp3.logging.interceptor) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.web3j) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // Add Mockito dependency + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.android) + androidTestImplementation(libs.mockito.core) + androidTestImplementation(libs.mockito.android) +} + +afterEvaluate { + configure { + publications { + create("release") { + from(components["release"]) + groupId = extra["libraryGroupId"] as String + artifactId = extra["libraryArtifactId"] as String + version = libraryVersion() + } + } + + repositories { + maven { + name = "CircleModularwallets" + url = URI(nexusRepo()) + isAllowInsecureProtocol = true + credentials { + username = nexusUsername() + password = nexusPassword() + } + } + } + } +} \ No newline at end of file diff --git a/lib/proguard-rules.pro b/lib/proguard-rules.pro new file mode 100644 index 0000000..0e8c6da --- /dev/null +++ b/lib/proguard-rules.pro @@ -0,0 +1,38 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +# https://developer.android.com/identity/sign-in/credential-manager#proguard +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} +-keep public class com.circle.modularwallets.core.** { + public *; +} +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialCreationOptions { *; } +-keep class com.circle.modularwallets.core.apis.rp.AuthenticatorSelectionCriteria { *; } +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialRpEntity { *; } +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialUserEntity { *; } +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialRequestOptions { *; } +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialDescriptor { *; } +-keep class com.circle.modularwallets.core.apis.rp.PublicKeyCredentialParameters { *; } +-keep class androidx.credentials.** { *; } +-keep class org.json.** { *; } \ No newline at end of file diff --git a/lib/src/main/AndroidManifest.xml b/lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..294dbaa --- /dev/null +++ b/lib/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/Account.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/Account.kt new file mode 100644 index 0000000..7fe292c --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/Account.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.accounts + +import android.content.Context + +/** + * Abstract class representing an account. + * + * @param T The type of the signed data. + */ +abstract class Account { + + /** + * Retrieves the address of the account. + * + * @return The address of the account. + */ + abstract fun getAddress(): String + + /** + * Signs the given hex data. + * + * @param context The context in which the signing operation is performed. + * @param hex The hex data to sign. + * @return The signed data of type T. + */ + abstract suspend fun sign(context: Context, hex: String): T + + /** + * Signs the given message. + * + * @param context The context in which the signing operation is performed. + * @param message The message to sign. + * @return The signed message of type T. + */ + abstract suspend fun signMessage(context: Context, message: String): T + + /** + * Signs the given typed data. + * + * @param context The context in which the signing operation is performed. + * @param typedData The typed data to sign. + * @return The signed typed data of type T. + */ + abstract suspend fun signTypedData(context: Context, typedData: String): T +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/CircleSmartAccount.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/CircleSmartAccount.kt new file mode 100644 index 0000000..16a0bcf --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/CircleSmartAccount.kt @@ -0,0 +1,592 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.accounts + +import android.content.Context +import com.circle.modularwallets.core.BuildConfig +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.apis.modular.ModularApiImpl +import com.circle.modularwallets.core.apis.modular.ModularWallet +import com.circle.modularwallets.core.apis.modular.ScaConfiguration +import com.circle.modularwallets.core.apis.modular.getCreateWalletReq +import com.circle.modularwallets.core.apis.public.PublicApiImpl +import com.circle.modularwallets.core.apis.util.UtilApiImpl +import com.circle.modularwallets.core.clients.Client +import com.circle.modularwallets.core.constants.CIRCLE_SMART_ACCOUNT_VERSION +import com.circle.modularwallets.core.constants.CIRCLE_SMART_ACCOUNT_VERSION_V1 +import com.circle.modularwallets.core.constants.CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN +import com.circle.modularwallets.core.constants.EIP712_PREFIX +import com.circle.modularwallets.core.constants.FACTORY +import com.circle.modularwallets.core.constants.PUBLIC_KEY_OWN_WEIGHT +import com.circle.modularwallets.core.constants.REPLAY_SAFE_HASH_V1 +import com.circle.modularwallets.core.constants.SALT +import com.circle.modularwallets.core.constants.STUB_SIGNATURE +import com.circle.modularwallets.core.constants.THRESHOLD_WEIGHT +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.SignResult +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.FunctionParameters +import com.circle.modularwallets.core.utils.NonceManager +import com.circle.modularwallets.core.utils.NonceManagerSource +import com.circle.modularwallets.core.utils.abi.encodeAbiParameters +import com.circle.modularwallets.core.utils.abi.encodeCallData +import com.circle.modularwallets.core.utils.abi.encodePacked +import com.circle.modularwallets.core.utils.data.concat +import com.circle.modularwallets.core.utils.data.pad +import com.circle.modularwallets.core.utils.data.slice +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import com.circle.modularwallets.core.utils.encoding.stringToHex +import com.circle.modularwallets.core.utils.encoding.toBytes +import com.circle.modularwallets.core.utils.encoding.toSha3Bytes +import com.circle.modularwallets.core.utils.signature.hashMessage +import com.circle.modularwallets.core.utils.signature.hashTypedData +import com.circle.modularwallets.core.utils.signature.parseP256Signature +import com.circle.modularwallets.core.utils.smartAccount.getMinimumVerificationGasLimit +import com.circle.modularwallets.core.utils.userOperation.getUserOperationHash +import com.circle.modularwallets.core.utils.userOperation.parseFactoryAddressAndData +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Bool +import org.web3j.abi.datatypes.DynamicArray +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.DynamicStruct +import org.web3j.abi.datatypes.StaticStruct +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.Utf8String +import org.web3j.abi.datatypes.generated.Bytes32 +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.abi.datatypes.generated.Uint8 +import org.web3j.crypto.Hash +import org.web3j.utils.Numeric +import java.math.BigInteger +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +internal suspend fun getModularWalletAddress( + transport: Transport, hexPublicKey: String, version: String, name: String? = null +): ModularWallet { + val (x, y) = parseP256Signature(hexPublicKey) + val wallet = + ModularApiImpl.getAddress( + transport, + getCreateWalletReq(x.toString(), y.toString(), version, name) + ) + return wallet +} + +internal suspend fun getComputeWallet( + client: Client, + owner: Account, + version: String +): ModularWallet { + return ModularWallet( + address = getAddressFromWebAuthnOwner(client.transport, owner.getAddress()), + scaConfiguration = ScaConfiguration( + scaCore = version, + ), + ) +} + +internal fun getCurrentDateTime(): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + val currentDate = Date() + return dateFormat.format(currentDate) +} + +internal fun getDefaultWalletName(): String { + return "passkey-${getCurrentDateTime()}" +} + +/** + * Creates a Circle smart account. + * + * @param client The client used to interact with the blockchain. + * @param owner The owner account associated with the Circle smart account. + * @param version The version of the Circle smart account. Default is "circle_passkey_account_v1". + * @param name The wallet name assigned to the newly registered account defaults to the passkey username provided by the end user. + * @return The created Circle smart account. + */ + +@Throws(Exception::class) +@JvmOverloads +suspend fun toCircleSmartAccount( + client: Client, + owner: Account, + version: String = CIRCLE_SMART_ACCOUNT_VERSION_V1, + name: String = getDefaultWalletName() +): CircleSmartAccount { + val actualVersion = CIRCLE_SMART_ACCOUNT_VERSION[version] ?: version + val wallet = + try { + getModularWalletAddress(client.transport, owner.getAddress(), actualVersion, name) + } catch (e: Throwable) { + if (BuildConfig.INTERNAL_BUILD) { + getComputeWallet(client, owner, actualVersion) + } else { + throw e + } + } + val account = CircleSmartAccount( + client, owner, wallet + ) + return account +} + +/** + * Class representing a Circle smart account. + * + * @param client The client used to interact with the blockchain. + * @param owner The owner account associated with the Circle Smart account. + * @param wallet The response containing the created wallet information. + * @param entryPoint The entry point for the smart account. Default is EntryPoint.V07. + */ + +class CircleSmartAccount( + client: Client, + private val owner: Account, + internal val wallet: ModularWallet, + entryPoint: EntryPoint = EntryPoint.V07 +) : SmartAccount(client, entryPoint) { + private var deployed = false + private val nonceManager = NonceManager(object : NonceManagerSource { + override fun get(parameters: FunctionParameters): BigInteger { + return BigInteger.valueOf(System.currentTimeMillis()) + } + + override fun set(parameters: FunctionParameters, nonce: BigInteger) { + } + }) + + /** + * Configuration for the user operation. + */ + override var userOperation: UserOperationConfiguration? = + UserOperationConfiguration { userOperation -> + val minimumVerificationGasLimit = + getMinimumVerificationGasLimit(isDeployed(), client.chain.chainId) + EstimateUserOperationGasResult( + verificationGasLimit = minimumVerificationGasLimit + .max(userOperation.verificationGasLimit ?: BigInteger.ZERO) + ) + } + + /** + * Returns the address of the Circle smart account. + * + * @return The address of the Circle smart account. + */ + override fun getAddress(): String { + return wallet.address + } + + /** + * Encodes the given call data arguments. + * + * @param args The call data arguments to encode. + * @return The encoded call data. + */ + override fun encodeCalls(args: Array): String { + return encodeCallData(args) + } + + /** + * Returns the factory arguments if the account is not deployed. + * + * @return The factory arguments or null if already deployed. + */ + override suspend fun getFactoryArgs(): Pair? { + if (isDeployed()) { + return null + } + wallet.scaConfiguration.initCode?.let { + return parseFactoryAddressAndData(it) + } + return Pair(FACTORY.address, getFactoryData(owner.getAddress())) + } + + /** + * Checks if the account is deployed. + * + * @return True if the account is deployed, false otherwise. + */ + + @Throws(Exception::class) + suspend fun isDeployed(): Boolean { + if (deployed) { + return true + } + try { + val byteCode = PublicApiImpl.getCode(client.transport, getAddress()) + deployed = Numeric.hexStringToByteArray(byteCode).isNotEmpty() + return deployed + } catch (e: Throwable) { + return false + } + } + + /** + * Returns the nonce for the Circle smart account. + * + * @param key Optional key to retrieve the nonce for. + * @return The nonce of the Circle smart account. + */ + + @Throws(Exception::class) + override suspend fun getNonce(key: BigInteger?): BigInteger { + val notNullKey = + key ?: nonceManager.consume(FunctionParameters(getAddress(), client.chain.chainId)) + val nonce = + UtilApiImpl.getNonce(client.transport, getAddress(), entryPoint.address, notNullKey) + return nonce + } + + /** + * Returns the stub signature for the given user operation. + * + * @param userOp The user operation to retrieve the stub signature for. Type T must be the subclass of UserOperation. + * @return The stub signature. + */ + override fun getStubSignature(userOp: T): String { + return STUB_SIGNATURE + } + + /** + * Signs the given hex data. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param hex The hex data to sign. + * @return The signed data. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun sign(context: Context, hex: String): String { + val digest = toSha3Bytes(hex) + val hash = getReplaySafeHash( + client.chain.chainId, getAddress(), bytesToHex(digest) + ) + val signResult = owner.sign(context, hash) + val signature = encodePackedForSignature( + signResult, + owner.getAddress(), + false, + ) + return signature + } + + /** + * Signs the given message. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param message The message to sign. + * @return The signed message. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun signMessage(context: Context, message: String): String { + val digest = toSha3Bytes(hashMessage(message.toByteArray())) + val hash = getReplaySafeHash( + client.chain.chainId, getAddress(), bytesToHex(digest) + ) + val signResult = owner.sign(context, hash) + val signature = encodePackedForSignature( + signResult, + owner.getAddress(), + false, + ) + return signature + } + + /** + * Signs the given typed data. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param typedData The typed data to sign. + * @return The signed typed data. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun signTypedData(context: Context, typedData: String): String { + val digest = toSha3Bytes(hashTypedData(typedData)) + val hash = getReplaySafeHash( + client.chain.chainId, getAddress(), bytesToHex(digest) + ) + val signResult = owner.sign(context, hash) + val signature = encodePackedForSignature( + signResult, + owner.getAddress(), + false, + ) + return signature + } + + /** + * Signs the given user operation. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param chainId The chain ID for the user operation. Default is the chain ID of the client. + * @param userOp The user operation to sign. + * @return The signed user operation. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun signUserOperation( + context: Context, chainId: Long, userOp: UserOperationV07 + ): String { + userOp.sender = getAddress() + val userOpHash = getUserOperationHash(chainId, userOp = userOp) + val hash = hashMessage(userOpHash) + val signResult = owner.sign(context, hash) + val signature = encodePackedForSignature( + signResult, + owner.getAddress(), + true, + ) + return signature + } + + /** + * Returns the initialization code for the Circle smart account. + * + * @return The initialization code. + */ + override suspend fun getInitCode(): String? { + return wallet.getInitCode() + } + +} + +internal fun getReplaySafeHash( + chainId: Long, + account: String, + hash: String, + verifyingContract: String = CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN.address, +): String { + val prefix = Numeric.hexStringToByteArray(EIP712_PREFIX) + val domainSeparatorTypeHash = + toSha3Bytes(REPLAY_SAFE_HASH_V1.domainSeparatorType) + + val domainSeparator = toSha3Bytes( + encodeAbiParameters( + listOf( + Bytes32(domainSeparatorTypeHash), + Bytes32(getModuleIdHash()), + Uint256(chainId), + Address(verifyingContract), + Bytes32(pad(toBytes(account), isRight = true)), + ) + ) + ) + + val structHash = toSha3Bytes( + encodeAbiParameters( + listOf( + Bytes32(getModuleTypeHash()), + Bytes32(Numeric.hexStringToByteArray(hash)) + ) + ) + ) + return bytesToHex( + Hash.sha3( + concat( + prefix, + domainSeparator, + structHash + ) + ) + ) +} + +internal fun getModuleIdHash(): ByteArray { + return toSha3Bytes( + encodePacked( + listOf>( + Utf8String(REPLAY_SAFE_HASH_V1.name), + Utf8String(REPLAY_SAFE_HASH_V1.version), + ) + ) + ) +} + +internal fun getModuleTypeHash(): ByteArray { + return toSha3Bytes( + REPLAY_SAFE_HASH_V1.moduleType + ) +} + +internal fun encodePackedForSignature( + signResult: SignResult, + publicKey: String, + hasUserOpGas: Boolean, +): String { + val (x, y) = parseP256Signature(publicKey) + val sender = getSender(x, y) + + val sigBytes = encodeWebAuthnSigDynamicPart(signResult) + val formattedSender = getFormattedSender(sender) + val sigType: Long = if (hasUserOpGas) 34 else 2 + val encoded = + encodePacked( + listOf>( + Bytes32(formattedSender), + Uint256(65), // dynamicPos + Uint8(sigType), + Uint256(sigBytes.size.toLong()), + DynamicBytes(sigBytes), + ) + ) + + return encoded +} + +internal fun encodeWebAuthnSigDynamicPart(signResult: SignResult): ByteArray { + val (r, s) = parseP256Signature(signResult.signature) + val encoded = encodeParametersWebAuthnSigDynamicPart( + signResult.webAuthn.authenticatorData, + signResult.webAuthn.clientDataJSON, + signResult.webAuthn.challengeIndex.toLong(), + signResult.webAuthn.typeIndex.toLong(), + true, + r, + s + ) + return Numeric.hexStringToByteArray(encoded) +} + +internal fun encodeParametersWebAuthnSigDynamicPart( + authenticatorData: String, + clientDataJSON: String, + challengeIndex: Long, + typeIndex: Long, + requireUserVerification: Boolean, + r: BigInteger, + s: BigInteger +): String { + val encoded = encodeAbiParameters( + listOf>( + DynamicStruct( + DynamicStruct( + DynamicBytes(Numeric.hexStringToByteArray(authenticatorData)), + DynamicBytes(Numeric.hexStringToByteArray(stringToHex(clientDataJSON))), + Uint256(challengeIndex), + Uint256(typeIndex), + Bool(requireUserVerification), + ), + Uint256(r), + Uint256(s), + ) + ) + ) + return encoded +} + +internal fun getFormattedSender(sender: String): ByteArray { + return Numeric.hexStringToByteArray(pad(slice(sender, 2))) +} + +internal fun getPluginInstallParams(x: BigInteger, y: BigInteger): String { + val encoded = encodeAbiParameters( + listOf( + DynamicArray(Address::class.java), DynamicArray(Uint256::class.java), DynamicArray( + StaticStruct::class.java, + StaticStruct( + Uint256(x), + Uint256(y), + ), + ), DynamicArray( + Uint256::class.java, Uint256(PUBLIC_KEY_OWN_WEIGHT) + ), Uint256(THRESHOLD_WEIGHT) + ) + ) + return encoded +} + +internal fun getInitializeUpgradableMSCAParams(x: BigInteger, y: BigInteger): String { + val pluginInstallParams = getPluginInstallParams(x, y) + val encoded = encodeAbiParameters( + listOf( + DynamicArray( + Address::class.java, + Address(CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN.address), + ), + DynamicArray( + Bytes32::class.java, + Bytes32(CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN.manifestHash), + ), + DynamicArray( + DynamicBytes::class.java, + DynamicBytes(Numeric.hexStringToByteArray(pluginInstallParams)), + ), + ) + ) + return encoded +} + +internal fun getSender(x: BigInteger, y: BigInteger): String { + val encoded = getSenderParams(x, y) + return Hash.sha3(encoded) +} + +internal fun getSenderParams(x: BigInteger, y: BigInteger): String { + return encodeAbiParameters( + listOf( + Uint256(x), + Uint256(y), + ) + ) +} + +internal fun getFactoryData(publicKey: String): String { + val (x, y) = parseP256Signature(publicKey) + val sender = getSender(x, y) + val initializeUpgradableMSCAParams = getInitializeUpgradableMSCAParams(x, y) + val function = org.web3j.abi.datatypes.Function( + "createAccount", listOf( + Bytes32(Numeric.hexStringToByteArray(sender)), + Bytes32(SALT), + DynamicBytes(Numeric.hexStringToByteArray(initializeUpgradableMSCAParams)), + ), listOf>(object : TypeReference
() {}) + ) + val factoryData = FunctionEncoder.encode(function) + return factoryData +} + +internal suspend fun getAddressFromWebAuthnOwner(transport: Transport, publicKey: String): String { + val (x, y) = parseP256Signature(publicKey) + val sender = getSender(x, y) + val initializeUpgradableMSCAParams = getInitializeUpgradableMSCAParams(x, y) + + /** address, mixedSalt */ + val result = UtilApiImpl.getAddress( + transport, + FACTORY.address, + Numeric.hexStringToByteArray(sender), + SALT, + Numeric.hexStringToByteArray(initializeUpgradableMSCAParams) + ) + return result.first +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/SmartAccount.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/SmartAccount.kt new file mode 100644 index 0000000..34ba2b6 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/SmartAccount.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.accounts + +import android.content.Context +import com.circle.modularwallets.core.clients.Client +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationV07 +import java.math.BigInteger + +/** + * A Smart Account is an account whose implementation resides in a Smart Contract, and implements the ERC-4337 interface. + * + * @param client The client used to interact with the blockchain. + * @param entryPoint The entry point for the smart account. + */ +abstract class SmartAccount(val client: Client, val entryPoint: EntryPoint) { + + /** + * Configuration for the user operation. + */ + open var userOperation: UserOperationConfiguration? = null + + /** + * Returns the address of the account. + * + * @return The address of the smart account. + */ + abstract fun getAddress(): String + + /** + * Encodes the given call data arguments. + * + * @param args The call data arguments to encode. + * @return The encoded call data. + */ + abstract fun encodeCalls(args: Array): String + + /** + * Returns the factory arguments for the smart account. + * + * @return A pair containing the factory arguments. + */ + abstract suspend fun getFactoryArgs(): Pair? + + /** + * Returns the nonce for the smart account. + * + * @param key Optional key to retrieve the nonce for. + * @return The nonce of the smart account. + */ + abstract suspend fun getNonce(key: BigInteger? = null): BigInteger + + /** + * Returns the stub signature for the given user operation. + * + * @param userOp The user operation to retrieve the stub signature for. Type T must be the subclass of UserOperation. + * @return The stub signature. + */ + abstract fun getStubSignature(userOp: T): String + + /** + * Signs the given hex data. + * + * @param context The context in which the signing operation is performed. + * @param hex The hex data to sign. + * @return The signed data. + */ + abstract suspend fun sign(context: Context, hex: String): String + + /** + * Signs the given message. + * + * @param context The context in which the signing operation is performed. + * @param message The message to sign. + * @return The signed message. + */ + abstract suspend fun signMessage(context: Context, message: String): String + + /** + * Signs the given typed data. + * + * @param context The context in which the signing operation is performed. + * @param typedData The typed data to sign. + * @return The signed typed data. + */ + abstract suspend fun signTypedData(context: Context, typedData: String): String + + /** + * Signs the given user operation. + * + * @param context The context in which the signing operation is performed. + * @param chainId The chain ID for the user operation. Default is the chain ID of the client. + * @param userOp The user operation to sign. + * @return The signed user operation. + */ + abstract suspend fun signUserOperation( + context: Context, + chainId: Long = client.chain.chainId, + userOp: UserOperationV07 + ): String + + /** + * Retrieves the initialization code for the smart account. + * + * @return The initialization code. + */ + abstract suspend fun getInitCode(): String? +} + +data class UserOperationConfiguration @JvmOverloads constructor(var estimateGas: (suspend (UserOperation) -> EstimateUserOperationGasResult)? = null) diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnAccount.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnAccount.kt new file mode 100644 index 0000000..ee80b1d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnAccount.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.accounts + +import android.content.Context +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.models.AuthenticationCredential +import com.circle.modularwallets.core.models.SignResult +import com.circle.modularwallets.core.models.toWebAuthnData +import com.circle.modularwallets.core.utils.fromJson +import com.circle.modularwallets.core.utils.signature.adjustSignature +import com.circle.modularwallets.core.utils.signature.hashMessage +import com.circle.modularwallets.core.utils.signature.hashTypedData +import com.circle.modularwallets.core.utils.signature.parseAsn1Signature +import com.circle.modularwallets.core.utils.signature.serializeSignature +import com.circle.modularwallets.core.utils.webauthn.getRequestOptions +import com.circle.modularwallets.core.utils.webauthn.getSavedCredentials + +/** + * Creates a WebAuthn account. + * + * @param credential The WebAuthn credential associated with the account. + * @return The created WebAuthn account. + */ +fun toWebAuthnAccount(credential: WebAuthnCredential): WebAuthnAccount { + return WebAuthnAccount(credential) +} + +/** + * Class representing a WebAuthn account. + * + * @param credential The WebAuthn credential associated with the account. + */ +open class WebAuthnAccount internal constructor(internal val credential: WebAuthnCredential) : + Account() { + /** + * Retrieves the address of the WebAuthn account. + * + * @return The public key associated with the WebAuthn credential. + */ + override fun getAddress(): String { + return credential.publicKey + } + + /** + * Signs the given hex data. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param hex The hex data to sign. + * @return The result of the signing operation. + * @throws BaseError if the credential request fails. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun sign(context: Context, hex: String): SignResult { + val optionsAndJson = getRequestOptions(credential.rpId, credential.id, hex) + val authRespJson = getSavedCredentials(context, optionsAndJson.second) + val authResp = fromJson(authRespJson, AuthenticationCredential::class.java) + ?: throw BaseError("credential request failed. Get null from json\n${authRespJson}") + val ecdsaSigner = parseAsn1Signature(authResp.response.signature) + val (r, s) = adjustSignature(ecdsaSigner.r, ecdsaSigner.s) + val signatureHex = serializeSignature(r, s) + return SignResult( + signatureHex, + authResp.toWebAuthnData(optionsAndJson.first.userVerification), + authResp + ) + } + + /** + * Signs the given message. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param message The message to sign. + * @return The result of the signing operation. + * @throws BaseError if the credential request fails. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun signMessage(context: Context, message: String): SignResult { + val hash = hashMessage(message.toByteArray()) + return sign(context, hash) + } + + /** + * Signs the given typed data. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param typedData The typed data to sign. + * @return The result of the signing operation. + * @throws BaseError if the credential request fails. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + override suspend fun signTypedData(context: Context, typedData: String): SignResult { + val hash = hashTypedData(typedData) + return sign(context, hash) + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnCredential.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnCredential.kt new file mode 100644 index 0000000..9963c01 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnCredential.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.accounts + +import android.content.Context +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.apis.rp.RpApiImpl +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.models.AuthenticationCredential +import com.circle.modularwallets.core.models.PublicKeyCredential +import com.circle.modularwallets.core.models.RegistrationCredential +import com.circle.modularwallets.core.models.WebAuthnMode +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.fromJson +import com.circle.modularwallets.core.utils.signature.parseCredentialPublicKey +import com.circle.modularwallets.core.utils.signature.serializePublicKey +import com.circle.modularwallets.core.utils.webauthn.createPasskey +import com.circle.modularwallets.core.utils.webauthn.getSavedCredentials + +/** + * Logs in or registers a user and returns a WebAuthnCredential. + * + * @param context The context used to launch framework UI flows ; use an activity context to make sure the UI will be launched within the same task stack. + * @param transport The transport used to communicate with the RP API. + * @param userName The username of the user. (required for WebAuthnMode.Register) + * @param mode The mode of the WebAuthn credential. + * @return The created WebAuthn credential. + * Throws: BaseError if userName is null for WebAuthnMode.Register. + * + */ + +@ExcludeFromGeneratedCCReport +@Throws(Exception::class) +@JvmOverloads +suspend fun toWebAuthnCredential( + context: Context, + transport: Transport, + userName: String? = null, + mode: WebAuthnMode +): WebAuthnCredential { + return when (mode) { + WebAuthnMode.Register -> { + userName ?: throw BaseError("userName cannot be null") + WebAuthnCredential.register(context, transport, userName) + } + + WebAuthnMode.Login -> WebAuthnCredential.login(context, transport) + } +} + +/** + * Data class representing a P-256 WebAuthn Credential. + * + * @param id The unique identifier for the credential. + * @param publicKey The public key associated with the credential. + * @param raw Web Authentication API returned PublicKeyCredential object. + * @param rpId The relying party identifier. + */ +class WebAuthnCredential( + val id: String, + val publicKey: String, + val raw: PublicKeyCredential, + val rpId: String +) { + companion object { + @ExcludeFromGeneratedCCReport + internal suspend fun register( + context: Context, + transport: Transport, + userName: String + ): WebAuthnCredential { + /** 1. RP getRegistrationOptions */ + val rpApi = + RpApiImpl(transport) + val (options, optionsJson) = rpApi.getRegistrationOptions(userName) + /** 2. Create credential */ + val registerRespJson = createPasskey(context, optionsJson).registrationResponseJson + val registerResp = fromJson(registerRespJson, RegistrationCredential::class.java) + ?: throw BaseError("credential request failed. RegistrationCredential is null\n${registerRespJson}") + /** 3. RP getRegistrationVerification */ + rpApi.getRegistrationVerification(registerResp) + /** 4. Parse and serialized public key */ + val publicKey = parseCredentialPublicKey(registerResp.response.publicKey) + val serializedPublicKey = serializePublicKey(publicKey) + return WebAuthnCredential( + registerResp.id, + serializedPublicKey, + registerResp, + options.rp.id + ) + } + + @ExcludeFromGeneratedCCReport + internal suspend fun login( + context: Context, + transport: Transport + ): WebAuthnCredential { + /** 1. RP getLoginOptions */ + val rpApi = + RpApiImpl(transport) + val (options, optionsJson) = rpApi.getLoginOptions() + + /** 2. Get credential */ + val authRespJson = getSavedCredentials(context, optionsJson) + val authResp = fromJson(authRespJson, AuthenticationCredential::class.java) + ?: throw BaseError("credential request failed. AuthenticationCredential is null\n${authRespJson}") + + /** 3. RP getLoginVerification */ + val cPublicKey = rpApi.getLoginVerification(authResp) + + /** 4. Parse and serialized public key */ + val publicKey = parseCredentialPublicKey(cPublicKey) + val serializedPublicKey = serializePublicKey(publicKey) + return WebAuthnCredential(authResp.id, serializedPublicKey, authResp, options.rpId) + } + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/annotation/ExcludeFromGeneratedCCReport.kt b/lib/src/main/java/com/circle/modularwallets/core/annotation/ExcludeFromGeneratedCCReport.kt new file mode 100644 index 0000000..dde00ae --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/annotation/ExcludeFromGeneratedCCReport.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.annotation + +/** + * @suppress + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Target( + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.CLASS, + AnnotationTarget.FIELD, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.LOCAL_VARIABLE, AnnotationTarget.PROPERTY +) +internal annotation class ExcludeFromGeneratedCCReport diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApi.kt new file mode 100644 index 0000000..bd407e6 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApi.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.bundler + +import com.circle.modularwallets.core.accounts.SmartAccount +import com.circle.modularwallets.core.clients.BundlerClient +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EstimateFeesPerGasResult +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.Paymaster +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationRpc +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.transports.Transport + +internal interface BundlerApi { + suspend fun estimateUserOperationGas( + transport: Transport, + userOp: T, + entryPoint: EntryPoint + ): EstimateUserOperationGasResult + + suspend fun getChainId(transport: Transport): String + suspend fun getSupportedEntryPoints(transport: Transport): ArrayList + suspend fun getUserOperation(transport: Transport, userOpHash: String): GetUserOperationResp + suspend fun getUserOperationReceipt( + transport: Transport, + userOpHash: String + ): UserOperationReceiptRpc + + suspend fun prepareUserOperation( + transport: Transport, + account: SmartAccount, + calls: Array?, + partialUserOp: UserOperationV07, + paymaster: Paymaster?, + bundlerClient: BundlerClient, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? + ): UserOperationV07 + + suspend fun sendUserOperation( + transport: Transport, + userOpRpc: UserOperationRpc, + entryPointAddress: String, + ): String + + suspend fun waitForUserOperationReceipt( + transport: Transport, + userOpHash: String, + pollingInterval: Long, + retryCount: Int, + timeout: Long? = null + ): UserOperationReceiptRpc +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApiImpl.kt new file mode 100644 index 0000000..c807deb --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApiImpl.kt @@ -0,0 +1,378 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.bundler + +import com.circle.modularwallets.core.accounts.SmartAccount +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.apis.paymaster.PaymasterApiImpl +import com.circle.modularwallets.core.apis.public.FeeValuesType +import com.circle.modularwallets.core.apis.public.PublicApi +import com.circle.modularwallets.core.apis.public.PublicApiImpl +import com.circle.modularwallets.core.clients.BundlerClient +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.BaseErrorParameters +import com.circle.modularwallets.core.errors.UserOperationReceiptNotFoundError +import com.circle.modularwallets.core.errors.WaitForUserOperationReceiptTimeoutError +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EstimateFeesPerGasResult +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.Paymaster +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationRpc +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.models.toRpcUserOperation +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.Logger +import com.circle.modularwallets.core.utils.error.getUserOperationError +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest +import com.circle.modularwallets.core.utils.unit.parseGwei +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import java.math.BigInteger + +internal object BundlerApiImpl : BundlerApi { + private val publicApi: PublicApi = PublicApiImpl + override suspend fun estimateUserOperationGas( + transport: Transport, + userOp: T, + entryPoint: EntryPoint + ): EstimateUserOperationGasResult { + val userOpRpc = + if (userOp is UserOperationV07) userOp.toRpcUserOperation() else userOp.toRpcUserOperation() + try { + val req = RpcRequest( + "eth_estimateUserOperationGas", + listOf(userOpRpc, entryPoint.address) + ) + val result = + performJsonRpcRequest(transport, req, EstimateUserOperationGasResp::class.java) + return result.first.toResult() + } catch (e: Throwable) { + if (e is BaseError) { + throw getUserOperationError(e, userOpRpc) + } + throw getUserOperationError( + BaseError(e.message ?: "", BaseErrorParameters(cause = e)), + userOpRpc + ) + } + } + + override suspend fun getChainId(transport: Transport): String { + return publicApi.getChainId(transport) + } + + override suspend fun getSupportedEntryPoints(transport: Transport): ArrayList { + val req = RpcRequest("eth_supportedEntryPoints") + val r = performJsonRpcRequest(transport, req) + return when (r) { + is String -> arrayListOf(r) + is List<*> -> r.filterIsInstance().takeIf { it.isNotEmpty() }?.let(::ArrayList) + else -> null + } ?: throw BaseError("getSupportedEntryPoints failed while casting") + } + + override suspend fun waitForUserOperationReceipt( + transport: Transport, + userOpHash: String, + pollingInterval: Long, + retryCount: Int, + timeout: Long? + ): UserOperationReceiptRpc { + try{ + if (timeout != null) { + return withTimeout(timeout) { + return@withTimeout startPolling(pollingInterval, retryCount) { + try { + return@startPolling getUserOperationReceipt(transport, userOpHash) + } catch (e: UserOperationReceiptNotFoundError) { + return@startPolling null + } + } + } ?: throw WaitForUserOperationReceiptTimeoutError(userOpHash) + } else { + return startPolling(pollingInterval, retryCount) { + try { + return@startPolling getUserOperationReceipt(transport, userOpHash) + } catch (e: UserOperationReceiptNotFoundError) { + return@startPolling null + } + } ?: throw WaitForUserOperationReceiptTimeoutError(userOpHash) + } + } catch (e: TimeoutCancellationException){ + throw WaitForUserOperationReceiptTimeoutError(userOpHash) + } + } + + override suspend fun getUserOperation( + transport: Transport, + userOpHash: String + ): GetUserOperationResp { + val req = RpcRequest("eth_getUserOperationByHash", listOf(userOpHash)) + val result = performJsonRpcRequest( + transport, + req, + GetUserOperationResp::class.java, + UserOperationReceiptNotFoundError(userOpHash) + ) + return result.first + } + + override suspend fun getUserOperationReceipt( + transport: Transport, + userOpHash: String + ): UserOperationReceiptRpc { + val req = RpcRequest("eth_getUserOperationReceipt", listOf(userOpHash)) + val result = performJsonRpcRequest( + transport, + req, + UserOperationReceiptRpc::class.java, + UserOperationReceiptNotFoundError(userOpHash) + ) + return result.first + } + + override suspend fun prepareUserOperation( + transport: Transport, + account: SmartAccount, + calls: Array?, + partialUserOp: UserOperationV07, + paymaster: Paymaster?, + bundlerClient: BundlerClient, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? + ): UserOperationV07 { + account.userOperation?.estimateGas?.let { + val r = it.invoke(partialUserOp) + r.preVerificationGas?.let { + partialUserOp.preVerificationGas = it + } + r.verificationGasLimit?.let { + partialUserOp.verificationGasLimit = it + } + r.callGasLimit?.let { + partialUserOp.callGasLimit = it + } + r.paymasterVerificationGasLimit?.let { + partialUserOp.paymasterVerificationGasLimit = it + } + r.paymasterPostOpGasLimit?.let { + partialUserOp.paymasterPostOpGasLimit = it + } + } + + val userOp = partialUserOp.copy() + userOp.sender = account.getAddress() + + calls?.let { + val updatedCalls = getUpdatedCalls(it) + userOp.callData = account.encodeCalls(updatedCalls) + } + + if (partialUserOp.factory.isNullOrBlank() or partialUserOp.factoryData.isNullOrBlank()) { + val arg = account.getFactoryArgs() + arg?.let { + userOp.factory = arg.first + userOp.factoryData = arg.second + } + } + try { + if (partialUserOp.maxFeePerGas == null || partialUserOp.maxPriorityFeePerGas == null) { + if (estimateFeesPerGas == null) { + val defaultMaxFeePerGas = parseGwei("3") + val defaultMaxPriorityFeePerGas = parseGwei("1") + val two = BigInteger.valueOf(2) + val fees = publicApi.estimateFeesPerGas( + account.client.transport, + FeeValuesType.eip1559 + ) + + if (partialUserOp.maxFeePerGas == null) { + userOp.maxFeePerGas = defaultMaxFeePerGas + fees.maxFeePerGas?.let { + userOp.maxFeePerGas = defaultMaxFeePerGas.max(it.multiply(two)) + } + } + if (partialUserOp.maxPriorityFeePerGas == null) { + userOp.maxPriorityFeePerGas = defaultMaxPriorityFeePerGas + fees.maxPriorityFeePerGas?.let { + userOp.maxPriorityFeePerGas = + defaultMaxPriorityFeePerGas.max(it.multiply(two)) + } + } + } else { + val r = estimateFeesPerGas(account, bundlerClient, userOp) + if (partialUserOp.maxFeePerGas == null) { + userOp.maxFeePerGas = r.maxFeePerGas + } + if (partialUserOp.maxPriorityFeePerGas == null) { + userOp.maxPriorityFeePerGas = r.maxPriorityFeePerGas + } + } + } + } catch (e: Throwable) { + e.printStackTrace() + } + + if (partialUserOp.nonce == null) { + userOp.nonce = account.getNonce() + } + if (partialUserOp.signature.isNullOrBlank()) { + userOp.signature = account.getStubSignature(partialUserOp) + } + var isPaymasterPopulated = false + if (paymaster != null) { + val stubR = when (paymaster) { + is Paymaster.True -> { + PaymasterApiImpl.getPaymasterStubData( + transport, + userOp, + account.entryPoint, + bundlerClient.chain.chainId, + paymaster.paymasterContext + ) + } + + is Paymaster.Client -> { + PaymasterApiImpl.getPaymasterStubData( + paymaster.client.transport, + userOp, + account.entryPoint, + bundlerClient.chain.chainId, + paymaster.paymasterContext + ) + } + } + + + isPaymasterPopulated = stubR.isFinal + userOp.paymaster = stubR.paymaster + userOp.paymasterVerificationGasLimit = stubR.paymasterVerificationGasLimit + userOp.paymasterPostOpGasLimit = stubR.paymasterPostOpGasLimit + userOp.paymasterData = stubR.paymasterData + } + + // If not all the gas properties are already populated, we will need to estimate the gas + // to fill the gas properties. + if (userOp.preVerificationGas == null || + userOp.verificationGasLimit == null || + userOp.callGasLimit == null || + (paymaster != null && userOp.paymasterVerificationGasLimit == null) || + (paymaster != null && userOp.paymasterPostOpGasLimit == null) + ) { + // Some Bundlers fail if nullish gas values are provided for gas estimation :') – + // so we will need to set a default zeroish value. + val tmpUserOp = userOp.copy() + tmpUserOp.callGasLimit = BigInteger.ZERO + tmpUserOp.preVerificationGas = BigInteger.ZERO + if(paymaster != null){ + tmpUserOp.paymasterVerificationGasLimit = BigInteger.ZERO + tmpUserOp.paymasterPostOpGasLimit = BigInteger.ZERO + } else{ + tmpUserOp.paymasterVerificationGasLimit = null + tmpUserOp.paymasterPostOpGasLimit = null + } + val r = estimateUserOperationGas(transport, tmpUserOp, account.entryPoint) + userOp.callGasLimit = + if (userOp.callGasLimit == null) r.callGasLimit else userOp.callGasLimit + userOp.preVerificationGas = + if (userOp.preVerificationGas == null) r.preVerificationGas else userOp.preVerificationGas + userOp.verificationGasLimit = + if (userOp.verificationGasLimit == null) r.verificationGasLimit else userOp.verificationGasLimit + userOp.paymasterPostOpGasLimit = + if (userOp.paymasterPostOpGasLimit == null) r.paymasterPostOpGasLimit else userOp.paymasterPostOpGasLimit + userOp.paymasterVerificationGasLimit = + if (userOp.paymasterVerificationGasLimit == null) r.paymasterVerificationGasLimit else userOp.paymasterVerificationGasLimit + } + if (paymaster != null && !isPaymasterPopulated) { + val r = when (paymaster) { + is Paymaster.True -> { + PaymasterApiImpl.getPaymasterData( + transport, + userOp, + account.entryPoint, + bundlerClient.chain.chainId, + paymaster.paymasterContext + ) + } + + is Paymaster.Client -> { + PaymasterApiImpl.getPaymasterData( + paymaster.client.transport, + userOp, + account.entryPoint, + bundlerClient.chain.chainId, + paymaster.paymasterContext + ) + } + } + userOp.paymaster = r.paymaster + userOp.paymasterData = r.paymasterData + } + return userOp + } + + @ExcludeFromGeneratedCCReport + override suspend fun sendUserOperation( + transport: Transport, + userOpRpc: UserOperationRpc, + entryPointAddress: String, + ): String { + try { + val req = RpcRequest("eth_sendUserOperation", listOf(userOpRpc, entryPointAddress)) + val result = performJsonRpcRequest(transport, req) as String + return result + } catch (e: Throwable) { + if (e is BaseError) { + throw getUserOperationError(e, userOpRpc) + } + throw getUserOperationError( + BaseError(e.message ?: "", BaseErrorParameters(cause = e)), + userOpRpc + ) + } + } +} +internal fun getUpdatedCalls(calls: Array): Array{ + val updatedCalls: Array = calls.map { call -> + return@map call.dataUpdated() + }.toTypedArray() + return updatedCalls +} +internal suspend fun startPolling( + pollingInterval: Long, + retryCount: Int, + block: suspend () -> T +): T? { + var currentCount = 0 + while (currentCount < retryCount) { + Logger.i("startPolling", "Polling currentCount: $currentCount") + val result = block() + if (result != null) { + Logger.i("startPolling", "Polling got result: $currentCount") + return result + } + currentCount++ + delay(pollingInterval) + } + Logger.i("startPolling", "Polling no result") + return null +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerReqResp.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerReqResp.kt new file mode 100644 index 0000000..7be518d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerReqResp.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.bundler + +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.UserOperationReceipt +import com.circle.modularwallets.core.models.GetUserOperationResult +import com.circle.modularwallets.core.models.Log +import com.circle.modularwallets.core.models.TransactionReceipt +import com.circle.modularwallets.core.models.UserOperationRpc +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +@JsonClass(generateAdapter = true) +internal data class EstimateUserOperationGasResp( + @Json(name = "preVerificationGas") var preVerificationGasHex: String? = null, + @Json(name = "verificationGasLimit") var verificationGasLimitHex: String? = null, + @Json(name = "callGasLimit") var callGasLimitHex: String? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimitHex: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimitHex: String? = null, +) { + val preVerificationGas: BigInteger? + get() = hexToBigInteger(preVerificationGasHex) + val verificationGasLimit: BigInteger? + get() = hexToBigInteger(verificationGasLimitHex) + val callGasLimit: BigInteger? + get() = hexToBigInteger(callGasLimitHex) + val paymasterVerificationGasLimit: BigInteger? + get() = hexToBigInteger(paymasterVerificationGasLimitHex) + val paymasterPostOpGasLimit: BigInteger? + get() = hexToBigInteger(paymasterPostOpGasLimitHex) +} + +internal fun EstimateUserOperationGasResp.toResult(): EstimateUserOperationGasResult { + return EstimateUserOperationGasResult( + preVerificationGas = this.preVerificationGas, + verificationGasLimit = this.verificationGasLimit, + callGasLimit = this.callGasLimit, + paymasterVerificationGasLimit = this.paymasterVerificationGasLimit, + paymasterPostOpGasLimit = this.paymasterPostOpGasLimit, + ) +} + +internal data class UserOperationReceiptRpc( + val actualGasCost: String?, + val actualGasUsed: String?, + val entryPoint: String?, + val logs: List?, + val nonce: String?, + val paymaster: String?, + val reason: String?, + val receipt: TransactionReceiptRpc, + val sender: String?, + val success: Boolean?, + val userOpHash: String?, +) + +internal data class LogRpc( + val address: String?, + val blockHash: String?, + val blockNumber: String?, + val data: String?, + val logIndex: String?, + val transactionHash: String?, + val transactionIndex: String?, + val removed: Boolean?, + val topics: List?, +) + +internal data class TransactionReceiptRpc( + val blobGasPrice: String?, + val blobGasUsed: String?, + val blockHash: String?, + val blockNumber: String?, + val contractAddress: String?, + val cumulativeGasUsed: String?, + val effectiveGasPrice: String?, + val from: String?, + val gasUsed: String?, + val logs: List?, + val logsBloom: String?, + val root: String?, + val status: String?, + val to: String?, + val transactionHash: String?, + val transactionIndex: String?, + val type: String?, +) + +internal fun LogRpc.toLog(): Log { + return Log( + address = this.address, + blockHash = this.blockHash, + blockNumber = hexToBigInteger(this.blockNumber), + data = this.data, + logIndex = hexToBigInteger(this.logIndex), + transactionHash = this.transactionHash, + transactionIndex = hexToBigInteger(this.transactionIndex), + removed = this.removed, + topics = this.topics, + ) +} + +internal fun TransactionReceiptRpc.toTransactionReceipt(): TransactionReceipt { + return TransactionReceipt( + blobGasPrice = hexToBigInteger(this.blobGasPrice), + blobGasUsed = hexToBigInteger(this.blobGasUsed), + blockHash = this.blockHash, + blockNumber = hexToBigInteger(this.blockNumber), + contractAddress = this.contractAddress, + cumulativeGasUsed = hexToBigInteger(this.cumulativeGasUsed), + effectiveGasPrice = hexToBigInteger(this.effectiveGasPrice), + from = this.from, + gasUsed = hexToBigInteger(this.gasUsed), + logs = this.logs?.map { it.toLog() }, + logsBloom = this.logsBloom, + root = this.root, + status = mapOf("0x0" to "reverted", "0x1" to "success")[this.status], + to = this.to, + transactionHash = this.transactionHash, + transactionIndex = hexToBigInteger(this.transactionIndex), + type = mapOf( + "0x0" to "legacy", + "0x1" to "eip2930", + "0x2" to "eip1559", + "0x3" to "eip4844", + "0x4" to "eip7702" + )[this.type], + ) +} + +internal fun UserOperationReceiptRpc.toUserOperationReceipt(): UserOperationReceipt { + return UserOperationReceipt( + actualGasCost = hexToBigInteger(this.actualGasCost), + actualGasUsed = hexToBigInteger(this.actualGasUsed), + entryPoint = this.entryPoint, + logs = this.logs?.map { it.toLog() }, + nonce = hexToBigInteger(this.nonce), + paymaster = this.paymaster, + reason = this.reason, + receipt = this.receipt.toTransactionReceipt(), + sender = this.sender, + success = this.success, + userOpHash = this.userOpHash, + ) +} + +@JsonClass(generateAdapter = true) +internal data class GetUserOperationResp( + @Json(name = "blockHash") val blockHash: String?, + @Json(name = "blockNumber") val blockNumber: String?, + @Json(name = "transactionHash") val transactionHash: String?, + @Json(name = "entryPoint") val entryPoint: String?, + @Json(name = "userOperation") val userOperation: UserOperationRpc? +) + +internal fun GetUserOperationResp.toResult(): GetUserOperationResult { + return GetUserOperationResult( + blockHash = this.blockHash, + blockNumber = hexToBigInteger(this.blockNumber), + transactionHash = this.transactionHash, + entryPoint = this.entryPoint, + userOperation = this.userOperation + ) + +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApi.kt new file mode 100644 index 0000000..2d78225 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApi.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.modular + +import com.circle.modularwallets.core.transports.Transport + +internal interface ModularApi { + suspend fun getAddress(transport: Transport, getAddressReq: GetAddressReq): ModularWallet + +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApiImpl.kt new file mode 100644 index 0000000..d1c7451 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularApiImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.modular + +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest + +internal object ModularApiImpl : ModularApi { + override suspend fun getAddress( + transport: Transport, + getAddressReq: GetAddressReq + ): ModularWallet { + val req = RpcRequest("circle_getAddress", listOf(getAddressReq)) + val result = performJsonRpcRequest(transport, req, ModularWallet::class.java) + return result.first + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularReqResp.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularReqResp.kt new file mode 100644 index 0000000..e08d8e8 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/modular/ModularReqResp.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.apis.modular + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Represents the Circle modular wallet. + * + * @property address The wallet address. + * @property blockchain The blockchain. + * @property state The state. + * @property name The name of the wallet. + * @property scaConfiguration The SCA configuratio. + */ +@JsonClass(generateAdapter = true) +data class ModularWallet( + @Json(name = "address") val address: String, + @Json(name = "blockchain") val blockchain: String? = null, + @Json(name = "state") val state: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "scaConfiguration") val scaConfiguration: ScaConfiguration, +){ + /** + * Gets the initialization code from the SCA configuration. + * + * @return The initialization code if present, null otherwise. + */ + fun getInitCode(): String? { + return scaConfiguration.initCode + } +} + +@JsonClass(generateAdapter = true) +internal data class GetAddressReq( + @Json(name = "scaConfiguration") val scaConfiguration: ScaConfiguration, + @Json(name = "metadata") val matadata: Metadata, +) + +@JsonClass(generateAdapter = true) +data class ScaConfiguration( + @Json(name = "initialOwnershipConfiguration") val initialOwnershipConfiguration: InitialOwnershipConfiguration? = null, + @Json(name = "scaCore") val scaCore: String?, // req + @Json(name = "initCode") val initCode: String? = null, // resp +) + +@JsonClass(generateAdapter = true) +data class Metadata( + @Json(name = "name") val name: String? = null, +) + +@JsonClass(generateAdapter = true) +data class InitialOwnershipConfiguration( + @Json(name = "weightedMultisig") val weightedMultisig: WeightedMultiSig, + @Json(name = "ownershipContractAddress") val ownershipContractAddress: String? = null, // resp +) + +@JsonClass(generateAdapter = true) +data class WeightedMultiSig( + @Json(name = "webauthnOwners") val webauthnOwners: Array, + @Json(name = "thresholdWeight") val thresholdWeight: Int, + @Json(name = "owners") val owners: Array? = null, +) + +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +data class EoaOwner( + @Json(name = "address") val address: String, + @Json(name = "weight") val weight: Int, +) + +@JsonClass(generateAdapter = true) +data class WebauthnOwner( + @Json(name = "publicKeyX") val publicKeyX: String, + @Json(name = "publicKeyY") val publicKeyY: String, + @Json(name = "weight") val weight: Int, +) + + +internal fun getCreateWalletReq( + publicKeyX: String, + publicKeyY: String, + version: String, + name: String? = null +): GetAddressReq { + return GetAddressReq( + ScaConfiguration( + InitialOwnershipConfiguration( + WeightedMultiSig( + arrayOf( + WebauthnOwner( + publicKeyX, publicKeyY, 1 + ) + ), 1 + ) + ), + version + ), + Metadata(name) + ) +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApi.kt new file mode 100644 index 0000000..e8af1cc --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApi.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.paymaster + +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.IEntryPoint +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.transports.Transport + +internal interface PaymasterApi { + suspend fun getPaymasterData( + transport: Transport, + userOp: T, + entryPoint: IEntryPoint, + chainId: Long, + context: Map? + ): GetPaymasterDataResp + + suspend fun getPaymasterStubData( + transport: Transport, + userOp: T, + entryPoint: IEntryPoint, + chainId: Long, + context: Map? + ): GetPaymasterStubDataResp +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApiImpl.kt new file mode 100644 index 0000000..56fa8e6 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterApiImpl.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.paymaster + +import com.circle.modularwallets.core.models.IEntryPoint +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.models.toRpcUserOperation +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest +import okhttp3.internal.toHexString + +internal object PaymasterApiImpl : PaymasterApi { + override suspend fun getPaymasterData( + transport: Transport, + userOp: T, + entryPoint: IEntryPoint, + chainId: Long, + context: Map? + ): GetPaymasterDataResp { + val rpcUserOp = + if (userOp is UserOperationV07) userOp.toRpcUserOperation() else userOp.toRpcUserOperation() + if (rpcUserOp.callGasLimit == null) { + rpcUserOp.callGasLimit = "0x0" + } + if (rpcUserOp.verificationGasLimit == null) { + rpcUserOp.verificationGasLimit = "0x0" + } + if (rpcUserOp.preVerificationGas == null) { + rpcUserOp.preVerificationGas = "0x0" + } + val param = if (context == null) listOf( + rpcUserOp, + entryPoint.address, + "0x${chainId.toHexString()}", + ) else listOf( + rpcUserOp, + entryPoint.address, + "0x${chainId.toHexString()}", + context + ) + val jsonRpcReq = RpcRequest( + "pm_getPaymasterData", + param + ) + val result = performJsonRpcRequest(transport, jsonRpcReq, GetPaymasterDataResp::class.java) + return result.first + } + + override suspend fun getPaymasterStubData( + transport: Transport, + userOp: T, + entryPoint: IEntryPoint, + chainId: Long, + context: Map? + ): GetPaymasterStubDataResp { + val rpcUserOp = + if (userOp is UserOperationV07) userOp.toRpcUserOperation() else userOp.toRpcUserOperation() + if (rpcUserOp.callGasLimit == null) { + rpcUserOp.callGasLimit = "0x0" + } + if (rpcUserOp.verificationGasLimit == null) { + rpcUserOp.verificationGasLimit = "0x0" + } + if (rpcUserOp.preVerificationGas == null) { + rpcUserOp.preVerificationGas = "0x0" + } + + val param = if (context == null) listOf( + rpcUserOp, + entryPoint.address, + "0x${chainId.toHexString()}", + ) else listOf( + rpcUserOp, + entryPoint.address, + "0x${chainId.toHexString()}", + context + ) + val jsonRpcReq = RpcRequest( + "pm_getPaymasterStubData", param + ) + val result = + performJsonRpcRequest(transport, jsonRpcReq, GetPaymasterStubDataResp::class.java) + return result.first + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterReqResp.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterReqResp.kt new file mode 100644 index 0000000..5847464 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/paymaster/PaymasterReqResp.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.paymaster + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.models.GetPaymasterDataResult +import com.circle.modularwallets.core.models.GetPaymasterStubDataResult +import com.circle.modularwallets.core.models.Sponsor +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class GetPaymasterDataResp( + @Json(name = "paymasterAndData") var paymasterAndData: String? = null, + @Json(name = "paymasterData") var paymasterData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimitHex: String? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimitHex: String? = null, +) { + val paymasterPostOpGasLimit: BigInteger? + get() = hexToBigInteger(paymasterPostOpGasLimitHex) + val paymasterVerificationGasLimit: BigInteger? + get() = hexToBigInteger(paymasterVerificationGasLimitHex) +} + +internal fun GetPaymasterDataResp.toResult(): GetPaymasterDataResult { + return GetPaymasterDataResult( + paymasterAndData = this.paymasterAndData, + paymaster = this.paymaster, + paymasterData = this.paymasterData, + paymasterPostOpGasLimit = this.paymasterPostOpGasLimit, + paymasterVerificationGasLimit = this.paymasterVerificationGasLimit, + ) +} + +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal class GetPaymasterStubDataResp( + @Json(name = "paymasterAndData") var paymasterAndData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterData") var paymasterData: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimitHex: String? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimitHex: String? = null, + @Json(name = "isFinal") var isFinal: Boolean = false, + @Json(name = "sponsor") var sponsor: Sponsor? = null, +) { + val paymasterPostOpGasLimit: BigInteger? + get() = hexToBigInteger(paymasterPostOpGasLimitHex) + val paymasterVerificationGasLimit: BigInteger? + get() = hexToBigInteger(paymasterVerificationGasLimitHex) +} + +internal fun GetPaymasterStubDataResp.toResult(): GetPaymasterStubDataResult { + return GetPaymasterStubDataResult( + paymasterAndData = this.paymasterAndData, + paymaster = this.paymaster, + paymasterData = this.paymasterData, + paymasterPostOpGasLimit = this.paymasterPostOpGasLimit, + paymasterVerificationGasLimit = this.paymasterVerificationGasLimit, + isFinal = this.isFinal, + sponsor = this.sponsor, + ) +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/public/BlockRpc.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/public/BlockRpc.kt new file mode 100644 index 0000000..d4a1a88 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/public/BlockRpc.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.public + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.models.Block +import com.circle.modularwallets.core.models.Withdrawal +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class BlockRpc( + @Json(name = "baseFeePerGas") var baseFeePerGas: String? = null, + @Json(name = "blobGasUsed") var blobGasUsed: String? = null, + @Json(name = "difficulty") var difficulty: String? = null, + @Json(name = "excessBlobGas") var excessBlobGas: String? = null, + @Json(name = "extraData") var extraData: String? = null, + @Json(name = "gasLimit") var gasLimit: String? = null, + @Json(name = "gasUsed") var gasUsed: String? = null, + @Json(name = "hash") var hash: String? = null, + @Json(name = "logsBloom") var logsBloom: String? = null, + @Json(name = "miner") var miner: String? = null, + @Json(name = "mixHash") var mixHash: String? = null, + @Json(name = "nonce") var nonce: String? = null, + @Json(name = "number") var number: String? = null, + @Json(name = "parentBeaconBlockRoot") var parentBeaconBlockRoot: String? = null, + @Json(name = "parentHash") var parentHash: String? = null, + @Json(name = "receiptsRoot") var receiptsRoot: String? = null, + @Json(name = "sha3Uncles") var sha3Uncles: String? = null, + @Json(name = "size") var size: String? = null, + @Json(name = "stateRoot") var stateRoot: String? = null, + @Json(name = "timestamp") var timestamp: String? = null, + @Json(name = "totalDifficulty") var totalDifficulty: String? = null, + @Json(name = "transactions") var transactions: Array? = null, + @Json(name = "transactionsRoot") var transactionsRoot: String? = null, + @Json(name = "uncles") var uncles: Array? = null, + @Json(name = "withdrawals") var withdrawals: Array? = null, + @Json(name = "withdrawalsRoot") var withdrawalsRoot: String? = null, +) + +internal fun BlockRpc.toBlock(): Block { + return Block( + baseFeePerGas = hexToBigInteger(this.baseFeePerGas), + blobGasUsed = hexToBigInteger(this.blobGasUsed), + difficulty = hexToBigInteger(this.difficulty), + excessBlobGas = hexToBigInteger(this.excessBlobGas), + extraData = this.extraData, + gasLimit = hexToBigInteger(this.gasLimit), + gasUsed = hexToBigInteger(this.gasUsed), + hash = this.hash, + logsBloom = this.logsBloom, + miner = this.miner, + mixHash = this.mixHash, + nonce = this.nonce, + number = hexToBigInteger(this.number), + parentBeaconBlockRoot = this.parentBeaconBlockRoot, + parentHash = this.parentHash, + receiptsRoot = this.receiptsRoot, + sha3Uncles = this.sha3Uncles, + size = hexToBigInteger(this.size), + stateRoot = this.stateRoot, + timestamp = hexToBigInteger(this.timestamp), + totalDifficulty = hexToBigInteger(this.totalDifficulty), + transactions = this.transactions, + transactionsRoot = this.transactionsRoot, + uncles = this.uncles, + withdrawals = this.withdrawals, + withdrawalsRoot = this.withdrawalsRoot, + ) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApi.kt new file mode 100644 index 0000000..7104fb8 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApi.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.public + +import com.circle.modularwallets.core.models.Block +import com.circle.modularwallets.core.models.EstimateFeesPerGasResult +import com.circle.modularwallets.core.transports.Transport +import java.math.BigInteger + +interface PublicApi { + /** Returns the number of the most recent block seen. */ + suspend fun getBlockNum(transport: Transport): BigInteger + + /** Returns information about a block at a block number (hex) or tag. */ + suspend fun getBlock( + transport: Transport, + includeTransactions: Boolean = false, + blockNumber: BigInteger + ): Block + + /** Returns information about a block at a block number (hex) or tag. */ + suspend fun getBlock( + transport: Transport, + includeTransactions: Boolean = false, + blockNumberHexOrTag: String = "latest" + ): Block + + /** Returns the chain ID associated with the current network. */ + suspend fun getChainId(transport: Transport): String + + /** Executes a new message call immediately without submitting a transaction to the network. */ + suspend fun call(transport: Transport, from: String?, to: String, data: String): String + + /** + * The type of fee values to return. + * - `legacy`: Returns the legacy gas price. + * - `eip1559`: Returns the max fee per gas and max priority fee per gas. + * */ + suspend fun estimateFeesPerGas( + transport: Transport, + type: FeeValuesType = FeeValuesType.eip1559 + ): EstimateFeesPerGasResult + + /** Returns the current price of gas (in wei) */ + suspend fun getGasPrice( + transport: Transport, + ): BigInteger + /** Retrieves the bytecode at an address. */ + suspend fun getCode(transport: Transport, address: String, blockNumber: BigInteger): String + /** Retrieves the bytecode at an address. */ + suspend fun getCode(transport: Transport, address: String, blockNumberHexOrTag: String = "latest"): String + /** Returns the balance of an address in wei. */ + suspend fun getBalance(transport: Transport, address: String, blockNumber: BigInteger): BigInteger + /** Returns the balance of an address in wei. */ + suspend fun getBalance(transport: Transport, address: String, blockNumberHexOrTag: String = "latest"): BigInteger +} + +enum class FeeValuesType { + legacy, + eip1559 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApiImpl.kt new file mode 100644 index 0000000..a89dc21 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/public/PublicApiImpl.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.public + +import com.circle.modularwallets.core.apis.util.UtilApiImpl +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.models.Block +import com.circle.modularwallets.core.models.EstimateFeesPerGasResult +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.encoding.bigIntegerToHex +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest +import org.web3j.protocol.core.methods.request.Transaction +import java.math.BigInteger +import kotlin.math.ceil +import kotlin.math.pow + +internal object PublicApiImpl : PublicApi { + + override suspend fun getChainId(transport: Transport): String { + val req = RpcRequest("eth_chainId") + val result: String = performJsonRpcRequest(transport, req) as String + return result + } + + override suspend fun call( + transport: Transport, + from: String?, + to: String, + data: String + ): String { + val transaction = Transaction.createEthCallTransaction(from, to, data) + val req = RpcRequest("eth_call", listOf(transaction, "latest")) + val result: String = performJsonRpcRequest(transport, req) as String + return result + } + + override suspend fun estimateFeesPerGas( + transport: Transport, + type: FeeValuesType + ): EstimateFeesPerGasResult { + val baseFeeMultiplier = 1.2 + val block = getBlock(transport) + if (type == FeeValuesType.eip1559) { + block.baseFeePerGas?.let { + val maxPriorityFeePerGas = estimateMaxPriorityFeePerGas(transport, block) + val baseFeePerGas = multiply(it, baseFeeMultiplier) + val maxFeePerGas = baseFeePerGas + maxPriorityFeePerGas + return EstimateFeesPerGasResult(maxFeePerGas, maxPriorityFeePerGas) + } + throw BaseError("Eip1559FeesNotSupportedError") + } + return EstimateFeesPerGasResult(gasPrice = getGasPrice(transport)) + } + + override suspend fun getBalance( + transport: Transport, + address: String, + blockNumber: BigInteger + ): BigInteger { + val hex = bigIntegerToHex(blockNumber) + hex?.let { + return getBalance(transport, address, hex) + } + return getBalance(transport, address) + } + + override suspend fun getBalance( + transport: Transport, + address: String, + blockNumberHexOrTag: String + ): BigInteger { + val req = RpcRequest("eth_getBalance", listOf(address, blockNumberHexOrTag)) + val result: String = performJsonRpcRequest(transport, req) as String + return hexToBigInteger(result) ?: throw BaseError("Failed to transform to BigInteger") + } + + override suspend fun getBlockNum(transport: Transport): BigInteger { + val req = RpcRequest("eth_blockNumber") + val result: String = performJsonRpcRequest(transport, req) as String + return hexToBigInteger(result) ?: throw BaseError("Failed to transform to BigInteger") + } + + override suspend fun getBlock( + transport: Transport, + includeTransactions: Boolean, + blockNumber: BigInteger + ): Block { + val hex = bigIntegerToHex(blockNumber) + hex?.let { + return getBlock(transport, includeTransactions, hex) + } + return getBlock(transport, includeTransactions) + } + + override suspend fun getBlock( + transport: Transport, + includeTransactions: Boolean, + blockNumberHexOrTag: String + ): Block { + val req = RpcRequest( + "eth_getBlockByNumber", + listOf(blockNumberHexOrTag, includeTransactions) + ) + val result = performJsonRpcRequest(transport, req, BlockRpc::class.java) + return result.first.toBlock() + } + + override suspend fun getGasPrice( + transport: Transport, + ): BigInteger { + val req = RpcRequest("eth_gasPrice") + val result: String = performJsonRpcRequest(transport, req) as String + return hexToBigInteger(result) ?: throw BaseError("Failed to transform to BigInteger") + } + + override suspend fun getCode( + transport: Transport, + address: String, + blockNumber: BigInteger + ): String { + val hex = bigIntegerToHex(blockNumber) + hex?.let { + return getCode(transport, address, hex) + } + return getCode(transport, address) + } + + override suspend fun getCode( + transport: Transport, + address: String, + blockNumberHexOrTag: String + ): String { + val req = RpcRequest("eth_getCode", listOf(address, blockNumberHexOrTag)) + val result: String = performJsonRpcRequest(transport, req) as String + return result + + } + + private fun multiply(base: BigInteger, baseFeeMultiplier: Double): BigInteger { + val decimals = baseFeeMultiplier.toString().split(".").getOrNull(1)?.length ?: 0 + val denominator = 10.0.pow(decimals).toLong() + val scaledMultiplier = ceil(baseFeeMultiplier * denominator).toLong() + return (base * BigInteger.valueOf(scaledMultiplier)) / BigInteger.valueOf(denominator) + } + + private suspend fun estimateMaxPriorityFeePerGas( + transport: Transport, + block: Block + ): BigInteger { + return try { + UtilApiImpl.getMaxPriorityFeePerGas(transport) + } catch (e: Throwable) { + estimateMaxPriorityFeePerGasFallback(transport, block) + } + } + + internal suspend fun estimateMaxPriorityFeePerGasFallback( + transport: Transport, + block: Block + ): BigInteger { + block.baseFeePerGas?.let { + val gasPrice = getGasPrice(transport) + val maxPriorityFeePerGas = gasPrice - it + return maxPriorityFeePerGas.max(BigInteger.ZERO) + } + throw BaseError("Eip1559FeesNotSupportedError") + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/rp/PublicKeyCredentialOptions.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/PublicKeyCredentialOptions.kt new file mode 100644 index 0000000..fdf4761 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/PublicKeyCredentialOptions.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.apis.rp + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +// PublicKeyCredentialCreationOptions +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialCreationOptions( + @Json(name = "challenge") val challenge: String, + @Json(name = "rp") val rp: PublicKeyCredentialRpEntity, + @Json(name = "pubKeyCredParams") val pubKeyCredParams: List = listOf( + PublicKeyCredentialParameters(-7, "public-key"), + PublicKeyCredentialParameters(-257, "public-key"), + ), + @Json(name = "authenticatorSelection") val authenticatorSelection: AuthenticatorSelectionCriteria = AuthenticatorSelectionCriteria( + residentKey = "required" + ), + @Json(name = "user") var user: PublicKeyCredentialUserEntity, + @Json(name = "timeout") val timeout: Long? = null, + @Json(name = "excludeCredentials") val excludeCredentials: MutableList? = null, + @Json(name = "attestation") val attestation: String? = null, + @Json(name = "extensions") val extensions: String? = null, +) +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class AuthenticatorSelectionCriteria( + @Json(name = "authenticatorAttachment") val authenticatorAttachment: String? = null, + @Json(name = "residentKey") val residentKey: String? = null, + @Json(name = "requireResidentKey") val requireResidentKey: Boolean? = null, + @Json(name = "userVerification") val userVerification: String? = null, +) +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialRpEntity( + @Json(name = "id") var id: String, + @Json(name = "name") val name: String = "", +) +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialParameters( + @Json(name = "alg") val alg: Int, + @Json(name = "type") val type: String, +) +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialUserEntity( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "displayName") val displayName: String, +) + +// PublicKeyCredentialRequestOptions +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialRequestOptions( + @Json(name = "challenge") val challenge: String, + @Json(name = "rpId") var rpId: String, + @Json(name = "allowCredentials") var allowCredentials: MutableList? = null, + @Json(name = "timeout") val timeout: Long = 1800000, + @Json(name = "userVerification") val userVerification: String = "required", +) +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class PublicKeyCredentialDescriptor( + @Json(name = "id") val id: String, + @Json(name = "transports") val transports: List? = null, + @Json(name = "type") val type: String = "public-key", +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApi.kt new file mode 100644 index 0000000..6d35ca1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApi.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + + +package com.circle.modularwallets.core.apis.rp + +import com.circle.modularwallets.core.models.AuthenticationCredential +import com.circle.modularwallets.core.models.RegistrationCredential + +internal interface RpApi { + suspend fun getRegistrationOptions(userName: String): Pair + suspend fun getRegistrationVerification(registrationCredential: RegistrationCredential): Boolean + suspend fun getLoginOptions(): Pair + suspend fun getLoginVerification(authenticationCredential: AuthenticationCredential): String +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApiImpl.kt new file mode 100644 index 0000000..5700b54 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpApiImpl.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.apis.rp + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.models.AuthenticationCredential +import com.circle.modularwallets.core.models.RegistrationCredential +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest +import com.circle.modularwallets.core.utils.toJson + +internal class RpApiImpl(val transport: Transport) : RpApi { + override suspend fun getRegistrationOptions(userName: String): Pair { + val req = RpcRequest("rp_getRegistrationOptions", listOf(userName)) + val result = + performJsonRpcRequest(transport, req, PublicKeyCredentialCreationOptions::class.java) + return Pair( + result.first, + toJson(result.first) + ) + } + + @ExcludeFromGeneratedCCReport + override suspend fun getRegistrationVerification(registrationCredential: RegistrationCredential): Boolean { + val req = RpcRequest( + "rp_getRegistrationVerification", + listOf(registrationCredential) + ) + return performJsonRpcRequest(transport, req) as Boolean + } + + override suspend fun getLoginOptions(): Pair { + val req = RpcRequest("rp_getLoginOptions") + val result = + performJsonRpcRequest(transport, req, PublicKeyCredentialRequestOptions::class.java) + return Pair( + result.first, + toJson(result.first) + ) + } + + @ExcludeFromGeneratedCCReport + override suspend fun getLoginVerification(authenticationCredential: AuthenticationCredential): String { + val req = + RpcRequest("rp_getLoginVerification", listOf(authenticationCredential)) + val result = performJsonRpcRequest(transport, req, GetLoginVerificationResp::class.java) + return result.first.publicKey + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpReqResp.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpReqResp.kt new file mode 100644 index 0000000..d0e3275 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/rp/RpReqResp.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.rp + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@ExcludeFromGeneratedCCReport +@JsonClass(generateAdapter = true) +internal data class GetLoginVerificationResp( + @Json(name = "publicKey") val publicKey: String, +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApi.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApi.kt new file mode 100644 index 0000000..256c9d3 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApi.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.util + +import com.circle.modularwallets.core.constants.CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN +import com.circle.modularwallets.core.transports.Transport +import java.math.BigInteger + +internal interface UtilApi { + suspend fun getAddress( + transport: Transport, + to: String, + sender: ByteArray, + salt: ByteArray, + initializingData: ByteArray, + ): Pair + + suspend fun createAccount( + transport: Transport, + to: String, + sender: String, + salt: ByteArray, + initializingData: ByteArray, + ): String + + suspend fun getNonce( + transport: Transport, + address: String, + to: String, + key: BigInteger = BigInteger.ZERO + ): BigInteger + + suspend fun getMaxPriorityFeePerGas( + transport: Transport, + ): BigInteger + + suspend fun isValidSignature( + transport: Transport, + message: String, + signature: String, + from: String, + to: String = CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN.address + ): Boolean + +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApiImpl.kt b/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApiImpl.kt new file mode 100644 index 0000000..708f39a --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/apis/util/UtilApiImpl.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.apis.util + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.apis.public.PublicApiImpl.call +import com.circle.modularwallets.core.constants.EIP1271_VALID_SIGNATURE +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.Logger +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.circle.modularwallets.core.utils.encoding.toSha3Bytes +import com.circle.modularwallets.core.utils.rpc.performJsonRpcRequest +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.FunctionReturnDecoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.Function +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.generated.Bytes32 +import org.web3j.abi.datatypes.generated.Bytes4 +import org.web3j.abi.datatypes.generated.Uint192 +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.utils.Numeric +import java.math.BigInteger + +internal object UtilApiImpl : UtilApi { + override suspend fun getAddress( + transport: Transport, + to: String, + sender: ByteArray, + salt: ByteArray, + initializingData: ByteArray, + ): Pair { + val function = Function( + "getAddress", + listOf>(Bytes32(sender), Bytes32(salt), DynamicBytes(initializingData)), + listOf>( + object : TypeReference
() {}, + object : TypeReference() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d(msg = "getAddress > call") + val resp = call(transport, null, to, data) + val decoded = FunctionReturnDecoder.decode(resp, function.outputParameters) + return Pair(decoded[0].value as String, Numeric.toHexString(decoded[1].value as ByteArray)) + } + + override suspend fun createAccount( + transport: Transport, + to: String, + sender: String, + salt: ByteArray, + initializingData: ByteArray, + ): String { + val function = Function( + "createAccount", + listOf>( + Bytes32(Numeric.hexStringToByteArray(sender)), + Bytes32(salt), + DynamicBytes(initializingData) + ), + listOf>( + object : TypeReference
() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d(msg = "createAccount > call") + val resp = call(transport, null, to, data) + val decoded = FunctionReturnDecoder.decode(resp, function.outputParameters) + return decoded[0].value as String + } + + override suspend fun getNonce( + transport: Transport, + address: String, + to: String, + key: BigInteger + ): BigInteger { + val function = Function( + "getNonce", + listOf>(Address(address), Uint192(key)), + listOf>(object : TypeReference() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d(msg = "getNonce > call") + val resp = call(transport, null, to, data) + return hexToBigInteger(resp) ?: throw BaseError("Failed to transform to BigInteger") + } + + override suspend fun getMaxPriorityFeePerGas( + transport: Transport, + ): BigInteger { + val req = RpcRequest("eth_maxPriorityFeePerGas") + val result: String = performJsonRpcRequest(transport, req) as String + return hexToBigInteger(result) ?: throw BaseError("Failed to transform to BigInteger") + } + + @ExcludeFromGeneratedCCReport + override suspend fun isValidSignature( + transport: Transport, + message: String, + signature: String, + from: String, + to: String + ): Boolean { + val digest = toSha3Bytes(message) + val function = Function( + "isValidSignature", + listOf>(Bytes32(digest), DynamicBytes(Numeric.hexStringToByteArray(signature))), + listOf>( + object : TypeReference() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d(msg = "isValidSignature > call") + val resp = call(transport, from, to, data) + val decoded = FunctionReturnDecoder.decode(resp, function.outputParameters) + return EIP1271_VALID_SIGNATURE.contentEquals(decoded[0].value as ByteArray) + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/Arbitrum.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/Arbitrum.kt new file mode 100644 index 0000000..905e5d1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/Arbitrum.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object Arbitrum : Chain() { + override val chainId: Long + get() = 42161 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/ArbitrumSepolia.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/ArbitrumSepolia.kt new file mode 100644 index 0000000..ceff56f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/ArbitrumSepolia.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object ArbitrumSepolia : Chain() { + override val chainId: Long + get() = 421614 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/Chain.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/Chain.kt new file mode 100644 index 0000000..ec68fa1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/Chain.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +/** + * Abstract class representing a blockchain. + * + * @property chainId The unique identifier for the blockchain. + */ +abstract class Chain { + abstract val chainId: Long +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/Mainnet.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/Mainnet.kt new file mode 100644 index 0000000..4a82d15 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/Mainnet.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object Mainnet : Chain() { + override val chainId: Long + get() = 1 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/Polygon.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/Polygon.kt new file mode 100644 index 0000000..f479e28 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/Polygon.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object Polygon : Chain() { + override val chainId: Long + get() = 137 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/PolygonAmoy.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/PolygonAmoy.kt new file mode 100644 index 0000000..cc7ea3a --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/PolygonAmoy.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object PolygonAmoy : Chain() { + override val chainId: Long + get() = 80_002 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/chains/Sepolia.kt b/lib/src/main/java/com/circle/modularwallets/core/chains/Sepolia.kt new file mode 100644 index 0000000..026f6b0 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/chains/Sepolia.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.chains + +object Sepolia : Chain() { + override val chainId: Long + get() = 11155111 +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/clients/BundlerClient.kt b/lib/src/main/java/com/circle/modularwallets/core/clients/BundlerClient.kt new file mode 100644 index 0000000..f0e7758 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/clients/BundlerClient.kt @@ -0,0 +1,391 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.clients + +import android.content.Context +import com.circle.modularwallets.core.accounts.SmartAccount +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.apis.bundler.BundlerApi +import com.circle.modularwallets.core.apis.bundler.BundlerApiImpl +import com.circle.modularwallets.core.apis.bundler.toResult +import com.circle.modularwallets.core.apis.bundler.toUserOperationReceipt +import com.circle.modularwallets.core.apis.public.PublicApi +import com.circle.modularwallets.core.apis.public.PublicApiImpl +import com.circle.modularwallets.core.apis.util.UtilApi +import com.circle.modularwallets.core.apis.util.UtilApiImpl +import com.circle.modularwallets.core.chains.Chain +import com.circle.modularwallets.core.models.Block +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EstimateFeesPerGasResult +import com.circle.modularwallets.core.models.EstimateUserOperationGasResult +import com.circle.modularwallets.core.models.GetUserOperationResult +import com.circle.modularwallets.core.models.Paymaster +import com.circle.modularwallets.core.models.UserOperationReceipt +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.models.toRpcUserOperation +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.encoding.hexToLong +import java.math.BigInteger + +class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transport) { + private val api: BundlerApi = BundlerApiImpl + private val pubApi: PublicApi = PublicApiImpl + private val utilApi: UtilApi = UtilApiImpl + + /** + * Estimates the gas values for a User Operation to be executed successfully. + * + * @param account The Account to use for User Operation execution. + * @param calls The calls to execute in the User Operation. + * @param paymaster Sets Paymaster configuration for the User Operation. + * @param estimateFeesPerGas Prepares fee properties for the User Operation request. Not available in Java. + * @return The estimated gas values for the User Operation. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun estimateUserOperationGas( + account: SmartAccount, + calls: Array, + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): EstimateUserOperationGasResult { + val userOp = when (account.entryPoint) { + EntryPoint.V07 -> { + api.prepareUserOperation( + transport, + account, + calls, + UserOperationV07(), + paymaster, + this, + estimateFeesPerGas + ) + } + } + return api.estimateUserOperationGas(transport, userOp, account.entryPoint) + } + + /** + * Returns the chain ID associated with the current network + * + * @return The current chain ID. + */ + + @Throws(Exception::class) + suspend fun getChainId(): Long { + val result = api.getChainId(transport) + return hexToLong(result) + } + + /** + * Returns the EntryPoints that the bundler supports. + * + * @return The EntryPoints that the bundler supports. + */ + + @Throws(Exception::class) + suspend fun getSupportedEntryPoints(): ArrayList { + return api.getSupportedEntryPoints(transport) + } + + /** + * Retrieves information about a User Operation given a hash. + * + * @param userOpHash User Operation hash. + * @return User Operation information. + */ + + @Throws(Exception::class) + suspend fun getUserOperation(userOpHash: String): GetUserOperationResult { + return api.getUserOperation(transport, userOpHash).toResult() + } + + /** + * Returns the User Operation Receipt given a User Operation hash. + * + * @param userOpHash User Operation hash. + * @return The User Operation receipt. + */ + + @Throws(Exception::class) + suspend fun getUserOperationReceipt(userOpHash: String): UserOperationReceipt { + return api.getUserOperationReceipt(transport, userOpHash).toUserOperationReceipt() + } + + /** + * Broadcasts a User Operation to the Bundler. + * + * @param context The context used to launch any UI needed; use an activity context to make sure the UI will be launched within the same task stack + * @param account The Account to use for User Operation execution. + * @param calls The calls to execute in the User Operation + * @param partialUserOp The partial User Operation to be completed + * @param paymaster Sets Paymaster configuration for the User Operation. + * @param estimateFeesPerGas Prepares fee properties for the User Operation request. Not available in Java. + * @return The hash of the sent User Operation. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + @JvmOverloads + suspend fun sendUserOperation( + context: Context, + account: SmartAccount, + calls: Array?, + partialUserOp: UserOperationV07 = UserOperationV07(), + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): String { + if (!partialUserOp.signature.isNullOrBlank()) { + return api.sendUserOperation( + transport, + partialUserOp.toRpcUserOperation(), + account.entryPoint.address + ) + } + val userOp = when (account.entryPoint) { + EntryPoint.V07 -> { + api.prepareUserOperation( + transport, + account, + calls, + partialUserOp, + paymaster, + this, + estimateFeesPerGas + ) + } + } + userOp.signature = account.signUserOperation(context, chain.chainId, userOp) + return api.sendUserOperation( + transport, + userOp.toRpcUserOperation(), + account.entryPoint.address + ) + } + + /** + * Prepares a User Operation for execution and fills in missing properties. + * + * @param account The Account to use for User Operation execution. + * @param calls The calls to execute in the User Operation + * @param partialUserOp The partial User Operation to be completed + * @param paymaster Sets Paymaster configuration for the User Operation. + * @param estimateFeesPerGas Prepares fee properties for the User Operation request. Not available in Java. + * @return The prepared User Operation. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun prepareUserOperation( + account: SmartAccount, + calls: Array?, + partialUserOp: UserOperationV07, + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): UserOperationV07 { + return api.prepareUserOperation( + transport, + account, + calls, + partialUserOp, + paymaster, + this, + estimateFeesPerGas + ) + } + + /** + * Waits for the User Operation to be included on a Block (one confirmation), and then returns the User Operation receipt. + * + * @param userOpHash A User Operation hash. + * @param pollingInterval Polling frequency (in ms). + * @param retryCount The number of times to retry. + * @param timeout Optional timeout (in ms) to wait before stopping polling. + * @return The User Operation receipt. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun waitForUserOperationReceipt( + userOpHash: String, + pollingInterval: Long = 4000, + retryCount: Int = 6, + timeout: Long? = null + ): UserOperationReceipt { + return api.waitForUserOperationReceipt( + transport, + userOpHash, + pollingInterval, + retryCount, + timeout + ).toUserOperationReceipt() + } + + /** + * Retrieves the balance of the specified address at a given block tag. + * + * @param address The address to query the balance for. Only wallet addresses that registered with the using client key can be retrieved + * @param blockNumber The balance of the account at a block number. + * @return The balance of the address in wei. + */ + + @Throws(Exception::class) + suspend fun getBalance(address: String, blockNumber: BigInteger): BigInteger { + val result = pubApi.getBalance(transport, address, blockNumber) + return result + } + + /** + * Retrieves the balance of the specified address at a given block tag. + * + * @param address The address to query the balance for. Only wallet addresses that registered with the using client key can be retrieved + * @param blockTag The balance of the account at a block tag. + * @return The balance of the address in wei. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getBalance(address: String, blockTag: String = "latest"): BigInteger { + val result = pubApi.getBalance(transport, address, blockTag) + return result + } + + /** + * Returns the number of the most recent block seen. + * + * @return The number of the block. + */ + + @Throws(Exception::class) + suspend fun getBlockNumber(): BigInteger { + val result = pubApi.getBlockNum(transport) + return result + } + + /** + * Returns the current price of gas (in wei). + * + * @return The gas price (in wei). + */ + + @Throws(Exception::class) + suspend fun getGasPrice(): BigInteger { + val result = pubApi.getGasPrice(transport) + return result + } + + /** + * Executes a new message call immediately without submitting a transaction to the network. + * + * @param from The Account to call from. + * @param to The contract address or recipient. + * @param data A contract hashed method call with encoded args. + * @return The result of the call. + */ + + @Throws(Exception::class) + suspend fun call( + from: String?, + to: String, + data: String + ): String { + return pubApi.call(transport, from, to, data) + } + + /** + * Retrieves the bytecode at an address. + * + * @param address The contract address. + * @param blockNumber The block number to perform the bytecode read against. + * @return The code of the specified address at the given block number. + */ + + @Throws(Exception::class) + suspend fun getCode( + address: String, + blockNumber: BigInteger + ): String { + return pubApi.getCode(transport, address, blockNumber) + } + + /** + * Retrieves the bytecode at an address. + * + * @param address The contract address. + * @param blockTag The block tag to perform the bytecode read against. + * @return The code of the specified address at the given block tag. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getCode( + address: String, + blockTag: String = "latest" + ): String { + return pubApi.getCode(transport, address, blockTag) + } + + /** + * Returns an estimate for the max priority fee per gas (in wei) for a transaction to be likely included in the next block. + * The Action will either call eth_maxPriorityFeePerGas (if supported) or manually calculate the max priority fee per gas based on the current block base fee per gas + gas price. + * + * @return An estimate (in wei) for the max priority fee per gas. + */ + + @Throws(Exception::class) + suspend fun estimateMaxPriorityFeePerGas( + ): BigInteger { + return utilApi.getMaxPriorityFeePerGas(transport) + } + + /** + * Returns information about a block at a given block number. + * + * @param includeTransactions Whether or not to include transactions (as a structured array of Transaction objects). Default is false. + * @param blockNumber The block number to query the information for. + * @return Information about the block. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getBlock( + includeTransactions: Boolean = false, + blockNumber: BigInteger + ): Block { + return pubApi.getBlock(transport, includeTransactions, blockNumber) + } + + /** + * Returns information about a block at a given block tag. + * + * @param includeTransactions Whether or not to include transactions (as a structured array of Transaction objects). Default is false. + * @param blockTag The block tag to query the information for. Default is "latest". + * @return Information about the block. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getBlock( + includeTransactions: Boolean = false, + blockTag: String = "latest" + ): Block { + return pubApi.getBlock(transport, includeTransactions, blockTag) + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/clients/Client.kt b/lib/src/main/java/com/circle/modularwallets/core/clients/Client.kt new file mode 100644 index 0000000..ddc04a1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/clients/Client.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.clients + +import com.circle.modularwallets.core.chains.Chain +import com.circle.modularwallets.core.transports.Transport + +/** + * Represents a client that interacts with a blockchain. + * + * @property chain The blockchain that the client interacts with. + * @property transport The transport mechanism used for making RPC requests. + */ +open class Client(val chain: Chain, val transport: Transport) diff --git a/lib/src/main/java/com/circle/modularwallets/core/clients/PaymasterClient.kt b/lib/src/main/java/com/circle/modularwallets/core/clients/PaymasterClient.kt new file mode 100644 index 0000000..71827fc --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/clients/PaymasterClient.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.clients + +import com.circle.modularwallets.core.apis.paymaster.PaymasterApi +import com.circle.modularwallets.core.apis.paymaster.PaymasterApiImpl +import com.circle.modularwallets.core.apis.paymaster.toResult +import com.circle.modularwallets.core.chains.Chain +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.GetPaymasterDataResult +import com.circle.modularwallets.core.models.GetPaymasterStubDataResult +import com.circle.modularwallets.core.models.IEntryPoint +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.transports.Transport + +class PaymasterClient(chain: Chain, transport: Transport) : Client(chain, transport) { + private val api: PaymasterApi = PaymasterApiImpl + + /** + * Retrieves Paymaster data for a given User Operation. + * + * @param T The User Operation to retrieve Paymaster data for. Type T must be the subclass of UserOperation + * @param userOp The User Operation to retrieve Paymaster data for. + * @param entryPoint EntryPoint address to target. + * @param context Paymaster specific fields. + * @return Paymaster-related User Operation properties. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getPaymasterData( + userOp: T, + entryPoint: EntryPoint, + context: Map? = null + ): GetPaymasterDataResult { + return api.getPaymasterData(transport, userOp, entryPoint, chain.chainId, context) + .toResult() + } + + /** + * Retrieves Paymaster stub data for a given User Operation. + * + * @param T The User Operation to retrieve Paymaster stub data for. Type T must be the subclass of UserOperation. + * @param userOp The User Operation to retrieve Paymaster stub data for. + * @param entryPoint EntryPoint address to target. + * @param context Paymaster specific fields. + * @return Paymaster-related User Operation properties. + */ + + @Throws(Exception::class) + @JvmOverloads + suspend fun getPaymasterStubData( + userOp: T, + entryPoint: IEntryPoint, + context: Map? = null + ): GetPaymasterStubDataResult { + return api.getPaymasterStubData(transport, userOp, entryPoint, chain.chainId, context) + .toResult() + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/constants/AbiConstants.kt b/lib/src/main/java/com/circle/modularwallets/core/constants/AbiConstants.kt new file mode 100644 index 0000000..24a07b1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/constants/AbiConstants.kt @@ -0,0 +1,1843 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.constants + +import com.circle.modularwallets.core.models.Token + +val ABI_FUNCTION_TRANSFER = "transfer" + +internal val CONTRACT_ADDRESS: Map = mapOf( + Token.Arbitrum_USDC.name to "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + Token.Arbitrum_ARB.name to "0x912CE59144191C1204E64559FE8253a0e49E6548", + Token.ArbitrumSepolia_USDC.name to "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", + Token.Polygon_USDC.name to "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + Token.PolygonAmoy_USDC.name to "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582", +) +val CIRCLE_PLUGIN_ADD_OWNERS_ABI = """ +[ + { + "inputs": [ + { + "internalType": "address[]", + "name": "ownersToAdd", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "weightsToAdd", + "type": "uint256[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "x", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "y", + "type": "uint256" + } + ], + "internalType": "struct PublicKey[]", + "name": "publicKeyOwnersToAdd", + "type": "tuple[]" + }, + { + "internalType": "uint256[]", + "name": "pubicKeyWeightsToAdd", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "newThresholdWeight", + "type": "uint256" + } + ], + "name": "addOwners", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] +""".trimIndent() +val ABI_ERC20 = """ +[ + { + "type": "event", + "name": "Approval", + "inputs": [ + { + "indexed": true, + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ] + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ] + }, + { + "type": "function", + "name": "allowance", + "stateMutability": "view", + "inputs": [ + { + "name": "owner", + "type": "address" + }, + { + "name": "spender", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "type": "function", + "name": "approve", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "spender", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + }, + { + "type": "function", + "name": "balanceOf", + "stateMutability": "view", + "inputs": [ + { + "name": "account", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "type": "function", + "name": "decimals", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8" + } + ] + }, + { + "type": "function", + "name": "name", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string" + } + ] + }, + { + "type": "function", + "name": "symbol", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string" + } + ] + }, + { + "type": "function", + "name": "totalSupply", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256" + } + ] + }, + { + "type": "function", + "name": "transfer", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "recipient", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + }, + { + "type": "function", + "name": "transferFrom", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "sender", + "type": "address" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool" + } + ] + } +] +""".trimIndent() + +val CIRCLE_MSCA_6900_V1_EP07_FACTORY_ABI = """ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + }, + { + "internalType": "address", + "name": "_entryPointAddr", + "type": "address" + }, + { + "internalType": "address", + "name": "_pluginManagerAddr", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "Create2FailedDeployment", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitializationInput", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidLength", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + } + ], + "name": "PluginIsNotAllowed", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "proxy", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "sender", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + } + ], + "name": "AccountCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "factory", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "accountImplementation", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "entryPoint", + "type": "address" + } + ], + "name": "FactoryDeployed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [], + "name": "ACCOUNT_IMPLEMENTATION", + "outputs": [ + { + "internalType": "contract UpgradableMSCA", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ENTRY_POINT", + "outputs": [ + { + "internalType": "contract IEntryPoint", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "_unstakeDelaySec", + "type": "uint32" + } + ], + "name": "addStake", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_sender", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "_salt", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "_initializingData", + "type": "bytes" + } + ], + "name": "createAccount", + "outputs": [ + { + "internalType": "contract UpgradableMSCA", + "name": "account", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_sender", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "_salt", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "_initializingData", + "type": "bytes" + } + ], + "name": "getAddress", + "outputs": [ + { + "internalType": "address", + "name": "addr", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "mixedSalt", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isPluginAllowed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pendingOwner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_plugins", + "type": "address[]" + }, + { + "internalType": "bool[]", + "name": "_permissions", + "type": "bool[]" + } + ], + "name": "setPlugins", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unlockStake", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address payable", + "name": "_withdrawAddress", + "type": "address" + } + ], + "name": "withdrawStake", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] +""".trimIndent() +val CIRCLE_MSCA_6900_V1_EP07_ABI = """ +[ + { + "inputs": [ + { + "internalType": "contract IEntryPoint", + "name": "_newEntryPoint", + "type": "address" + }, + { + "internalType": "contract PluginManager", + "name": "_newPluginManager", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "ExecFromPluginToSelectorNotPermitted", + "type": "error" + }, + { + "inputs": [], + "name": "ExecuteFromPluginToExternalNotPermitted", + "type": "error" + }, + { + "inputs": [], + "name": "FailedInnerCall", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAuthorizer", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "InvalidExecutionFunction", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "name": "InvalidHookFunctionId", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitializationInput", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidLimit", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "name": "InvalidValidationFunctionId", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + } + ], + "name": "NativeTokenSpendingNotPermitted", + "type": "error" + }, + { + "inputs": [], + "name": "NotFoundSelector", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "NotNativeFunctionSelector", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "PostExecHookFailed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "PreExecHookFailed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "PreRuntimeValidationHookFailed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + }, + { + "internalType": "bytes", + "name": "revertReason", + "type": "bytes" + } + ], + "name": "RuntimeValidationFailed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + } + ], + "name": "TargetIsPlugin", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnauthorizedCaller", + "type": "error" + }, + { + "inputs": [], + "name": "WalletStorageIsInitialized", + "type": "error" + }, + { + "inputs": [], + "name": "WalletStorageIsInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "WalletStorageIsNotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "WrongTimeBounds", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "manifestHash", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "indexed": false, + "internalType": "struct FunctionReference[]", + "name": "dependencies", + "type": "tuple[]" + } + ], + "name": "PluginInstalled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "indexed": true, + "internalType": "bool", + "name": "onUninstallSucceeded", + "type": "bool" + } + ], + "name": "PluginUninstalled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "entryPointAddress", + "type": "address" + } + ], + "name": "UpgradableMSCAInitialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "WalletStorageInitialized", + "type": "event" + }, + { + "stateMutability": "payable", + "type": "fallback" + }, + { + "inputs": [], + "name": "AUTHOR", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ENTRY_POINT", + "outputs": [ + { + "internalType": "contract IEntryPoint", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PLUGIN_MANAGER", + "outputs": [ + { + "internalType": "contract PluginManager", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "addDeposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "execute", + "outputs": [ + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "internalType": "struct Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "executeBatch", + "outputs": [ + { + "internalType": "bytes[]", + "name": "returnData", + "type": "bytes[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "executeFromPlugin", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "executeFromPluginExternal", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "getDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getEntryPoint", + "outputs": [ + { + "internalType": "contract IEntryPoint", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "getExecutionFunctionConfig", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference", + "name": "userOpValidationFunction", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference", + "name": "runtimeValidationFunction", + "type": "tuple" + } + ], + "internalType": "struct ExecutionFunctionConfig", + "name": "executionFunctionConfig", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "getExecutionHooks", + "outputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference", + "name": "preExecHook", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference", + "name": "postExecHook", + "type": "tuple" + } + ], + "internalType": "struct ExecutionHooks[]", + "name": "executionHooks", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getInstalledPlugins", + "outputs": [ + { + "internalType": "address[]", + "name": "pluginAddresses", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "getPreValidationHooks", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference[]", + "name": "preUserOpValidationHooks", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference[]", + "name": "preRuntimeValidationHooks", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "plugins", + "type": "address[]" + }, + { + "internalType": "bytes32[]", + "name": "manifestHashes", + "type": "bytes32[]" + }, + { + "internalType": "bytes[]", + "name": "pluginInstallData", + "type": "bytes[]" + } + ], + "name": "initializeUpgradableMSCA", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "manifestHash", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "pluginInstallData", + "type": "bytes" + }, + { + "components": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "uint8", + "name": "functionId", + "type": "uint8" + } + ], + "internalType": "struct FunctionReference[]", + "name": "dependencies", + "type": "tuple[]" + } + ], + "name": "installPlugin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155BatchReceived", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC721Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "userData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "operatorData", + "type": "bytes" + } + ], + "name": "tokensReceived", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "plugin", + "type": "address" + }, + { + "internalType": "bytes", + "name": "config", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "pluginUninstallData", + "type": "bytes" + } + ], + "name": "uninstallPlugin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "initCode", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "accountGasLimits", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "preVerificationGas", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "gasFees", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "paymasterAndData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "internalType": "struct PackedUserOperation", + "name": "userOp", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "userOpHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "missingAccountFunds", + "type": "uint256" + } + ], + "name": "validateUserOp", + "outputs": [ + { + "internalType": "uint256", + "name": "validationData", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address payable", + "name": "withdrawAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "withdrawDepositTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] +""".trimIndent() \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/constants/MscaConstants.kt b/lib/src/main/java/com/circle/modularwallets/core/constants/MscaConstants.kt new file mode 100644 index 0000000..9384208 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/constants/MscaConstants.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.constants + +import com.circle.modularwallets.core.utils.data.pad +import org.web3j.utils.Numeric +import java.math.BigInteger + +val MINIMUM_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(600_000) +val MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(1_500_000) +val SEPOLIA_MINIMUM_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(600_000) +val SEPOLIA_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(2_000_000) +val MAINNET_MINIMUM_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(1_000_000) +val MAINNET_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(2_500_000) + + +/** The Circle Upgradable MSCA Factory. */ +object FACTORY { + val abi = CIRCLE_MSCA_6900_V1_EP07_FACTORY_ABI + val address = "0x0000000DF7E6c9Dc387cAFc5eCBfa6c3a6179AdD" +} + +/** The upgradable MSCA account implementation */ +object CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN { + val address = "0x0000000C984AFf541D6cE86Bb697e68ec57873C8" + val manifestHash = + Numeric.hexStringToByteArray("0xa043327d77a74c1c55cfa799284b831fe09535a88b9f5fa4173d334e5ba0fd91") +} + +object REPLAY_SAFE_HASH_V1 { + val name = "Weighted Multisig Webauthn Plugin" + val primaryType = "CircleWeightedWebauthnMultisigMessage" + val domainSeparatorType = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" + val moduleType = "CircleWeightedWebauthnMultisigMessage(bytes32 hash)" + val version = "1.0.0" +} + +const val EIP712_PREFIX = "0x1901" +val EIP1271_VALID_SIGNATURE = byteArrayOf(0x16, 0x26, 0xba.toByte(), 0x7e) + +/** The salt for the MSCA factory contract. */ +internal val SALT = Numeric.hexStringToByteArray(pad("0x", 32)) + +/** The public key own weights. */ +val PUBLIC_KEY_OWN_WEIGHT = 1L + +/** The threshold weight. */ +val THRESHOLD_WEIGHT = 1L +val STUB_SIGNATURE = + "0x0000be58786f7ae825e097256fc83a4749b95189e03e9963348373e9c595b15200000000000000000000000000000000000000000000000000000000000000412200000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000006091077742edaf8be2fa866827236532ec2a5547fe2721e606ba591d1ffae7a15c022e5f8fe5614bbf65ea23ad3781910eb04a1a60fae88190001ecf46e5f5680a00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002549960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000867b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224b6d62474d316a4d554b57794d6352414c6774553953537144384841744867486178564b6547516b503541222c226f726967696e223a22687474703a2f2f6c6f63616c686f73743a35313733222c2263726f73734f726967696e223a66616c73657d0000000000000000000000000000000000000000000000000000" +const val CIRCLE_SMART_ACCOUNT_VERSION_V1 = "circle_passkey_account_v1" +internal val CIRCLE_SMART_ACCOUNT_VERSION: Map = mapOf( + CIRCLE_SMART_ACCOUNT_VERSION_V1 to "circle_6900_v1" +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/constants/UnitConstants.kt b/lib/src/main/java/com/circle/modularwallets/core/constants/UnitConstants.kt new file mode 100644 index 0000000..0bb5b69 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/constants/UnitConstants.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.constants + +// Ether units +internal val etherUnits: Map = mapOf( + "gwei" to 9, + "wei" to 18 +) + +// Gwei units +internal val gweiUnits: Map = mapOf( + "ether" to -9, + "wei" to 9 +) + +// Wei units +internal val weiUnits: Map = mapOf( + "ether" to -18, + "gwei" to -9 +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/AddressErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/AddressErrors.kt new file mode 100644 index 0000000..c6997d7 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/AddressErrors.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +open class InvalidAddressError( + address: String +) : BaseError( + "Address \"$address\" is invalid.", + BaseErrorParameters( + metaMessages = mutableListOf( + "- Address must be a hex value of 20 bytes (40 hex characters).", + "- Address must match its checksum counterpart." + ), + name = "InvalidAddressError" + ) +) diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/BaseError.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/BaseError.kt new file mode 100644 index 0000000..e5f12b9 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/BaseError.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +import com.circle.modularwallets.core.BuildConfig + +data class BaseErrorParameters( + val cause: Throwable? = null, + val details: String? = null, + val metaMessages: MutableList? = null, + val name: String = "BaseError", +) + +open class BaseError( + open val shortMessage: String, + args: BaseErrorParameters = BaseErrorParameters() +) : Exception(buildMessage(shortMessage, args), args.cause) { + + val details: String? = getDetails(args) + val metaMessages: MutableList? = args.metaMessages + open val name: String = args.name + + companion object { + private fun getDetails(args: BaseErrorParameters): String? { + return when { + args.cause is BaseError -> args.cause.details + args.cause?.message != null -> args.cause.message + else -> args.details + } + } + + private fun buildMessage(shortMessage: String, args: BaseErrorParameters): String { + val messageParts = mutableListOf() + messageParts.add("${args.name}: " + (shortMessage.takeIf { it.isNotEmpty() } + ?: "An error occurred.")) + if (messageParts.isNotEmpty() && !messageParts.last().endsWith("\n")) { + messageParts[messageParts.lastIndex] = messageParts.last() + "\n" + } + + args.metaMessages?.let { + messageParts.addAll(it) + } + + if (messageParts.isNotEmpty() && !messageParts.last().endsWith("\n")) { + messageParts[messageParts.lastIndex] = messageParts.last() + "\n" + } + + val details = getDetails(args) + details?.let { + messageParts.add("Details: $it") + } + + messageParts.add("Version: ${BuildConfig.version}") + + return messageParts.joinToString("\n") + } + } + + fun walk(fn: ((Throwable?) -> Boolean)? = null): Throwable? { + return walk(this, fn) + } +} + +fun walk( + err: Throwable? = null, + fn: ((Throwable?) -> Boolean)? = null +): Throwable? { + if (fn?.invoke(err) == true) return err + err?.cause?.let { + return walk(it, fn) + } + return if (fn != null) null else err +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/BundlerErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/BundlerErrors.kt new file mode 100644 index 0000000..29ac29d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/BundlerErrors.kt @@ -0,0 +1,646 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +import java.math.BigInteger + +open class AccountNotDeployedError( + cause: BaseError? = null, +) : BaseError( + "Smart Account is not deployed.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- No `factory`/`factoryData` or `initCode` properties are provided for Smart Account deployment.", + "- An incorrect `sender` address is provided.", + ), + name = "AccountNotDeployedError" + ) +) { + companion object { + const val message = "aa20" + } +} + +open class ExecutionRevertedError( + cause: BaseError? = null, + message: String? = null +) : BaseError( + getMessage(message), + BaseErrorParameters( + cause, + name = "ExecutionRevertedError" + ) +) { + companion object { + const val code: Int = -32521 + const val message = "execution reverted" + fun getMessage(message: String? = null): String { + + var reason = message + ?.replace("execution reverted: ", "") + ?.replace("execution reverted", "") + reason = + if (reason?.isNotEmpty() == true) "with reason: $reason" else "for an unknown reason" + return "Execution reverted $reason." + } + } + +} + +open class FailedToSendToBeneficiaryError( + cause: BaseError? = null, +) : BaseError( + "Failed to send funds to beneficiary.", + BaseErrorParameters( + cause, + name = "FailedToSendToBeneficiaryError" + ) +) { + companion object { + const val message = "aa91" + } +} + +open class GasValuesOverflowError( + cause: BaseError? = null, +) : BaseError( + "Gas value overflowed.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- one of the gas values exceeded 2**120 (uint120)", + ), + name = "GasValuesOverflowError" + ) +) { + companion object { + const val message = "aa94" + } +} + +open class HandleOpsOutOfGasError( + cause: BaseError? = null, +) : BaseError( + "The `handleOps` function was called by the Bundler with a gas limit too low.", + BaseErrorParameters( + cause, + name = "HandleOpsOutOfGasError" + ) +) { + companion object { + const val message = "aa95" + } +} + +open class InitCodeFailedError( + cause: BaseError? = null, + factory: String? = null, + factoryData: String? = null, + initCode: String? = null, +) : BaseError( + "Failed to simulate deployment for Smart Account.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- Invalid `factory`/`factoryData` or `initCode` properties are present", + "- Smart Account deployment execution ran out of gas (low `verificationGasLimit` value)", + "- Smart Account deployment execution reverted with an error", + ).apply { + val rare = lastIndex + if (isNotEmpty() && !this[lastIndex].endsWith("\n")) { + this[lastIndex] = this[lastIndex] + "\n" + } + + factory?.let { add("factory: $it") } + factoryData?.let { add("factoryData: $it") } + initCode?.let { add("initCode: $it") } + + if (isNotEmpty() && rare != lastIndex && !this[lastIndex].endsWith("\n")) { + this[lastIndex] = this[lastIndex] + "\n" + } + }, + name = "InitCodeFailedError" + ) +) { + companion object { + const val message = "aa13" + } +} + +open class InitCodeMustCreateSenderError( + cause: BaseError? = null, + factory: String? = null, + factoryData: String? = null, + initCode: String? = null, +) : BaseError( + "Smart Account initialization implementation did not create an account.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- `factory`/`factoryData` or `initCode` properties are invalid", + "- Smart Account initialization implementation is incorrect\n", + ).apply { + factory?.let { add("factory: $it") } + factoryData?.let { add("factoryData: $it") } + initCode?.let { add("initCode: $it") } + }, + name = "InitCodeMustCreateSenderError" + ) +) { + companion object { + const val message = "aa15" + } +} + +open class InitCodeMustReturnSenderError( + cause: BaseError? = null, + factory: String? = null, + factoryData: String? = null, + initCode: String? = null, + sender: String? = null, +) : BaseError( + "Smart Account initialization implementation does not return the expected sender.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "Smart Account initialization implementation does not return a sender address\n", + ).apply { + factory?.let { add("factory: $it") } + factoryData?.let { add("factoryData: $it") } + initCode?.let { add("initCode: $it") } + sender?.let { add("sender: $it") } + }, + name = "InitCodeMustReturnSenderError" + ) +) { + companion object { + const val message = "aa14" + } +} + +open class InsufficientPrefundError( + cause: BaseError? = null, +) : BaseError( + "Smart Account does not have sufficient funds to execute the User Operation.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the Smart Account does not have sufficient funds to cover the required prefund, or", + "- a Paymaster was not provided", + ), + name = "InsufficientPrefundError" + ) +) { + companion object { + const val message = "aa21" + } +} + +open class InternalCallOnlyError( + cause: BaseError? = null, +) : BaseError( + "Bundler attempted to call an invalid function on the EntryPoint.", + BaseErrorParameters( + cause, + name = "InternalCallOnlyError" + ) +) { + companion object { + const val message = "aa92" + } +} + +open class InvalidAggregatorError( + cause: BaseError? = null, +) : BaseError( + "Bundler used an invalid aggregator for handling aggregated User Operations.", + BaseErrorParameters( + cause, + name = "InvalidAggregatorError" + ) +) { + companion object { + const val message = "aa96" + } +} + +open class InvalidAccountNonceError( + cause: BaseError? = null, + nonce: BigInteger? = null, +) : BaseError( + "Invalid Smart Account nonce used for User Operation.", + BaseErrorParameters( + cause, + metaMessages = nonce?.let { + mutableListOf("nonce: $it") + }, + name = "InvalidAccountNonceError" + ) +) { + companion object { + const val message = "aa25" + } +} + +open class InvalidBeneficiaryError( + cause: BaseError? = null, +) : BaseError( + "Bundler has not set a beneficiary address.", + BaseErrorParameters( + cause, + name = "InvalidBeneficiaryError" + ) +) { + companion object { + const val message = "aa90" + } +} + +open class InvalidFieldsError( + cause: BaseError? = null, +) : BaseError( + "Invalid fields set on User Operation.", + BaseErrorParameters( + cause, + name = "InvalidFieldsError" + ) +) { + companion object { + const val code: Int = -32602 + } +} + +open class InvalidPaymasterAndDataError( + cause: BaseError? = null, + paymasterAndData: String? = null, +) : BaseError( + "Paymaster properties provided are invalid.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `paymasterAndData` property is of an incorrect length\n", + ).apply { + paymasterAndData?.let { add("paymasterAndData: $it") } + }, + name = "InvalidPaymasterAndDataError" + ) +) { + companion object { + const val message = "aa93" + } +} + +open class PaymasterDepositTooLowError( + cause: BaseError? = null, +) : BaseError( + "Paymaster deposit for the User Operation is too low.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the Paymaster has deposited less than the expected amount via the `deposit` function", + ), + name = "PaymasterDepositTooLowError" + ) +) { + companion object { + const val code: Int = -32508 + const val message = "aa31" + } + +} + +open class PaymasterFunctionRevertedError( + cause: BaseError? = null, +) : BaseError( + "The `validatePaymasterUserOp` function on the Paymaster reverted.", + BaseErrorParameters( + cause, + name = "PaymasterFunctionRevertedError" + ) +) { + companion object { + const val message = "aa33" + } +} + +open class PaymasterNotDeployedError( + cause: BaseError? = null, +) : BaseError( + "The Paymaster contract has not been deployed.", + BaseErrorParameters( + cause, + name = "PaymasterNotDeployedError" + ) +) { + companion object { + const val message = "aa30" + } +} + +open class PaymasterRateLimitError( + cause: BaseError? = null, +) : BaseError( + "UserOperation rejected because paymaster (or signature aggregator) is throttled/banned.", + BaseErrorParameters( + cause, + name = "PaymasterRateLimitError" + ) +) { + companion object { + const val code: Int = -32504 + } +} + +open class PaymasterStakeTooLowError( + cause: BaseError? = null, +) : BaseError( + "UserOperation rejected because paymaster (or signature aggregator) is throttled/banned.", + BaseErrorParameters( + cause, + name = "PaymasterStakeTooLowError" + ) +) { + companion object { + const val code: Int = -32505 + } +} + +open class PaymasterPostOpFunctionRevertedError( + cause: BaseError? = null, +) : BaseError( + "Paymaster `postOp` function reverted.", + BaseErrorParameters( + cause, + name = "PaymasterPostOpFunctionRevertedError" + ) +) { + companion object { + const val message = "aa50" + } +} + +open class SenderAlreadyConstructedError( + cause: BaseError? = null, + factory: String? = null, + factoryData: String? = null, + initCode: String? = null, +) : BaseError( + "Smart Account has already been deployed.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "Remove the following properties and try again:", + ).apply { + factory?.let { add("`factory`") } + factoryData?.let { add("`factoryData`") } + initCode?.let { add("`initCode`") } + }, + name = "SenderAlreadyConstructedError" + ) +) { + companion object { + const val message = "aa10" + } +} + +open class SignatureCheckFailedError( + cause: BaseError? = null, +) : BaseError( + "UserOperation rejected because account signature check failed (or paymaster signature, if the paymaster uses its data as signature).", + BaseErrorParameters( + cause, + name = "SignatureCheckFailedError" + ) +) { + companion object { + const val code: Int = -32507 + } +} + +open class SmartAccountFunctionRevertedError( + cause: BaseError? = null, +) : BaseError( + "The `validateUserOp` function on the Smart Account reverted.", + BaseErrorParameters( + cause, + name = "SmartAccountFunctionRevertedError" + ) +) { + companion object { + const val message = "aa23" + } +} + +open class UnsupportedSignatureAggregatorError( + cause: BaseError? = null, +) : BaseError( + "UserOperation rejected because account specified unsupported signature aggregator.", + BaseErrorParameters( + cause, + name = "UnsupportedSignatureAggregatorError" + ) +) { + companion object { + const val code: Int = -32506 + } +} + +open class UserOperationExpiredError( + cause: BaseError? = null, +) : BaseError( + "User Operation expired.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `validAfter` or `validUntil` values returned from `validateUserOp` on the Smart Account are not satisfied", + ), + name = "UserOperationExpiredError" + ) +) { + companion object { + const val message = "aa22" + } +} + +open class UserOperationPaymasterExpiredError( + cause: BaseError? = null, +) : BaseError( + "Paymaster for User Operation expired.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `validAfter` or `validUntil` values returned from `validatePaymasterUserOp` on the Paymaster are not satisfied", + ), + name = "UserOperationPaymasterExpiredError" + ) +) { + companion object { + const val message = "aa32" + } +} + +open class UserOperationSignatureError( + cause: BaseError? = null, +) : BaseError( + "Signature provided for the User Operation is invalid.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `signature` for the User Operation is incorrectly computed, and unable to be verified by the Smart Account", + ), + name = "UserOperationSignatureError" + ) +) { + companion object { + const val message = "aa24" + } +} + +open class UserOperationPaymasterSignatureError( + cause: BaseError? = null, +) : BaseError( + "Signature provided for the User Operation is invalid.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `signature` for the User Operation is incorrectly computed, and unable to be verified by the Paymaster", + ), + name = "UserOperationPaymasterSignatureError" + ) +) { + companion object { + const val message = "aa34" + } +} + +open class UserOperationRejectedByEntryPointError( + cause: BaseError? = null, +) : BaseError( + "User Operation rejected by EntryPoint's `simulateValidation` during account creation or validation.", + BaseErrorParameters( + cause, + name = "UserOperationRejectedByEntryPointError" + ) +) { + companion object { + const val code: Int = -32500 + } +} + +open class UserOperationRejectedByPaymasterError( + cause: BaseError? = null, +) : BaseError( + "User Operation rejected by Paymaster's `validatePaymasterUserOp`.", + BaseErrorParameters( + cause, + name = "UserOperationRejectedByPaymasterError" + ) +) { + companion object { + const val code: Int = -32501 + } +} + +open class UserOperationRejectedByOpCodeError( + cause: BaseError? = null, +) : BaseError( + "User Operation rejected with op code validation error.", + BaseErrorParameters( + cause, + name = "UserOperationRejectedByOpCodeError" + ) +) { + companion object { + const val code: Int = -32502 + } +} + +open class UserOperationOutOfTimeRangeError( + cause: BaseError? = null, +) : BaseError( + "UserOperation out of time-range: either wallet or paymaster returned a time-range, and it is already expired (or will expire soon).", + BaseErrorParameters( + cause, + name = "UserOperationOutOfTimeRangeError" + ) +) { + companion object { + const val code: Int = -32503 + } +} + +open class UnknownBundlerError( + cause: BaseError? = null, +) : BaseError( + "An error occurred while executing user operation: ${cause?.shortMessage}", + BaseErrorParameters( + cause, + name = "UnknownBundlerError" + ) +) + +open class VerificationGasLimitExceededError( + cause: BaseError? = null, +) : BaseError( + "User Operation verification gas limit exceeded.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the gas used for verification exceeded the `verificationGasLimit`", + ), + name = "VerificationGasLimitExceededError" + ) +) { + companion object { + const val message = "aa40" + } +} + +open class VerificationGasLimitTooLowError( + cause: BaseError? = null, +) : BaseError( + "User Operation verification gas limit is too low.", + BaseErrorParameters( + cause, + metaMessages = mutableListOf( + "This could arise when:", + "- the `verificationGasLimit` is too low to verify the User Operation", + ), + name = "VerificationGasLimitTooLowError" + ) +) { + companion object { + const val message = "aa41" + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/EncodingErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/EncodingErrors.kt new file mode 100644 index 0000000..69e9b20 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/EncodingErrors.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +class IntegerOutOfRangeError( + cause: BaseError? = null +) : BaseError( + "The input value is out of range", + BaseErrorParameters( + cause, + name = "IntegerOutOfRangeError" + ) +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/RequestErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/RequestErrors.kt new file mode 100644 index 0000000..67633cb --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/RequestErrors.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +import com.circle.modularwallets.core.transports.JsonRpcError +import com.circle.modularwallets.core.utils.toJson + + +open class RpcRequestError( + body: Any, + error: JsonRpcError, + url: String +) : BaseError( + "RPC Request failed.", + BaseErrorParameters( + details = error.message, + metaMessages = mutableListOf("URL: $url", "Request body: ${toJson(body)}"), + name = "RpcRequestError" + ) +) { + val code: Int = error.code +} + +open class HttpRequestError( + val body: Any? = null, + cause: Throwable? = null, + details: String? = null, + val headers: Any? = null, + val status: Int? = null, + val url: String +) : BaseError( + "HTTP request failed.", + BaseErrorParameters( + cause, + details, + metaMessages = getMetaMessage(status, url, body), + name = "HttpRequestError" + ) +) { + companion object { + fun getMetaMessage(status: Int?, url: String, body: Any?): MutableList { + val result = mutableListOf() + status?.let { + result.add("Status: $it") + } + result.add("URL: $url") + body?.let { + result.add("Request body: ${toJson(it)}") + } + return result + } + } +} + +open class TimeoutError( + body: Any?, + url: String +) : BaseError( + "The request took too long to respond.", + BaseErrorParameters( + details = "The request timed out.", + metaMessages = mutableListOf("URL: $url", "Request body: ${toJson(body)}"), + name = "TimeoutError" + ) +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/RpcErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/RpcErrors.kt new file mode 100644 index 0000000..996a570 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/RpcErrors.kt @@ -0,0 +1,312 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + + +data class RpcErrorOptions( + val code: Int? = null, + val metaMessages: MutableList? = null, + val name: String? = null, + val shortMessage: String, +) + +open class RpcError( + cause: Throwable, + options: RpcErrorOptions, +) : BaseError( + options.shortMessage, BaseErrorParameters( + cause, + metaMessages = getMetaMessage(options, cause), + name = getName(options, cause) + ) +) { + val code: Int = if (cause is RpcRequestError) { + cause.code + } else { + options.code ?: UnknownRpcError.code + } + + companion object { + fun getMetaMessage(options: RpcErrorOptions, cause: Throwable): MutableList? { + options.metaMessages?.let { + return it + } + if (cause is BaseError) { + return cause.metaMessages + } + return null + } + + fun getName(options: RpcErrorOptions, cause: Throwable): String { + options.name?.let { + return it + } + if (cause is BaseError) { + return cause.name + } + return "RpcError" + } + } +} + +open class ProviderRpcError(cause: Throwable, options: RpcErrorOptions) : RpcError( + cause, + options +) + +class ParseRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "ParseRpcError", + shortMessage = "Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text." + ) +) { + companion object { + const val code = -32700 + } +} + +class InvalidRequestRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "InvalidRequestRpcError", + shortMessage = "JSON is not a valid request object." + ) +) { + companion object { + const val code = -32600 + } +} + +class MethodNotFoundRpcError(cause: Throwable, method: String? = null) : RpcError( + cause, + RpcErrorOptions(code, + name = "MethodNotFoundRpcError", + shortMessage = "The method${method?.let { " \"$it\"" } ?: ""} does not exist / is not available.") +) { + companion object { + const val code = -32601 + } +} + +class InvalidParamsRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "InvalidParamsRpcError", + shortMessage = "Invalid parameters were provided to the RPC method.\nDouble check you have provided the correct parameters." + ) +) { + companion object { + const val code = -32602 + } +} + +class InternalRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "InternalRpcError", + shortMessage = "An internal error was received." + ) +) { + companion object { + const val code = -32603 + } +} + +class InvalidInputRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "InvalidInputRpcError", + shortMessage = "Missing or invalid parameters.\nDouble check you have provided the correct parameters." + ) +) { + companion object { + const val code = -32000 + } +} + +class ResourceNotFoundRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "ResourceNotFoundRpcError", + shortMessage = "Requested resource not found." + ) +) { + companion object { + const val code = -32001 + } +} + +class ResourceUnavailableRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "ResourceUnavailableRpcError", + shortMessage = "Requested resource not available." + ) +) { + companion object { + const val code = -32002 + } +} + +class TransactionRejectedRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "TransactionRejectedRpcError", + shortMessage = "Transaction creation failed." + ) +) { + companion object { + const val code = -32003 + } +} + +class MethodNotSupportedRpcError(cause: Throwable, method: String? = null) : RpcError( + cause, + RpcErrorOptions(code, + name = "MethodNotSupportedRpcError", + shortMessage = "Method${method?.let { " \"$it\"" } ?: ""} is not implemented.") +) { + companion object { + const val code = -32004 + } +} + +class LimitExceededRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "LimitExceededRpcError", + shortMessage = "Request exceeds defined limit." + ) +) { + companion object { + const val code = -32005 + } +} + +class JsonRpcVersionUnsupportedError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + code, + name = "JsonRpcVersionUnsupportedError", + shortMessage = "Version of JSON-RPC protocol is not supported." + ) +) { + companion object { + const val code = -32006 + } +} + +class UserRejectedRequestError(cause: Throwable) : ProviderRpcError( + cause, + RpcErrorOptions( + code, + name = "UserRejectedRequestError", + shortMessage = "User rejected the request." + ) +) { + companion object { + const val code = 4001 + } +} + +class UnauthorizedProviderError(cause: Throwable) : ProviderRpcError( + cause, + RpcErrorOptions( + code, + name = "UnauthorizedProviderError", + shortMessage = "The requested method and/or account has not been authorized by the user." + ) +) { + companion object { + const val code = 4100 + } +} + +class UnsupportedProviderMethodError(cause: Throwable, method: String? = null) : ProviderRpcError( + cause, + RpcErrorOptions(code, + name = "UnsupportedProviderMethodError", + shortMessage = "The Provider does not support the requested method${method?.let { " \"$it\"" } ?: ""}.") +) { + companion object { + const val code = 4200 + } +} + +class ProviderDisconnectedError(cause: Throwable) : ProviderRpcError( + cause, + RpcErrorOptions( + code, + name = "ProviderDisconnectedError", + shortMessage = "The Provider is disconnected from all chains." + ) +) { + companion object { + const val code = 4900 + } +} + +class ChainDisconnectedError(cause: Throwable) : ProviderRpcError( + cause, + RpcErrorOptions( + code, + name = "ChainDisconnectedError", + shortMessage = "The Provider is not connected to the requested chain." + ) +) { + companion object { + const val code = 4901 + } +} + +class SwitchChainError(cause: Throwable) : ProviderRpcError( + cause, + RpcErrorOptions( + code, + name = "SwitchChainError", + shortMessage = "An error occurred when attempting to switch chain." + ) +) { + companion object { + const val code = 4902 + } +} + +class UnknownRpcError(cause: Throwable) : RpcError( + cause, + RpcErrorOptions( + name = "UnknownRpcError", + shortMessage = "An unknown RPC error occurred." + ) +) { + companion object { + const val code = -1 + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/errors/UserOperationErrors.kt b/lib/src/main/java/com/circle/modularwallets/core/errors/UserOperationErrors.kt new file mode 100644 index 0000000..cdb9a3f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/errors/UserOperationErrors.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.errors + +import com.circle.modularwallets.core.models.UserOperation +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.utils.prettyPrint +import com.circle.modularwallets.core.utils.toMap +import com.circle.modularwallets.core.utils.unit.formatGwei +import java.math.BigInteger + + +// https://github.com/wevm/viem/blob/3866a6faeb9e64ba3da6063fe78a079ea53c2c5f/src/account-abstraction/errors/userOperation.ts#L104 +class WaitForUserOperationReceiptTimeoutError(hash: String) : BaseError( + "Timed out while waiting for User Operation with hash \"$hash\" to be confirmed.", + BaseErrorParameters(name = "WaitForUserOperationReceiptTimeoutError") +) + +// https://github.com/wevm/viem/blob/3866a6faeb9e64ba3da6063fe78a079ea53c2c5f/src/account-abstraction/errors/userOperation.ts#L80 +class UserOperationReceiptNotFoundError(hash: String) : BaseError( + "User Operation receipt with hash \"$hash\" could not be found. The User Operation may not have been processed yet.", + BaseErrorParameters(name = "UserOperationReceiptNotFoundError") +) + +//https://github.com/wevm/viem/blob/f34580367127be8ec02e2f1a9dbf5d81c29e74e8/src/account-abstraction/errors/userOperation.ts#L89C1-L99C1 +class UserOperationNotFoundError(hash: String) : BaseError( + "User Operation with hash \"$hash\" could not be found.", + BaseErrorParameters(name = "UserOperationNotFoundError") +) + +class UserOperationExecutionError private constructor( + cause: BaseError, + parameters: BaseErrorParameters +) : BaseError(cause.shortMessage, parameters) { + constructor(cause: BaseError, userOp: UserOperationV07) : this( + cause, BaseErrorParameters( + cause, + metaMessages = getMetaMessages(cause, getUserOpPrettyPrint(userOp)), + name = "UserOperationExecutionError" + ) + ) + + companion object { + inline fun getUserOpPrettyPrint(userOp: T): String { + val map = userOp.toMap().toMutableMap() + map["maxFeePerGas"]?.let { + val n: BigInteger? = if (it is BigInteger) it else null + map["maxFeePerGas"] = "${formatGwei(n)} gwei" + } + map["maxPriorityFeePerGas"]?.let { + val n: BigInteger? = if (it is BigInteger) it else null + map["maxPriorityFeePerGas"] = "${formatGwei(n)} gwei" + } + return prettyPrint(map) + } + + fun getMetaMessages(cause: BaseError, prettyArgs: String): MutableList { + val messages = mutableListOf() + cause.metaMessages?.let { + messages.addAll(it) + } + + + if (messages.isNotEmpty() && !messages.last().endsWith("\n")) { + messages[messages.lastIndex] = messages.last() + "\n" + } + + messages.add("Request Arguments:") + messages.add(prettyArgs) + + return messages.filter { it.isNotBlank() }.toMutableList() + } + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/Block.kt b/lib/src/main/java/com/circle/modularwallets/core/models/Block.kt new file mode 100644 index 0000000..26eb5e4 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/Block.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +/** + * Data class representing a block. + * + * @property baseFeePerGas Base fee per gas. + * @property blobGasUsed Total used blob gas by all transactions in this block. + * @property difficulty Difficulty for this block. + * @property excessBlobGas Excess blob gas. + * @property extraData "Extra data" field of this block. + * @property gasLimit Maximum gas allowed in this block. + * @property gasUsed Total used gas by all transactions in this block. + * @property hash Block hash or `null` if pending. + * @property logsBloom Logs bloom filter or `null` if pending. + * @property miner Address that received this block’s mining rewards. + * @property mixHash Unique identifier for the block. + * @property nonce Proof-of-work hash or `null` if pending. + * @property number Block number or `null` if pending. + * @property parentBeaconBlockRoot Root of the parent beacon chain block. + * @property parentHash Parent block hash. + * @property receiptsRoot Root of this block’s receipts trie. + * @property sealFields List of seal fields. + * @property sha3Uncles SHA3 of the uncles data in this block. + * @property size Size of this block in bytes. + * @property stateRoot Root of this block’s final state trie. + * @property timestamp Unix timestamp of when this block was collated. + * @property totalDifficulty Total difficulty of the chain until this block. + * @property transactions List of transaction objects or hashes. + * @property transactionsRoot Root of this block’s transaction trie. + * @property uncles List of uncle hashes. + * @property withdrawals List of withdrawal objects. + * @property withdrawalsRoot Root of this block’s withdrawals trie. + */ +@JsonClass(generateAdapter = true) +data class Block @JvmOverloads constructor( + @Json(name = "baseFeePerGas") var baseFeePerGas: BigInteger? = null, + @Json(name = "blobGasUsed") var blobGasUsed: BigInteger? = null, + @Json(name = "difficulty") var difficulty: BigInteger? = null, + @Json(name = "excessBlobGas") var excessBlobGas: BigInteger? = null, + @Json(name = "extraData") var extraData: String? = null, + @Json(name = "gasLimit") var gasLimit: BigInteger? = null, + @Json(name = "gasUsed") var gasUsed: BigInteger? = null, + @Json(name = "hash") var hash: String? = null, + @Json(name = "logsBloom") var logsBloom: String? = null, + @Json(name = "miner") var miner: String? = null, + @Json(name = "mixHash") var mixHash: String? = null, + @Json(name = "nonce") var nonce: String? = null, + @Json(name = "number") var number: BigInteger? = null, + @Json(name = "parentBeaconBlockRoot") var parentBeaconBlockRoot: String? = null, + @Json(name = "parentHash") var parentHash: String? = null, + @Json(name = "receiptsRoot") var receiptsRoot: String? = null, + @Json(name = "sha3Uncles") var sha3Uncles: String? = null, + @Json(name = "size") var size: BigInteger? = null, + @Json(name = "stateRoot") var stateRoot: String? = null, + @Json(name = "timestamp") var timestamp: BigInteger? = null, + @Json(name = "totalDifficulty") var totalDifficulty: BigInteger? = null, + @Json(name = "transactions") var transactions: Array? = null, + @Json(name = "transactionsRoot") var transactionsRoot: String? = null, + @Json(name = "uncles") var uncles: Array? = null, + @Json(name = "withdrawals") var withdrawals: Array? = null, + @Json(name = "withdrawalsRoot") var withdrawalsRoot: String? = null, +) + +data class Withdrawal @JvmOverloads constructor( + @Json(name = "index") var index: String? = null, + @Json(name = "validatorIndex") var validatorIndex: String? = null, + @Json(name = "address") var address: String? = null, + @Json(name = "amount") var amount: String? = null, +) diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/BundlerResults.kt b/lib/src/main/java/com/circle/modularwallets/core/models/BundlerResults.kt new file mode 100644 index 0000000..5d0c989 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/BundlerResults.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +/** + * Response model for estimating gas usage for user operations. + * + * @property preVerificationGas Gas overhead of this UserOperation. + * @property verificationGasLimit Estimation of gas limit required by the validation of this UserOperation. + * @property callGasLimit Estimation of gas limit required by the inner account execution. + * @property paymasterVerificationGasLimit Estimation of gas limit required by the paymaster verification, if the UserOperation defines a Paymaster address. + * @property paymasterPostOpGasLimit The amount of gas to allocate for the paymaster post-operation code. + */ +@JsonClass(generateAdapter = true) +data class EstimateUserOperationGasResult @JvmOverloads constructor( + @Json(name = "preVerificationGas") var preVerificationGas: BigInteger? = null, + @Json(name = "verificationGasLimit") var verificationGasLimit: BigInteger? = null, + @Json(name = "callGasLimit") var callGasLimit: BigInteger? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimit: BigInteger? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimit: BigInteger? = null, +) + +/** + * Response model for estimating fees per gas. + * + * @property maxFeePerGas Total fee per gas in wei (gasPrice/baseFeePerGas + maxPriorityFeePerGas). + * @property maxPriorityFeePerGas Max priority fee per gas (in wei). + * @property gasPrice Legacy gas price (optional, usually undefined for EIP-1559). + */ +@JsonClass(generateAdapter = true) +data class EstimateFeesPerGasResult @JvmOverloads constructor( + val maxFeePerGas: BigInteger? = null, + val maxPriorityFeePerGas: BigInteger? = null, + val gasPrice: BigInteger? = null, +) + +/** + * Response model for getting user operation details. + * + * @property blockHash The block hash the User Operation was included on. + * @property blockNumber The block number the User Operation was included on. + * @property entryPoint The EntryPoint which handled the User Operation. + * @property transactionHash The hash of the transaction which included the User Operation. + * @property userOperation The User Operation. + */ +data class GetUserOperationResult( + val blockHash: String?, + val blockNumber: BigInteger?, + val transactionHash: String?, + val entryPoint: String?, + val userOperation: UserOperationRpc? +) + +/** + * Data class representing the receipt of a user operation. + * + * @property actualGasCost Actual gas cost. + * @property actualGasUsed Actual gas used. + * @property entryPoint Entrypoint address. + * @property logs Logs emitted during execution. + * @property nonce Anti-replay parameter. + * @property paymaster Paymaster for the user operation. + * @property reason Revert reason, if unsuccessful. + * @property receipt Transaction receipt of the user operation execution. + * @property sender Address of the sender. + * @property success If the user operation execution was successful. + * @property userOpHash Hash of the user operation. + */ +data class UserOperationReceipt( + val actualGasCost: BigInteger?, + val actualGasUsed: BigInteger?, + val entryPoint: String?, + val logs: List?, + val nonce: BigInteger?, + val paymaster: String?, + val reason: String?, + val receipt: TransactionReceipt, + val sender: String?, + val success: Boolean?, + val userOpHash: String?, +) + +/** + * Data class representing a log entry. + * + * @property address The address from which this log originated. + * @property blockHash Hash of the block containing this log or `null` if pending. + * @property blockNumber Number of the block containing this log or `null` if pending. + * @property data Contains the non-indexed arguments of the log. + * @property logIndex Index of this log within its block or `null` if pending. + * @property transactionHash Hash of the transaction that created this log or `null` if pending. + * @property transactionIndex Index of the transaction that created this log or `null` if pending. + * @property removed `true` if this filter has been destroyed and is invalid. + * @property topics List of topics associated with this log. + */ +data class Log( + val address: String?, + val blockHash: String?, + val blockNumber: BigInteger?, + val data: String?, + val logIndex: BigInteger?, + val transactionHash: String?, + val transactionIndex: BigInteger?, + val removed: Boolean?, + val topics: List?, +) + +/** + * Data class representing the receipt of a transaction. + * + * @property blobGasPrice The actual value per gas deducted from the sender's account for blob gas. Only specified for blob transactions as defined by EIP-4844. + * @property blobGasUsed The amount of blob gas used. Only specified for blob transactions as defined by EIP-4844. + * @property blockHash Hash of the block containing this transaction. + * @property blockNumber Number of the block containing this transaction. + * @property contractAddress Address of the new contract or `null` if no contract was created. + * @property cumulativeGasUsed Gas used by this and all preceding transactions in this block. + * @property effectiveGasPrice Pre-London, it is equal to the transaction's gasPrice. Post-London, it is equal to the actual gas price paid for inclusion. + * @property from Transaction sender. + * @property gasUsed Gas used by this transaction. + * @property logs List of log objects generated by this transaction. + * @property logsBloom Logs bloom filter. + * @property root The post-transaction state root. Only specified for transactions included before the Byzantium upgrade. + * @property status `success` if this transaction was successful or `reverted` if it failed. + * @property to Transaction recipient or `null` if deploying a contract. + * @property transactionHash Hash of this transaction. + * @property transactionIndex Index of this transaction in the block. + * @property type Transaction type. + */ +data class TransactionReceipt( + val blobGasPrice: BigInteger?, + val blobGasUsed: BigInteger?, + val blockHash: String?, + val blockNumber: BigInteger?, + val contractAddress: String?, + val cumulativeGasUsed: BigInteger?, + val effectiveGasPrice: BigInteger?, + val from: String?, + val gasUsed: BigInteger?, + val logs: List?, + val logsBloom: String?, + val root: String?, + val status: String?, + val to: String?, + val transactionHash: String?, + val transactionIndex: BigInteger?, + val type: String?, +) diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/EIP712TypedData.kt b/lib/src/main/java/com/circle/modularwallets/core/models/EIP712TypedData.kt new file mode 100644 index 0000000..470b628 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/EIP712TypedData.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.constants.REPLAY_SAFE_HASH_V1 +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class EIP712Message @JvmOverloads constructor( + @Json(name = "types") var types: MutableMap>? = null, + @Json(name = "primaryType") var primaryType: String? = null, + @Json(name = "message") var message: MutableMap? = null, + @Json(name = "domain") var domain: EIP712Domain? = null, +) + +@JsonClass(generateAdapter = true) +data class Entry @JvmOverloads constructor( + @Json(name = "name") var name: String? = null, + @Json(name = "type") var type: String? = null, +) + +@JsonClass(generateAdapter = true) +data class EIP712Domain @JvmOverloads constructor( + @Json(name = "name") var name: String? = null, + @Json(name = "version") var version: String? = null, + @Json(name = "chainId") var chainId: Long? = null, + @Json(name = "verifyingContract") val verifyingContract: String? = null, + @Json(name = "salt") var salt: String? = null, +) + +internal fun getCircleDomainMessage(chainId: Long, verifyingContract: String, hash: String): EIP712Message { + return EIP712Message( + domain = EIP712Domain( + REPLAY_SAFE_HASH_V1.name, + REPLAY_SAFE_HASH_V1.version, + chainId, + verifyingContract + ), + types = mutableMapOf( + REPLAY_SAFE_HASH_V1.primaryType to mutableListOf( + Entry( + "hash", + "bytes32" + ) + ) + ), + primaryType = REPLAY_SAFE_HASH_V1.primaryType, + message = mutableMapOf("hash" to hash) + ) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/EncodeCallDataArg.kt b/lib/src/main/java/com/circle/modularwallets/core/models/EncodeCallDataArg.kt new file mode 100644 index 0000000..9a6e629 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/EncodeCallDataArg.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.utils.abi.encodeFunctionData +import java.math.BigInteger + +/** + * Data class representing arguments required for encoding call data. + * + * @property to The recipient address. + * @property value The value to be sent with the transaction. + * @property data The call data in hexadecimal format. + * @property abiJson The ABI definition in JSON format. + * @property args The arguments for the function call. + * @property functionName The function name. + */ +data class EncodeCallDataArg @JvmOverloads constructor( + val to: String, + val value: BigInteger? = null, + val data: String? = null, //hex + val abiJson: String? = null, + val args: Array? = null, + val functionName: String? = null +) { + internal fun dataUpdated(): EncodeCallDataArg { + if (!abiJson.isNullOrBlank() && !functionName.isNullOrBlank()) { + return EncodeCallDataArg( + to, + value, + encodeFunctionData( + functionName, + abiJson, + args ?: emptyArray() + ) + ) + } + return this + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/EncodeTransferResult.kt b/lib/src/main/java/com/circle/modularwallets/core/models/EncodeTransferResult.kt new file mode 100644 index 0000000..8fce370 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/EncodeTransferResult.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +/** + * The return type for encodeTransfer. + */ +data class EncodeTransferResult( + /** + * The encoded data. + */ + val data: String, + /** + * The token address. + */ + val to: String +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/EntryPoint.kt b/lib/src/main/java/com/circle/modularwallets/core/models/EntryPoint.kt new file mode 100644 index 0000000..38ed51a --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/EntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +interface IEntryPoint { + val address: String +} + +/** + * Enum class representing entry points with their respective addresses. + * + * @property address The address of the entry point. + */ +enum class EntryPoint(override val address: String) :IEntryPoint{ + /** + * Represents the entry point version 0.7 with its respective address. + */ + V07("0x0000000071727De22E5E9d8BAf0edAc6f37da032"); +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/Paymaster.kt b/lib/src/main/java/com/circle/modularwallets/core/models/Paymaster.kt new file mode 100644 index 0000000..339632f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/Paymaster.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.clients.PaymasterClient + +/** + * Sealed class for setting User Operation Paymaster configuration. + * + * If `paymaster` is `PaymasterClient`, it will use the provided Paymaster Client for sponsorship. + * If `paymaster` is `true`, it will be assumed that the Bundler Client also supports Paymaster RPC methods + * (e.g. `pm_getPaymasterData`), and use them for sponsorship. + */ +sealed class Paymaster { + /** + * Represents a Paymaster configuration where the Bundler Client supports Paymaster RPC methods. + * + * @property paymasterContext Optional context for the paymaster. + */ + data class True @JvmOverloads constructor( + val paymasterContext: Map? = null + ) : Paymaster() + + /** + * Represents a Paymaster configuration using a provided Paymaster Client for sponsorship. + * + * @property client The Paymaster Client used for sponsorship. + * @property paymasterContext Optional context for the paymaster. + */ + data class Client @JvmOverloads constructor( + val client: PaymasterClient, + val paymasterContext: Map? = null + ) : Paymaster() +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/PaymasterResults.kt b/lib/src/main/java/com/circle/modularwallets/core/models/PaymasterResults.kt new file mode 100644 index 0000000..a9e6b95 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/PaymasterResults.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +/** + * Data class representing the result of getting paymaster data. + * + * @property paymasterAndData Combined paymaster and data. + * @property paymasterData Paymaster data. + * @property paymaster Paymaster address. + * @property paymasterPostOpGasLimit Gas limit for post-operation of paymaster. + * @property paymasterVerificationGasLimit Gas limit for verification of paymaster. + */ +@JsonClass(generateAdapter = true) +data class GetPaymasterDataResult @JvmOverloads constructor( + @Json(name = "paymasterAndData") var paymasterAndData: String? = null, + @Json(name = "paymasterData") var paymasterData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimit: BigInteger? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimit: BigInteger? = null, +) + +/** + * Data class representing the result of getting paymaster stub data. + * + * @property paymasterAndData Combined paymaster and data. + * @property paymaster Paymaster address. + * @property paymasterData Paymaster data. + * @property paymasterPostOpGasLimit Gas limit for post-operation of paymaster. + * @property paymasterVerificationGasLimit Gas limit for verification of paymaster. + * @property isFinal Indicates if the data is final. + * @property sponsor Sponsor information. + */ +@JsonClass(generateAdapter = true) +data class GetPaymasterStubDataResult @JvmOverloads constructor( + @Json(name = "paymasterAndData") var paymasterAndData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterData") var paymasterData: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimit: BigInteger? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimit: BigInteger? = null, + @Json(name = "isFinal") var isFinal: Boolean? = null, + @Json(name = "sponsor") var sponsor: Sponsor? = null, +) +/** + * Data class representing sponsor information. + * + * @property name Sponsor name. + * @property icon Sponsor icon. + */ +@JsonClass(generateAdapter = true) +data class Sponsor @JvmOverloads constructor( + var name: String? = null, + var icon: String? = null +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/PublicKeyCredentials.kt b/lib/src/main/java/com/circle/modularwallets/core/models/PublicKeyCredentials.kt new file mode 100644 index 0000000..6a36079 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/PublicKeyCredentials.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Abstract class representing a public key credential. + * + * @property id The unique identifier for the credential. + * @property type The type of the credential. + * @property authenticatorAttachment The attachment type of the authenticator. + * @property response The response from the authenticator. + * @property clientExtensionResults Optional client extension results. + */ +abstract class PublicKeyCredential { + abstract val id: String + abstract val type: String + abstract val authenticatorAttachment: String + abstract val response: AuthenticatorResponse + abstract val clientExtensionResults: AuthenticationExtensionsClientOutputs? +} + +/** + * Abstract class representing a response from an authenticator. + * + * @property clientDataJSON The client data in JSON format. + */ +abstract class AuthenticatorResponse { + abstract val clientDataJSON: String +} + +/** + * Data class representing a registration credential. + * + * @property rawId The raw identifier for the credential. + * @property authenticatorAttachment The attachment type of the authenticator. + * @property type The type of the credential. + * @property id The unique identifier for the credential. + * @property response The attestation response from the authenticator. + * @property clientExtensionResults Optional client extension results. + */ +@JsonClass(generateAdapter = true) +data class RegistrationCredential( + @Json(name = "rawId") val rawId: String, + @Json(name = "authenticatorAttachment") override val authenticatorAttachment: String, + @Json(name = "type") override val type: String, + @Json(name = "id") override val id: String, + @Json(name = "response") override val response: AuthenticatorAttestationResponse, + @Json(name = "clientExtensionResults") override val clientExtensionResults: AuthenticationExtensionsClientOutputs?, +) : PublicKeyCredential() + +/** + * Data class representing an attestation response from an authenticator. + * + * @property clientDataJSON The client data in JSON format. + * @property attestationObject The attestation object. + * @property transports The list of supported transports. + * @property authenticatorData The authenticator data. + * @property publicKeyAlgorithm The public key algorithm. + * @property publicKey The public key. + */ +@JsonClass(generateAdapter = true) +data class AuthenticatorAttestationResponse( + @Json(name = "clientDataJSON") override val clientDataJSON: String, + @Json(name = "attestationObject") val attestationObject: String, + @Json(name = "transports") val transports: List, + @Json(name = "authenticatorData") val authenticatorData: String, + @Json(name = "publicKeyAlgorithm") val publicKeyAlgorithm: Int, + @Json(name = "publicKey") val publicKey: String, +) : AuthenticatorResponse() + +/** + * Data class representing an authentication credential. + * + * @property rawId The raw identifier for the credential. + * @property authenticatorAttachment The attachment type of the authenticator. + * @property type The type of the credential. + * @property id The unique identifier for the credential. + * @property response The assertion response from the authenticator. + * @property clientExtensionResults Optional client extension results. + */ +@JsonClass(generateAdapter = true) +data class AuthenticationCredential( + @Json(name = "rawId") val rawId: String, + @Json(name = "authenticatorAttachment") override val authenticatorAttachment: String, + @Json(name = "type") override val type: String, + @Json(name = "id") override val id: String, + @Json(name = "response") override val response: AuthenticatorAssertionResponse, + @Json(name = "clientExtensionResults") override val clientExtensionResults: AuthenticationExtensionsClientOutputs?, +) : PublicKeyCredential() + +/** + * Data class representing an assertion response from an authenticator. + * + * @property clientDataJSON The client data in JSON format. + * @property authenticatorData The authenticator data. + * @property signature The signature. + * @property userHandle The user handle. + */ +@JsonClass(generateAdapter = true) +data class AuthenticatorAssertionResponse( + @Json(name = "clientDataJSON") override val clientDataJSON: String, + @Json(name = "authenticatorData") val authenticatorData: String, + @Json(name = "signature") val signature: String, + @Json(name = "userHandle") val userHandle: String, +) : AuthenticatorResponse() + +/** + * Data class representing client extension outputs for authentication. + * + * @property credProps Optional credential properties output. + */ +@JsonClass(generateAdapter = true) +data class AuthenticationExtensionsClientOutputs @JvmOverloads constructor( + @Json(name = "credProps") val credProps: CredentialPropertiesOutput? = null, +) + +/** + * Data class representing credential properties output. + * + * @property rk Optional resident key property. + */ +@JsonClass(generateAdapter = true) +data class CredentialPropertiesOutput( + @Json(name = "rk") val rk: Boolean? = null, +) diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/SignResult.kt b/lib/src/main/java/com/circle/modularwallets/core/models/SignResult.kt new file mode 100644 index 0000000..3173682 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/SignResult.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import com.circle.modularwallets.core.utils.signature.base64DecodeToString +import com.circle.modularwallets.core.utils.signature.base64UrlToBytes + +/** + * Data class representing the result of a signing operation. + * + * @param signature The signature generated by the signing operation. + * @param webAuthn The WebAuthn data associated with the signing operation. + * @param raw The raw authentication credential used in the signing operation. + */ +data class SignResult( + val signature: String, + val webAuthn: WebAuthnData, + val raw: AuthenticationCredential, +) + +/** + * Data class representing WebAuthn data. + * + * @param authenticatorData The authenticator data in hexadecimal format. + * @param challengeIndex The index of the challenge in the client data JSON. + * @param clientDataJSON The client data JSON. + * @param typeIndex The index of the type in the client data JSON. + * @param userVerificationRequired Indicates whether user verification is required. + */ +data class WebAuthnData( + val authenticatorData: String, + val challengeIndex: Int, + val clientDataJSON: String, + val typeIndex: Int, + val userVerificationRequired: Boolean, +) + +internal fun AuthenticationCredential.toWebAuthnData(userVerification: String): WebAuthnData { + val decoded = base64DecodeToString(response.clientDataJSON) + + return WebAuthnData( + bytesToHex(base64UrlToBytes(response.authenticatorData)), + decoded.indexOf("\"challenge\""), + decoded, + decoded.indexOf("\"type\""), + userVerification == "required" + ) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/Token.kt b/lib/src/main/java/com/circle/modularwallets/core/models/Token.kt new file mode 100644 index 0000000..f03ea5e --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/Token.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.utils.abi.encodeTransfer + +/** + * Enum representing various tokens supported by [encodeTransfer]. + * The format is {chain}_{symbol} + */ +enum class Token { + Arbitrum_USDC, + Arbitrum_ARB, + ArbitrumSepolia_USDC, + Polygon_USDC, + PolygonAmoy_USDC; +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/UserOperation.kt b/lib/src/main/java/com/circle/modularwallets/core/models/UserOperation.kt new file mode 100644 index 0000000..b138995 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/UserOperation.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +import com.circle.modularwallets.core.utils.encoding.bigIntegerToHex +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigInteger + +/** + * Abstract class representing a user operation. + * + * @property sender The address of the sender. + * @property nonce The nonce of the operation. + * @property callData The data to be sent in the call. + * @property callGasLimit The gas limit for the call. + * @property verificationGasLimit The gas limit for verification. + * @property preVerificationGas The gas used before verification. + * @property maxPriorityFeePerGas The maximum priority fee per gas. + * @property maxFeePerGas The maximum fee per gas. + * @property signature The signature of the operation. + */ +abstract class UserOperation { + abstract var sender: String? + abstract var nonce: BigInteger? + abstract var callData: String? + abstract var callGasLimit: BigInteger? + abstract var verificationGasLimit: BigInteger? + abstract var preVerificationGas: BigInteger? + abstract var maxPriorityFeePerGas: BigInteger? + abstract var maxFeePerGas: BigInteger? + abstract var signature: String? +} + +/** + * Data class representing a user operation for version 0.7. + * + * @property sender The address of the sender. + * @property nonce The nonce of the operation. + * @property callData The data to be sent in the call. + * @property callGasLimit The gas limit for the call. + * @property verificationGasLimit The gas limit for verification. + * @property preVerificationGas The gas used before verification. + * @property maxPriorityFeePerGas The maximum priority fee per gas. + * @property maxFeePerGas The maximum fee per gas. + * @property signature The signature of the operation. + * @property factory The factory address. + * @property factoryData The data for the factory. + * @property paymaster The paymaster address. + * @property paymasterVerificationGasLimit The gas limit for paymaster verification. + * @property paymasterPostOpGasLimit The gas limit for paymaster post-operation. + * @property paymasterData The data for the paymaster. + */ +@JsonClass(generateAdapter = true) +data class UserOperationV07 @JvmOverloads constructor( + @Json(name = "sender") override var sender: String? = null, + @Json(name = "nonce") override var nonce: BigInteger? = null, + @Json(name = "callData") override var callData: String? = null, + @Json(name = "callGasLimit") override var callGasLimit: BigInteger? = null, + @Json(name = "verificationGasLimit") override var verificationGasLimit: BigInteger? = null, + @Json(name = "preVerificationGas") override var preVerificationGas: BigInteger? = null, + @Json(name = "maxPriorityFeePerGas") override var maxPriorityFeePerGas: BigInteger? = null, + @Json(name = "maxFeePerGas") override var maxFeePerGas: BigInteger? = null, + @Json(name = "signature") override var signature: String? = null, + @Json(name = "factory") var factory: String? = null, + @Json(name = "factoryData") var factoryData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimit: BigInteger? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimit: BigInteger? = null, + @Json(name = "paymasterData") var paymasterData: String? = null +) : UserOperation() + +/** + * Converts a `UserOperationV07` instance to a `UserOperationRpc` instance. + * + * @return A `UserOperationRpc` instance with the corresponding properties from the `UserOperationV07` instance. + */ +fun UserOperationV07.toRpcUserOperation(): UserOperationRpc { + return UserOperationRpc( + sender = this.sender, + nonce = bigIntegerToHex(this.nonce), + callData = this.callData, + callGasLimit = bigIntegerToHex(this.callGasLimit), + verificationGasLimit = bigIntegerToHex(this.verificationGasLimit), + preVerificationGas = bigIntegerToHex(this.preVerificationGas), + maxPriorityFeePerGas = bigIntegerToHex(this.maxPriorityFeePerGas), + maxFeePerGas = bigIntegerToHex(this.maxFeePerGas), + signature = this.signature, + factory = this.factory, + factoryData = this.factoryData, + paymaster = this.paymaster, + paymasterData = this.paymasterData, + paymasterVerificationGasLimit = bigIntegerToHex(this.paymasterVerificationGasLimit), + paymasterPostOpGasLimit = bigIntegerToHex(this.paymasterPostOpGasLimit), + ) +} + +/** + * Converts a `UserOperation` instance to a `UserOperationRpc` instance. + * + * @return A `UserOperationRpc` instance with the corresponding properties from the `UserOperation` instance. + */ +fun UserOperation.toRpcUserOperation(): UserOperationRpc { + return UserOperationRpc( + sender = this.sender, + nonce = bigIntegerToHex(this.nonce), + callData = this.callData, + callGasLimit = bigIntegerToHex(this.callGasLimit), + verificationGasLimit = bigIntegerToHex(this.verificationGasLimit), + preVerificationGas = bigIntegerToHex(this.preVerificationGas), + maxPriorityFeePerGas = bigIntegerToHex(this.maxPriorityFeePerGas), + maxFeePerGas = bigIntegerToHex(this.maxFeePerGas), + signature = this.signature + ) +} + +/** + * Converts a `UserOperationRpc` instance to a `UserOperationV07` instance. + * + * @return A `UserOperationV07` instance with the corresponding properties from the `UserOperationRpc` instance. + */ +fun UserOperationRpc.toUserOperationV07(): UserOperationV07 { + return UserOperationV07( + sender = this.sender, + nonce = hexToBigInteger(this.nonce), + callData = this.callData, + callGasLimit = hexToBigInteger(this.callGasLimit), + verificationGasLimit = hexToBigInteger(this.verificationGasLimit), + preVerificationGas = hexToBigInteger(this.preVerificationGas), + maxPriorityFeePerGas = hexToBigInteger(this.maxPriorityFeePerGas), + maxFeePerGas = hexToBigInteger(this.maxFeePerGas), + signature = this.signature, + factory = this.factory, + factoryData = this.factoryData, + paymaster = this.paymaster, + paymasterVerificationGasLimit = hexToBigInteger(this.paymasterVerificationGasLimit), + ) +} + +/** + * Data class representing a user operation in RPC format. + * + * @property sender The address of the sender. + * @property nonce The nonce of the operation. + * @property callData The data to be sent in the call. + * @property callGasLimit The gas limit for the call. + * @property verificationGasLimit The gas limit for verification. + * @property preVerificationGas The gas used before verification. + * @property maxPriorityFeePerGas The maximum priority fee per gas. + * @property maxFeePerGas The maximum fee per gas. + * @property signature The signature of the operation. + * @property factory The factory address. + * @property factoryData The data for the factory. + * @property paymaster The paymaster address. + * @property paymasterVerificationGasLimit The gas limit for paymaster verification. + * @property paymasterPostOpGasLimit The gas limit for paymaster post-operation. + * @property paymasterData The data for the paymaster. + * @property initCode The initialization code. + * @property paymasterAndData The paymaster and data. + */ +@JsonClass(generateAdapter = true) +data class UserOperationRpc @JvmOverloads constructor( + @Json(name = "sender") var sender: String? = null, + @Json(name = "nonce") var nonce: String? = null, + @Json(name = "callData") var callData: String? = null, + @Json(name = "callGasLimit") var callGasLimit: String? = null, + @Json(name = "verificationGasLimit") var verificationGasLimit: String? = null, + @Json(name = "preVerificationGas") var preVerificationGas: String? = null, + @Json(name = "maxPriorityFeePerGas") var maxPriorityFeePerGas: String? = null, + @Json(name = "maxFeePerGas") var maxFeePerGas: String? = null, + @Json(name = "signature") var signature: String? = null, + @Json(name = "factory") var factory: String? = null, + @Json(name = "factoryData") var factoryData: String? = null, + @Json(name = "paymaster") var paymaster: String? = null, + @Json(name = "paymasterVerificationGasLimit") var paymasterVerificationGasLimit: String? = null, + @Json(name = "paymasterPostOpGasLimit") var paymasterPostOpGasLimit: String? = null, + @Json(name = "paymasterData") var paymasterData: String? = null, + @Json(name = "initCode") var initCode: String? = null, + @Json(name = "paymasterAndData") var paymasterAndData: String? = null +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/WebAuthnMode.kt b/lib/src/main/java/com/circle/modularwallets/core/models/WebAuthnMode.kt new file mode 100644 index 0000000..6b721c3 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/WebAuthnMode.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.models + +/** + * Enum class representing the WebAuthn modes. + */ +enum class WebAuthnMode { + /** + * Mode for registering a new credential. + */ + Register, + + /** + * Mode for logging in with an existing credential. + */ + Login +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/transports/JsonRpcReqResp.kt b/lib/src/main/java/com/circle/modularwallets/core/transports/JsonRpcReqResp.kt new file mode 100644 index 0000000..db49c10 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/transports/JsonRpcReqResp.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.transports + +import androidx.annotation.Keep +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +data class RpcRequest @JvmOverloads constructor( + @Json(name = "method") val method: String, + @Json(name = "params") val params: Any? = null, + @Json(name = "id") val id: Long = System.currentTimeMillis(), + @Json(name = "jsonrpc") val jsonrpc: String = "2.0", +) + +@Keep +@JsonClass(generateAdapter = true) +data class RpcResponse( + @Json(name = "id") val id: String, + @Json(name = "jsonrpc") val jsonrpc: String, + @Json(name = "error") val error: JsonRpcError?, + @Json(name = "result") val result: Any?, +) + +@Keep +@JsonClass(generateAdapter = true) +data class JsonRpcError( + @Json(name = "code") val code: Int, + @Json(name = "message") val message: String +) + +@Keep +@JsonClass(generateAdapter = true) +data class HttpError( + @Json(name = "statusCode") val statusCode: Int? = null, + @Json(name = "error") val error: String? = null, + @Json(name = "message") val message: String? = null, +) \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/transports/Transport.kt b/lib/src/main/java/com/circle/modularwallets/core/transports/Transport.kt new file mode 100644 index 0000000..85731ea --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/transports/Transport.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.transports + +import retrofit2.Response + +/** + * Interface representing a transport mechanism for making RPC requests. + */ +interface Transport { + /** + * Sends an RPC request and returns the response. + * + * @param req The RPC request to be sent. + * @return The response from the RPC request. + */ + suspend fun request(req: RpcRequest): Response +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/transports/http/HttpTransport.kt b/lib/src/main/java/com/circle/modularwallets/core/transports/http/HttpTransport.kt new file mode 100644 index 0000000..e2c27dc --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/transports/http/HttpTransport.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.circle.modularwallets.core.transports.http + +import android.content.Context +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.RpcResponse +import com.circle.modularwallets.core.transports.Transport +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +/** + * Data class representing HTTP RPC client options. + * + * @property headers Optional headers to include in the HTTP requests. + */ +data class HttpRpcClientOptions @JvmOverloads constructor( + val headers: Map? = null +) + +interface HttpTransport : Transport { + override suspend fun request(@Body req: RpcRequest): Response +} + +internal class HttpTransportImpl(val url: String, private val service: RetrofitService) : + HttpTransport { + override suspend fun request(req: RpcRequest): Response { + return service.request(url, req) + } +} + +internal interface RetrofitService { + @POST + suspend fun request(@Url url: String, @Body req: RpcRequest): Response +} + +/** + * Creates a HTTP transport instance. + * + * @param context The application context. + * @param clientKey The client key for authorization. + * @param url The URL for the HTTP transport. + * @return The configured HTTP transport instance. + */ +fun toModularTransport( + context: Context, + clientKey: String, + url: String +): HttpTransport { + return http( + context, + url, + HttpRpcClientOptions(headers = mapOf("Authorization" to "Bearer $clientKey")) + ) +} + +/** + * Creates a HTTP transport instance. + * + * @param context The application context. + * @param clientKey The client key for authorization. + * @param url The URL for the HTTP transport. + * @return The configured HTTP transport instance. + */ +fun toPasskeyTransport( + context: Context, + clientKey: String, + url: String +): HttpTransport { + return http( + context, + url, + HttpRpcClientOptions(headers = mapOf("Authorization" to "Bearer $clientKey")) + ) +} + +/** + * Creates an HTTP transport instance. + * + * @param context The application context. + * @param url The URL for the HTTP transport. + * @param config The configuration options for the HTTP transport (default is an empty configuration). + * @return The configured HTTP transport instance. + */ + +@JvmOverloads +fun http( + context: Context, + url: String, + config: HttpRpcClientOptions = HttpRpcClientOptions() +): HttpTransport { + val transport = RetrofitProvider().get( + context, + url, config + ).create(RetrofitService::class.java) + return HttpTransportImpl(url, transport) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/transports/http/RetrofitProvider.kt b/lib/src/main/java/com/circle/modularwallets/core/transports/http/RetrofitProvider.kt new file mode 100644 index 0000000..6d1beee --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/transports/http/RetrofitProvider.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.circle.modularwallets.core.transports.http + +import android.content.Context +import android.text.TextUtils +import android.util.Log +import com.circle.modularwallets.core.BuildConfig +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.HttpRequestError +import com.circle.modularwallets.core.errors.TimeoutError +import com.circle.modularwallets.core.utils.rpc.ParseInterceptor +import com.circle.modularwallets.core.utils.rpc.getAppInfo +import com.circle.modularwallets.core.utils.rpc.getBodyString +import com.circle.modularwallets.core.utils.rpc.getMoshi +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.io.IOException +import java.net.SocketTimeoutException +import java.util.concurrent.TimeUnit + +internal class RetrofitProvider { + + fun get(context: Context, baseUrl: String, config: HttpRpcClientOptions): Retrofit { + val splitUrl = baseUrl.split("?") + val finalBaseUrl = if (TextUtils.isEmpty(splitUrl[0])) { + "https://modular-sdk.circle.com/v1/rpc/w3s/buidl/" // placeholder + } else if (splitUrl[0].endsWith("/")) { + splitUrl[0] + } else { + "${splitUrl[0]}/" + } + + val appInfo = getAppInfo(context) + val clientBuilder = OkHttpClient.Builder() + val requestInterceptor = Interceptor { chain -> + val original = chain.request() + try { + chain.proceed( + original.newBuilder() + .apply { + config.headers?.let { + it.forEach { entry -> header(entry.key, entry.value) } + } + } + .header("X-AppInfo", appInfo) + .method(original.method, original.body) + .build() + ) + } catch (e: Throwable) { + val body = getBodyString(original.body) + val url = original.url.toString() + when (e) { + is SocketTimeoutException -> throw IOException( + TimeoutError(body, url) + ) + + is IOException -> throw e + is BaseError -> throw IOException( + e + ) + + else -> throw IOException( + HttpRequestError( + body = body, + cause = e, + url = url + ) + ) + } + } + } + clientBuilder.addInterceptor(requestInterceptor) + clientBuilder.addInterceptor(ParseInterceptor) + clientBuilder.readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + val moshi = getMoshi() + if (BuildConfig.INTERNAL_BUILD) { + val logging = + HttpLoggingInterceptor { message: String? -> Log.d(" Http", message!!) } + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + clientBuilder.addInterceptor(logging) + } + return Retrofit.Builder() + .baseUrl(finalBaseUrl) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .client(clientBuilder.build()) + .build() + } +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/Extensions.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/Extensions.kt new file mode 100644 index 0000000..d806cbe --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/Extensions.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils + +import kotlin.reflect.full.memberProperties + +inline fun T.toMap(): Map { + val props = T::class.memberProperties.associateBy { it.name } + return props.keys.associateWith { props[it]?.get(this) } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/JsonConversionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/JsonConversionUtils.kt new file mode 100644 index 0000000..ece1760 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/JsonConversionUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils + +import com.google.gson.Gson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + + +internal fun toJson(obj: Any?): String { + obj ?: return "" + if (obj is String) { + return obj + } + return Gson().toJson(obj) +} + +internal fun fromJson(jsonString: String, type: Class): T? { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + val adapter: JsonAdapter = moshi.adapter(type) + return adapter.fromJson(jsonString) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/Logger.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/Logger.kt new file mode 100644 index 0000000..27f5407 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/Logger.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils + +import android.util.Log +import com.circle.modularwallets.core.BuildConfig +import org.json.JSONObject + +internal object Logger { + private const val TAG = "" + private val isLogEnabled: Boolean + get() = BuildConfig.INTERNAL_BUILD + + fun sdkTag(tag: String?): String { + return tag?.let { "$TAG $tag" } ?: TAG + } + + fun i(tag: String? = null, msg: String, tr: Throwable? = null) { + if (!isLogEnabled) return + if (tr != null) { + Log.i(sdkTag(tag), msg, tr) + } else { + Log.i(sdkTag(tag), msg) + } + } + + fun d(tag: String? = null, msg: String, tr: Throwable? = null) { + if (!isLogEnabled) return + if (tr != null) { + Log.d(sdkTag(tag), msg, tr) + } else { + Log.d(sdkTag(tag), msg) + } + } + + fun w(tag: String? = null, msg: String, tr: Throwable? = null) { + if (!isLogEnabled) return + if (tr != null) { + Log.w(sdkTag(tag), msg, tr) + } else { + Log.w(sdkTag(tag), msg) + } + } + + @JvmStatic + @JvmOverloads + fun e(tag: String? = null, msg: String, tr: Throwable? = null) { + if (!isLogEnabled) return + if (tr != null) { + Log.e(sdkTag(tag), msg, tr) + } else { + Log.e(sdkTag(tag), msg) + } + } +} + +fun prettyPrint(args: Map): String { + val entries = args.entries + .mapNotNull { (key, value) -> + if (value == null || value == false) null else key to value + } + + val maxLength = entries.fold(0) { acc, (key) -> + maxOf(acc, key.length) + } + + return entries.joinToString("\n") { (key, value) -> + " ${"${key}:".padEnd(maxLength + 1)} $value" + } +} + +fun prettyPrintJson(obj: Any?): String { + return JSONObject(toJson(obj)).toString(2) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/NonceManager.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/NonceManager.kt new file mode 100644 index 0000000..3245aba --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/NonceManager.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils + +import java.math.BigInteger + +data class FunctionParameters(val address: String, val chainId: Long) + +interface NonceManagerSource { + fun get(parameters: FunctionParameters): BigInteger + fun set(parameters: FunctionParameters, nonce: BigInteger) +} + +internal class NonceManager(private val source: NonceManagerSource) { + private val deltaMap = mutableMapOf() + private val nonceMap = mutableMapOf() + + private fun getKey(params: FunctionParameters) = "${params.address}.${params.chainId}" + /** + * Increase delta + * Update nonceMap with value (source nonce or previousNonce + 1) + delta. + * The value will be used as previousNonce next time. + * */ + fun consume(params: FunctionParameters): BigInteger { + val key = getKey(params) + increment(params) + val nonce = get(params) + source.set(params, nonce) + nonceMap[key] = nonce + return nonce + } + /** Increase delta */ + private fun increment(params: FunctionParameters) { + val key = getKey(params) + val delta = deltaMap[key] ?: BigInteger.ZERO + deltaMap[key] = delta.plus(BigInteger.ONE) + } + /** Return (source nonce or previousNonce + 1) + delta */ + fun get(params: FunctionParameters): BigInteger { + val key = getKey(params) + val delta = deltaMap[key] ?: BigInteger.ZERO + val nonce = internalGet(params, key) + return delta + nonce + } + /** Reset delta */ + private fun reset(params: FunctionParameters) { + val key = getKey(params) + deltaMap.remove(key) + } + /** Return source nonce or previousNonce + 1 */ + private fun internalGet(params: FunctionParameters, key: String): BigInteger { + val nonce: BigInteger + try { + val fetchedNonce = source.get(params) + val previousNonce = nonceMap[key] ?: BigInteger.ZERO + if (previousNonce > BigInteger.ZERO && fetchedNonce <= previousNonce) { + nonce = previousNonce.plus(BigInteger.ONE) + } else { + nonceMap.remove(key) + nonce = fetchedNonce + } + } finally { + reset(params) + } + return nonce + } +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeAbiParametersUtil.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeAbiParametersUtil.kt new file mode 100644 index 0000000..996d9fb --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeAbiParametersUtil.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import org.web3j.abi.DefaultFunctionEncoder +import org.web3j.abi.datatypes.Type + +fun encodeAbiParameters(parameters: List>): String { + return "0x${ + DefaultFunctionEncoder().encodeParameters(parameters) + }" +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeCallDataUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeCallDataUtils.kt new file mode 100644 index 0000000..7f33a53 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeCallDataUtils.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import com.circle.modularwallets.core.errors.InvalidAddressError +import com.circle.modularwallets.core.models.EncodeCallDataArg +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.DynamicArray +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.DynamicStruct +import org.web3j.abi.datatypes.Function +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.utils.Numeric +import java.math.BigInteger + +/** + * Encodes the argument into calldata for executing a User Operation. + * + * @param arg The call data argument. + * @return The encoded call data. + */ +internal fun encodeCallData(arg: EncodeCallDataArg): String { + if (!isAddress(arg.to)) { + throw InvalidAddressError(arg.to) + } + + val function = + Function( + "execute", + listOf>( + Address(arg.to), + Uint256(arg.value ?: BigInteger.ZERO), + DynamicBytes(Numeric.hexStringToByteArray(arg.data ?: "0x")) + ), + mutableListOf(), + ) + return FunctionEncoder.encode(function) +} + +private fun isAddress(to: String): Boolean { + val addressRegex = Regex("^0x[a-fA-F0-9]{40}$") + return addressRegex.matches(to) +} + +/** + * Encodes the array of arguments into calldata for executing a User Operation. + * + * @param args The array of call data arguments. + * @return The encoded call data. + */ +internal fun encodeCallData(args: Array): String { + if (args.size == 1) { + return encodeCallData(args[0]) + } + if (args.isEmpty()) { // empty call + return "0x34fcd5be00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000" + } + val tupleArray: MutableList = mutableListOf() + for (arg in args) { + tupleArray.add( + DynamicStruct( + Address(arg.to), + Uint256(arg.value ?: BigInteger.ZERO), + DynamicBytes(Numeric.hexStringToByteArray(arg.data ?: "0x")) + ) + ) + } + val function = + Function( + "executeBatch", + listOf>( + DynamicArray(DynamicStruct::class.java, tupleArray), + ), + mutableListOf(), + ) + return FunctionEncoder.encode(function) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeContractExecutionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeContractExecutionUtils.kt new file mode 100644 index 0000000..336238f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeContractExecutionUtils.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import com.circle.modularwallets.core.models.EncodeCallDataArg +import org.web3j.protocol.core.methods.response.AbiDefinition +import java.math.BigInteger + +/** + * Encodes a contract execution into calldata for executing a User Operation. + * + * @param to The recipient address. + * @param abiSignature The ABI signature. + * @param args The arguments for the function call. + * @param value The value to send with the transaction. + * @return The encoded call data. + */ + +@JvmOverloads +fun encodeContractExecution( + to: String, + abiSignature: String, + args: Array = emptyArray(), + value: BigInteger +): String { + val encodedAbi = encodeFromAbiSignature(abiSignature, args) + val arg = EncodeCallDataArg(to, value, encodedAbi) + return encodeCallData(arg) +} + +internal fun encodeFromAbiSignature( + abiSignature: String, + args: Array = emptyArray(), +): String { + val abiDefinition = getAbiDefinitionFromAbiSignature(abiSignature) + val encodedAbi = encodeFunctionData(abiDefinition.name, abiDefinition, args) + return encodedAbi +} + +private fun getAbiDefinitionFromAbiSignature(abiSignature: String): AbiDefinition { + val functionName = getFunctionName(abiSignature) + val inputs: List = getNamedTypesFromAbiSignature(abiSignature) + val abiDefinition = AbiDefinition() + abiDefinition.name = functionName + abiDefinition.inputs = inputs + abiDefinition.type = "function" + abiDefinition.stateMutability = "nonpayable" + + return abiDefinition +} + +private fun getNamedTypesFromAbiSignature(abiSignature: String): List { + val regex = Regex("(\\w+)\\((.*)\\)") + val matchResult = regex.matchEntire(abiSignature) + + val paramTypeString = matchResult?.groups?.get(2)?.value ?: "" + val paramTypes = + if (paramTypeString.isBlank()) emptyList() else parseParamTypes(paramTypeString) + + return paramTypes.map { parseType(it) } +} + +private fun parseParamTypes(paramTypeString: String): List { + val paramTypes = mutableListOf() + var depth = 0 + val currentType = StringBuilder() + + for (c in paramTypeString) { + if (c == ',' && depth == 0) { + paramTypes.add(currentType.toString()) + currentType.setLength(0) + } else { + if (c == '(') { + depth++ + currentType.append("tuple") + } + if (c == ')') depth-- + currentType.append(c) + } + } + if (currentType.isNotEmpty()) { + paramTypes.add(currentType.toString()) + } + + return paramTypes +} + +private fun parseType(type: String): AbiDefinition.NamedType { + return when { + type.startsWith("tuple") -> { + val componentsString = type.substringAfter("tuple(").substringBeforeLast(")") + val componentTypes = parseParamTypes(componentsString) + val components = componentTypes.map { parseType(it.trim()) } + AbiDefinition.NamedType("", "tuple", components, "", false) + } + + else -> { + AbiDefinition.NamedType("", type) + } + } +} + +private fun getFunctionName(abiSignature: String): String { + val regex = Regex("(\\w+)\\((.*)\\)") + val matchResult = regex.matchEntire(abiSignature) + + val functionName = matchResult?.groups?.get(1)?.value ?: "" + return functionName +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeFunctionDataUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeFunctionDataUtils.kt new file mode 100644 index 0000000..0a5eba5 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeFunctionDataUtils.kt @@ -0,0 +1,763 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.BaseErrorParameters +import com.google.gson.Gson +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.AbiTypes +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Bool +import org.web3j.abi.datatypes.DynamicArray +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.DynamicStruct +import org.web3j.abi.datatypes.Function +import org.web3j.abi.datatypes.StaticStruct +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.Utf8String +import org.web3j.abi.datatypes.generated.Bytes1 +import org.web3j.abi.datatypes.generated.Bytes10 +import org.web3j.abi.datatypes.generated.Bytes11 +import org.web3j.abi.datatypes.generated.Bytes12 +import org.web3j.abi.datatypes.generated.Bytes13 +import org.web3j.abi.datatypes.generated.Bytes14 +import org.web3j.abi.datatypes.generated.Bytes15 +import org.web3j.abi.datatypes.generated.Bytes16 +import org.web3j.abi.datatypes.generated.Bytes17 +import org.web3j.abi.datatypes.generated.Bytes18 +import org.web3j.abi.datatypes.generated.Bytes19 +import org.web3j.abi.datatypes.generated.Bytes2 +import org.web3j.abi.datatypes.generated.Bytes20 +import org.web3j.abi.datatypes.generated.Bytes21 +import org.web3j.abi.datatypes.generated.Bytes22 +import org.web3j.abi.datatypes.generated.Bytes23 +import org.web3j.abi.datatypes.generated.Bytes24 +import org.web3j.abi.datatypes.generated.Bytes25 +import org.web3j.abi.datatypes.generated.Bytes26 +import org.web3j.abi.datatypes.generated.Bytes27 +import org.web3j.abi.datatypes.generated.Bytes28 +import org.web3j.abi.datatypes.generated.Bytes29 +import org.web3j.abi.datatypes.generated.Bytes3 +import org.web3j.abi.datatypes.generated.Bytes30 +import org.web3j.abi.datatypes.generated.Bytes31 +import org.web3j.abi.datatypes.generated.Bytes32 +import org.web3j.abi.datatypes.generated.Bytes4 +import org.web3j.abi.datatypes.generated.Bytes5 +import org.web3j.abi.datatypes.generated.Bytes6 +import org.web3j.abi.datatypes.generated.Bytes7 +import org.web3j.abi.datatypes.generated.Bytes8 +import org.web3j.abi.datatypes.generated.Bytes9 +import org.web3j.abi.datatypes.generated.Int104 +import org.web3j.abi.datatypes.generated.Int112 +import org.web3j.abi.datatypes.generated.Int120 +import org.web3j.abi.datatypes.generated.Int128 +import org.web3j.abi.datatypes.generated.Int136 +import org.web3j.abi.datatypes.generated.Int144 +import org.web3j.abi.datatypes.generated.Int152 +import org.web3j.abi.datatypes.generated.Int16 +import org.web3j.abi.datatypes.generated.Int160 +import org.web3j.abi.datatypes.generated.Int168 +import org.web3j.abi.datatypes.generated.Int176 +import org.web3j.abi.datatypes.generated.Int184 +import org.web3j.abi.datatypes.generated.Int192 +import org.web3j.abi.datatypes.generated.Int200 +import org.web3j.abi.datatypes.generated.Int208 +import org.web3j.abi.datatypes.generated.Int216 +import org.web3j.abi.datatypes.generated.Int224 +import org.web3j.abi.datatypes.generated.Int232 +import org.web3j.abi.datatypes.generated.Int24 +import org.web3j.abi.datatypes.generated.Int240 +import org.web3j.abi.datatypes.generated.Int248 +import org.web3j.abi.datatypes.generated.Int256 +import org.web3j.abi.datatypes.generated.Int32 +import org.web3j.abi.datatypes.generated.Int40 +import org.web3j.abi.datatypes.generated.Int48 +import org.web3j.abi.datatypes.generated.Int56 +import org.web3j.abi.datatypes.generated.Int64 +import org.web3j.abi.datatypes.generated.Int72 +import org.web3j.abi.datatypes.generated.Int8 +import org.web3j.abi.datatypes.generated.Int80 +import org.web3j.abi.datatypes.generated.Int88 +import org.web3j.abi.datatypes.generated.Int96 +import org.web3j.abi.datatypes.generated.Uint104 +import org.web3j.abi.datatypes.generated.Uint112 +import org.web3j.abi.datatypes.generated.Uint120 +import org.web3j.abi.datatypes.generated.Uint128 +import org.web3j.abi.datatypes.generated.Uint136 +import org.web3j.abi.datatypes.generated.Uint144 +import org.web3j.abi.datatypes.generated.Uint152 +import org.web3j.abi.datatypes.generated.Uint16 +import org.web3j.abi.datatypes.generated.Uint160 +import org.web3j.abi.datatypes.generated.Uint168 +import org.web3j.abi.datatypes.generated.Uint176 +import org.web3j.abi.datatypes.generated.Uint184 +import org.web3j.abi.datatypes.generated.Uint192 +import org.web3j.abi.datatypes.generated.Uint200 +import org.web3j.abi.datatypes.generated.Uint208 +import org.web3j.abi.datatypes.generated.Uint216 +import org.web3j.abi.datatypes.generated.Uint224 +import org.web3j.abi.datatypes.generated.Uint232 +import org.web3j.abi.datatypes.generated.Uint24 +import org.web3j.abi.datatypes.generated.Uint240 +import org.web3j.abi.datatypes.generated.Uint248 +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.abi.datatypes.generated.Uint32 +import org.web3j.abi.datatypes.generated.Uint40 +import org.web3j.abi.datatypes.generated.Uint48 +import org.web3j.abi.datatypes.generated.Uint56 +import org.web3j.abi.datatypes.generated.Uint64 +import org.web3j.abi.datatypes.generated.Uint72 +import org.web3j.abi.datatypes.generated.Uint8 +import org.web3j.abi.datatypes.generated.Uint80 +import org.web3j.abi.datatypes.generated.Uint88 +import org.web3j.abi.datatypes.generated.Uint96 +import org.web3j.crypto.Hash +import org.web3j.protocol.core.methods.response.AbiDefinition +import org.web3j.utils.Numeric +import java.math.BigInteger + +const val TYPE_FUNCTION: String = "function" + +/** + * Encodes the function name and parameters into an ABI encoded value (4 byte selector & arguments). + * + * @param functionName The function to encode from the ABI. + * @param abiJson The ABI definition in JSON format. + * @param args The arguments for the function call. + * @return The encoded function call data. + */ + +@Throws(Exception::class) +@JvmOverloads +fun encodeFunctionData( + functionName: String, + abiJson: String, + args: Array = emptyArray() +): String { + val abiDefinition = + getAbiDefinition(functionName, abiJson) ?: throw BaseError("Invalid abiJson: $abiJson") + return encodeFunctionData(functionName, abiDefinition, args) +} + +internal fun encodeFunctionData( + functionName: String, + abiDefinition: AbiDefinition, + args: Array = emptyArray() +): String { + if (abiDefinition.inputs.size != args.size) { + throw BaseError("AbiEncodingLengthMismatchError") + } + try { + val finalInputs = inputFormat(abiDefinition.inputs, args) + val finalOutputs = emptyList>() + val function = + Function( + functionName, + finalInputs, + finalOutputs + ) + return encodeFunction(abiDefinition, function) + } catch (e: Throwable) { + if (e is BaseError) { + throw e + } + throw BaseError("encode function failed", BaseErrorParameters(e)) + } +} +internal fun encodeFunction(abiDefinition: AbiDefinition, function: Function): String { + val oriAbi = FunctionEncoder.encode(function) + val encodedParameters = oriAbi.substring(10) + val methodSignature = buildMethodSignature(abiDefinition) + val methodId = buildMethodId(methodSignature) + return "${methodId}${encodedParameters}" +} + +internal fun buildMethodSignature(abiDefinition: AbiDefinition): String { + val type = buildMethodSignatureType(abiDefinition.inputs) + return "${abiDefinition.name}$type" +} + +internal fun buildMethodSignatureType(nameTypes: List): String { + val sb = StringBuilder() + sb.append("(") + nameTypes.forEachIndexed { index, input -> + if (input.type.startsWith("tuple")) { + val type = buildMethodSignatureType(input.components) + sb.append(input.type.replace("tuple", type)) + } else { + sb.append(input.type) + } + if (index != nameTypes.size - 1) { + sb.append(",") + } + } + sb.append(")") + return sb.toString() +} + +internal fun buildMethodId(methodSignature: String): String { + val input = methodSignature.toByteArray() + val hash = Hash.sha3(input) + return Numeric.toHexString(hash).substring(0, 10) +} + +@Throws(Exception::class) +private fun inputFormat( + inputNameTypes: List, + params: Array +): List> { + val finalInputs: MutableList> = ArrayList() + for (i in inputNameTypes.indices) { + val type = inputNameTypes[i].type + val inputType: Type<*> = when { + type.endsWith("[]") -> { + val elementType = type.removeSuffix("[]") + val array = (params[i] as? Array<*>)?.map { it as Any }?.toTypedArray() + ?: throw BaseError("Expected an array, but got ${params[i]::class}") + + getDynamicArray(elementType, array) + } + + + + type.startsWith("tuple") -> { + getTuple(params[i]) + } + + else -> { + getTypeInstance(type, params[i]) + } + } + finalInputs.add(inputType) + } + return finalInputs +} + +internal fun getTuple(param: Any): Type<*> { + return when (param) { + is StaticStruct -> param + is DynamicStruct -> param + is List<*> -> { + val tupleElements = param.filterIsInstance>() + val hasDynamicType = param.any { it is DynamicBytes || it is Utf8String || it is DynamicArray<*> } + if (hasDynamicType) { + DynamicStruct(tupleElements) + } else { + StaticStruct(tupleElements) + } + } + + else -> { + val tupleElements = (param as? Array<*>)?.mapNotNull { it as? Type<*> } + ?: throw BaseError("Expected an array, but got ${param::class}") + val hasDynamicType = tupleElements.any { it is DynamicBytes || it is Utf8String || it is DynamicArray<*> } + if (hasDynamicType) { + DynamicStruct(tupleElements) + } else { + StaticStruct(tupleElements) + } + } + } +} + +private fun getDynamicArray(type: String, param: Array): DynamicArray<*> { + return when (type) { + "address" -> DynamicArray(Address::class.java, param.map { Address(it.toString()) }) + "bool" -> DynamicArray(Bool::class.java, param.map { Bool(it.toString().toBoolean()) }) + "string" -> DynamicArray(Utf8String::class.java, param.map { Utf8String(it.toString()) }) + "bytes" -> DynamicArray( + DynamicBytes::class.java, + param.map { DynamicBytes(Numeric.hexStringToByteArray(it.toString())) }) + + "uint8" -> DynamicArray(Uint8::class.java, param.map { Uint8(BigInteger(it.toString())) }) + "int8" -> DynamicArray(Int8::class.java, param.map { Int8(BigInteger(it.toString())) }) + "uint16" -> DynamicArray( + Uint16::class.java, + param.map { Uint16(BigInteger(it.toString())) }) + + "int16" -> DynamicArray(Int16::class.java, param.map { Int16(BigInteger(it.toString())) }) + "uint24" -> DynamicArray( + Uint24::class.java, + param.map { Uint24(BigInteger(it.toString())) }) + + "int24" -> DynamicArray(Int24::class.java, param.map { Int24(BigInteger(it.toString())) }) + "uint32" -> DynamicArray( + Uint32::class.java, + param.map { Uint32(BigInteger(it.toString())) }) + + "int32" -> DynamicArray(Int32::class.java, param.map { Int32(BigInteger(it.toString())) }) + "uint40" -> DynamicArray( + Uint40::class.java, + param.map { Uint40(BigInteger(it.toString())) }) + + "int40" -> DynamicArray(Int40::class.java, param.map { Int40(BigInteger(it.toString())) }) + "uint48" -> DynamicArray( + Uint48::class.java, + param.map { Uint48(BigInteger(it.toString())) }) + + "int48" -> DynamicArray(Int48::class.java, param.map { Int48(BigInteger(it.toString())) }) + "uint56" -> DynamicArray( + Uint56::class.java, + param.map { Uint56(BigInteger(it.toString())) }) + + "int56" -> DynamicArray(Int56::class.java, param.map { Int56(BigInteger(it.toString())) }) + "uint64" -> DynamicArray( + Uint64::class.java, + param.map { Uint64(BigInteger(it.toString())) }) + + "int64" -> DynamicArray(Int64::class.java, param.map { Int64(BigInteger(it.toString())) }) + "uint72" -> DynamicArray( + Uint72::class.java, + param.map { Uint72(BigInteger(it.toString())) }) + + "int72" -> DynamicArray(Int72::class.java, param.map { Int72(BigInteger(it.toString())) }) + "uint80" -> DynamicArray( + Uint80::class.java, + param.map { Uint80(BigInteger(it.toString())) }) + + "int80" -> DynamicArray(Int80::class.java, param.map { Int80(BigInteger(it.toString())) }) + "uint88" -> DynamicArray( + Uint88::class.java, + param.map { Uint88(BigInteger(it.toString())) }) + + "int88" -> DynamicArray(Int88::class.java, param.map { Int88(BigInteger(it.toString())) }) + "uint96" -> DynamicArray( + Uint96::class.java, + param.map { Uint96(BigInteger(it.toString())) }) + + "int96" -> DynamicArray(Int96::class.java, param.map { Int96(BigInteger(it.toString())) }) + "uint104" -> DynamicArray( + Uint104::class.java, + param.map { Uint104(BigInteger(it.toString())) }) + + "int104" -> DynamicArray( + Int104::class.java, + param.map { Int104(BigInteger(it.toString())) }) + + "uint112" -> DynamicArray( + Uint112::class.java, + param.map { Uint112(BigInteger(it.toString())) }) + + "int112" -> DynamicArray( + Int112::class.java, + param.map { Int112(BigInteger(it.toString())) }) + + "uint120" -> DynamicArray( + Uint120::class.java, + param.map { Uint120(BigInteger(it.toString())) }) + + "int120" -> DynamicArray( + Int120::class.java, + param.map { Int120(BigInteger(it.toString())) }) + + "uint128" -> DynamicArray( + Uint128::class.java, + param.map { Uint128(BigInteger(it.toString())) }) + + "int128" -> DynamicArray( + Int128::class.java, + param.map { Int128(BigInteger(it.toString())) }) + + "uint136" -> DynamicArray( + Uint136::class.java, + param.map { Uint136(BigInteger(it.toString())) }) + + "int136" -> DynamicArray( + Int136::class.java, + param.map { Int136(BigInteger(it.toString())) }) + + "uint144" -> DynamicArray( + Uint144::class.java, + param.map { Uint144(BigInteger(it.toString())) }) + + "int144" -> DynamicArray( + Int144::class.java, + param.map { Int144(BigInteger(it.toString())) }) + + "uint152" -> DynamicArray( + Uint152::class.java, + param.map { Uint152(BigInteger(it.toString())) }) + + "int152" -> DynamicArray( + Int152::class.java, + param.map { Int152(BigInteger(it.toString())) }) + + "uint160" -> DynamicArray( + Uint160::class.java, + param.map { Uint160(BigInteger(it.toString())) }) + + "int160" -> DynamicArray( + Int160::class.java, + param.map { Int160(BigInteger(it.toString())) }) + + "uint168" -> DynamicArray( + Uint168::class.java, + param.map { Uint168(BigInteger(it.toString())) }) + + "int168" -> DynamicArray( + Int168::class.java, + param.map { Int168(BigInteger(it.toString())) }) + + "uint176" -> DynamicArray( + Uint176::class.java, + param.map { Uint176(BigInteger(it.toString())) }) + + "int176" -> DynamicArray( + Int176::class.java, + param.map { Int176(BigInteger(it.toString())) }) + + "uint184" -> DynamicArray( + Uint184::class.java, + param.map { Uint184(BigInteger(it.toString())) }) + + "int184" -> DynamicArray( + Int184::class.java, + param.map { Int184(BigInteger(it.toString())) }) + + "uint192" -> DynamicArray( + Uint192::class.java, + param.map { Uint192(BigInteger(it.toString())) }) + + "int192" -> DynamicArray( + Int192::class.java, + param.map { Int192(BigInteger(it.toString())) }) + + "uint200" -> DynamicArray( + Uint200::class.java, + param.map { Uint200(BigInteger(it.toString())) }) + + "int200" -> DynamicArray( + Int200::class.java, + param.map { Int200(BigInteger(it.toString())) }) + + "uint208" -> DynamicArray( + Uint208::class.java, + param.map { Uint208(BigInteger(it.toString())) }) + + "int208" -> DynamicArray( + Int208::class.java, + param.map { Int208(BigInteger(it.toString())) }) + + "uint216" -> DynamicArray( + Uint216::class.java, + param.map { Uint216(BigInteger(it.toString())) }) + + "int216" -> DynamicArray( + Int216::class.java, + param.map { Int216(BigInteger(it.toString())) }) + + "uint224" -> DynamicArray( + Uint224::class.java, + param.map { Uint224(BigInteger(it.toString())) }) + + "int224" -> DynamicArray( + Int224::class.java, + param.map { Int224(BigInteger(it.toString())) }) + + "uint232" -> DynamicArray( + Uint232::class.java, + param.map { Uint232(BigInteger(it.toString())) }) + + "int232" -> DynamicArray( + Int232::class.java, + param.map { Int232(BigInteger(it.toString())) }) + + "uint240" -> DynamicArray( + Uint240::class.java, + param.map { Uint240(BigInteger(it.toString())) }) + + "int240" -> DynamicArray( + Int240::class.java, + param.map { Int240(BigInteger(it.toString())) }) + + "uint248" -> DynamicArray( + Uint248::class.java, + param.map { Uint248(BigInteger(it.toString())) }) + + "int248" -> DynamicArray( + Int248::class.java, + param.map { Int248(BigInteger(it.toString())) }) + + "uint256" -> DynamicArray( + Uint256::class.java, + param.map { Uint256(BigInteger(it.toString())) }) + + "int256" -> DynamicArray( + Int256::class.java, + param.map { Int256(BigInteger(it.toString())) }) + + "bytes1" -> DynamicArray( + Bytes1::class.java, + param.map { Bytes1(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes2" -> DynamicArray( + Bytes2::class.java, + param.map { Bytes2(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes3" -> DynamicArray( + Bytes3::class.java, + param.map { Bytes3(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes4" -> DynamicArray( + Bytes4::class.java, + param.map { Bytes4(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes5" -> DynamicArray( + Bytes5::class.java, + param.map { Bytes5(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes6" -> DynamicArray( + Bytes6::class.java, + param.map { Bytes6(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes7" -> DynamicArray( + Bytes7::class.java, + param.map { Bytes7(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes8" -> DynamicArray( + Bytes8::class.java, + param.map { Bytes8(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes9" -> DynamicArray( + Bytes9::class.java, + param.map { Bytes9(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes10" -> DynamicArray( + Bytes10::class.java, + param.map { Bytes10(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes11" -> DynamicArray( + Bytes11::class.java, + param.map { Bytes11(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes12" -> DynamicArray( + Bytes12::class.java, + param.map { Bytes12(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes13" -> DynamicArray( + Bytes13::class.java, + param.map { Bytes13(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes14" -> DynamicArray( + Bytes14::class.java, + param.map { Bytes14(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes15" -> DynamicArray( + Bytes15::class.java, + param.map { Bytes15(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes16" -> DynamicArray( + Bytes16::class.java, + param.map { Bytes16(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes17" -> DynamicArray( + Bytes17::class.java, + param.map { Bytes17(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes18" -> DynamicArray( + Bytes18::class.java, + param.map { Bytes18(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes19" -> DynamicArray( + Bytes19::class.java, + param.map { Bytes19(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes20" -> DynamicArray( + Bytes20::class.java, + param.map { Bytes20(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes21" -> DynamicArray( + Bytes21::class.java, + param.map { Bytes21(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes22" -> DynamicArray( + Bytes22::class.java, + param.map { Bytes22(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes23" -> DynamicArray( + Bytes23::class.java, + param.map { Bytes23(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes24" -> DynamicArray( + Bytes24::class.java, + param.map { Bytes24(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes25" -> DynamicArray( + Bytes25::class.java, + param.map { Bytes25(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes26" -> DynamicArray( + Bytes26::class.java, + param.map { Bytes26(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes27" -> DynamicArray( + Bytes27::class.java, + param.map { Bytes27(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes28" -> DynamicArray( + Bytes28::class.java, + param.map { Bytes28(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes29" -> DynamicArray( + Bytes29::class.java, + param.map { Bytes29(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes30" -> DynamicArray( + Bytes30::class.java, + param.map { Bytes30(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes31" -> DynamicArray( + Bytes31::class.java, + param.map { Bytes31(Numeric.hexStringToByteArray(it.toString())) }) + + "bytes32" -> DynamicArray( + Bytes32::class.java, + param.map { Bytes32(Numeric.hexStringToByteArray(it.toString())) }) + + "tuple" -> DynamicArray( + Type::class.java, + param.map { + getTuple(it) + }) + + else -> throw BaseError("Unsupported type: $type") + } +} + +private fun getTypeInstance(type: String, value: Any): Type<*> { + return when (type) { + "address" -> Address(value.toString()) + "bool" -> Bool(value.toString().toBoolean()) + "string" -> Utf8String(value.toString()) + "bytes" -> DynamicBytes(Numeric.hexStringToByteArray(value.toString())) + "uint8" -> Uint8(BigInteger(value.toString())) + "int8" -> Int8(BigInteger(value.toString())) + "uint16" -> Uint16(BigInteger(value.toString())) + "int16" -> Int16(BigInteger(value.toString())) + "uint24" -> Uint24(BigInteger(value.toString())) + "int24" -> Int24(BigInteger(value.toString())) + "uint32" -> Uint32(BigInteger(value.toString())) + "int32" -> Int32(BigInteger(value.toString())) + "uint40" -> Uint40(BigInteger(value.toString())) + "int40" -> Int40(BigInteger(value.toString())) + "uint48" -> Uint48(BigInteger(value.toString())) + "int48" -> Int48(BigInteger(value.toString())) + "uint56" -> Uint56(BigInteger(value.toString())) + "int56" -> Int56(BigInteger(value.toString())) + "uint64" -> Uint64(BigInteger(value.toString())) + "int64" -> Int64(BigInteger(value.toString())) + "uint72" -> Uint72(BigInteger(value.toString())) + "int72" -> Int72(BigInteger(value.toString())) + "uint80" -> Uint80(BigInteger(value.toString())) + "int80" -> Int80(BigInteger(value.toString())) + "uint88" -> Uint88(BigInteger(value.toString())) + "int88" -> Int88(BigInteger(value.toString())) + "uint96" -> Uint96(BigInteger(value.toString())) + "int96" -> Int96(BigInteger(value.toString())) + "uint104" -> Uint104(BigInteger(value.toString())) + "int104" -> Int104(BigInteger(value.toString())) + "uint112" -> Uint112(BigInteger(value.toString())) + "int112" -> Int112(BigInteger(value.toString())) + "uint120" -> Uint120(BigInteger(value.toString())) + "int120" -> Int120(BigInteger(value.toString())) + "uint128" -> Uint128(BigInteger(value.toString())) + "int128" -> Int128(BigInteger(value.toString())) + "uint136" -> Uint136(BigInteger(value.toString())) + "int136" -> Int136(BigInteger(value.toString())) + "uint144" -> Uint144(BigInteger(value.toString())) + "int144" -> Int144(BigInteger(value.toString())) + "uint152" -> Uint152(BigInteger(value.toString())) + "int152" -> Int152(BigInteger(value.toString())) + "uint160" -> Uint160(BigInteger(value.toString())) + "int160" -> Int160(BigInteger(value.toString())) + "uint168" -> Uint168(BigInteger(value.toString())) + "int168" -> Int168(BigInteger(value.toString())) + "uint176" -> Uint176(BigInteger(value.toString())) + "int176" -> Int176(BigInteger(value.toString())) + "uint184" -> Uint184(BigInteger(value.toString())) + "int184" -> Int184(BigInteger(value.toString())) + "uint192" -> Uint192(BigInteger(value.toString())) + "int192" -> Int192(BigInteger(value.toString())) + "uint200" -> Uint200(BigInteger(value.toString())) + "int200" -> Int200(BigInteger(value.toString())) + "uint208" -> Uint208(BigInteger(value.toString())) + "int208" -> Int208(BigInteger(value.toString())) + "uint216" -> Uint216(BigInteger(value.toString())) + "int216" -> Int216(BigInteger(value.toString())) + "uint224" -> Uint224(BigInteger(value.toString())) + "int224" -> Int224(BigInteger(value.toString())) + "uint232" -> Uint232(BigInteger(value.toString())) + "int232" -> Int232(BigInteger(value.toString())) + "uint240" -> Uint240(BigInteger(value.toString())) + "int240" -> Int240(BigInteger(value.toString())) + "uint248" -> Uint248(BigInteger(value.toString())) + "int248" -> Int248(BigInteger(value.toString())) + "uint256" -> Uint256(BigInteger(value.toString())) + "int256" -> Int256(BigInteger(value.toString())) + "bytes1" -> Bytes1(Numeric.hexStringToByteArray(value.toString())) + "bytes2" -> Bytes2(Numeric.hexStringToByteArray(value.toString())) + "bytes3" -> Bytes3(Numeric.hexStringToByteArray(value.toString())) + "bytes4" -> Bytes4(Numeric.hexStringToByteArray(value.toString())) + "bytes5" -> Bytes5(Numeric.hexStringToByteArray(value.toString())) + "bytes6" -> Bytes6(Numeric.hexStringToByteArray(value.toString())) + "bytes7" -> Bytes7(Numeric.hexStringToByteArray(value.toString())) + "bytes8" -> Bytes8(Numeric.hexStringToByteArray(value.toString())) + "bytes9" -> Bytes9(Numeric.hexStringToByteArray(value.toString())) + "bytes10" -> Bytes10(Numeric.hexStringToByteArray(value.toString())) + "bytes11" -> Bytes11(Numeric.hexStringToByteArray(value.toString())) + "bytes12" -> Bytes12(Numeric.hexStringToByteArray(value.toString())) + "bytes13" -> Bytes13(Numeric.hexStringToByteArray(value.toString())) + "bytes14" -> Bytes14(Numeric.hexStringToByteArray(value.toString())) + "bytes15" -> Bytes15(Numeric.hexStringToByteArray(value.toString())) + "bytes16" -> Bytes16(Numeric.hexStringToByteArray(value.toString())) + "bytes17" -> Bytes17(Numeric.hexStringToByteArray(value.toString())) + "bytes18" -> Bytes18(Numeric.hexStringToByteArray(value.toString())) + "bytes19" -> Bytes19(Numeric.hexStringToByteArray(value.toString())) + "bytes20" -> Bytes20(Numeric.hexStringToByteArray(value.toString())) + "bytes21" -> Bytes21(Numeric.hexStringToByteArray(value.toString())) + "bytes22" -> Bytes22(Numeric.hexStringToByteArray(value.toString())) + "bytes23" -> Bytes23(Numeric.hexStringToByteArray(value.toString())) + "bytes24" -> Bytes24(Numeric.hexStringToByteArray(value.toString())) + "bytes25" -> Bytes25(Numeric.hexStringToByteArray(value.toString())) + "bytes26" -> Bytes26(Numeric.hexStringToByteArray(value.toString())) + "bytes27" -> Bytes27(Numeric.hexStringToByteArray(value.toString())) + "bytes28" -> Bytes28(Numeric.hexStringToByteArray(value.toString())) + "bytes29" -> Bytes29(Numeric.hexStringToByteArray(value.toString())) + "bytes30" -> Bytes30(Numeric.hexStringToByteArray(value.toString())) + "bytes31" -> Bytes31(Numeric.hexStringToByteArray(value.toString())) + "bytes32" -> Bytes32(Numeric.hexStringToByteArray(value.toString())) + else -> throw BaseError("Unsupported type: $type") + } +} + +private fun getAbiDefinition(name: String, contractAbi: String?): AbiDefinition? { + val abiDefinitions = Gson().fromJson(contractAbi, Array::class.java) + ?: return null + var result: AbiDefinition? = null + for (abiDefinition in abiDefinitions) { + if (TYPE_FUNCTION == abiDefinition.type && name == abiDefinition.name) { + result = abiDefinition + break + } + } + return result +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodePackedUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodePackedUtils.kt new file mode 100644 index 0000000..0860fd6 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodePackedUtils.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import org.web3j.abi.TypeEncoder +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Array +import org.web3j.abi.datatypes.Bool +import org.web3j.abi.datatypes.Bytes +import org.web3j.abi.datatypes.BytesType +import org.web3j.abi.datatypes.DynamicArray +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.DynamicStruct +import org.web3j.abi.datatypes.Fixed +import org.web3j.abi.datatypes.FixedPointType +import org.web3j.abi.datatypes.NumericType +import org.web3j.abi.datatypes.StaticArray +import org.web3j.abi.datatypes.StaticStruct +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.Ufixed +import org.web3j.abi.datatypes.Utf8String +import org.web3j.abi.datatypes.primitive.PrimitiveType +import org.web3j.utils.Numeric +import java.nio.charset.StandardCharsets + +@Throws(Exception::class) +fun encodePacked(parameters: List>): String { + val result = StringBuilder() + for (parameter in parameters) { + result.append(encodePacked(parameter)) + } + return "0x$result" +} + +private fun encodePacked(parameter: Type<*>): String { + return when (parameter) { + is Utf8String -> { + Numeric.toHexStringNoPrefix( + parameter.value.toByteArray(StandardCharsets.UTF_8) + ) + } + + is DynamicBytes -> { + Numeric.toHexStringNoPrefix(parameter.value) + } + + is DynamicArray<*> -> { + arrayEncodePacked(parameter) + } + + is StaticArray<*> -> { + arrayEncodePacked(parameter) + } + + is PrimitiveType<*> -> { + encodePacked(parameter.toSolidityType()) + } + + else -> { + removePadding(TypeEncoder.encode(parameter), parameter) + } + } +} + +private fun removePadding(encodedValue: String, parameter: Type<*>): String { + when (parameter) { + is NumericType -> { + if (parameter is Ufixed || parameter is Fixed) { + return encodedValue + } + return encodedValue.substring(64 - parameter.bitSize / 4, 64) + } + + is Address -> { + return encodedValue.substring(64 - parameter.toUint().bitSize / 4, 64) + } + + is Bool -> { + return encodedValue.substring(62, 64) + } + + is Bytes -> { + return encodedValue.substring(0, (parameter as BytesType).value.size * 2) + } + + is Utf8String -> { + val length = + parameter.value.toByteArray(StandardCharsets.UTF_8).size + return encodedValue.substring(64, 64 + length * 2) + } + + is DynamicBytes -> { + return encodedValue.substring( + 64, 64 + parameter.value.size * 2 + ) + } + + else -> { + throw UnsupportedOperationException( + "Type cannot be encoded: " + parameter.javaClass + ) + } + } +} + +private fun > isSupportingEncodedPacked(value: Array): Boolean { + return !(Utf8String::class.java.isAssignableFrom(value.componentType) + || DynamicStruct::class.java.isAssignableFrom(value.componentType) + || DynamicArray::class.java.isAssignableFrom(value.componentType) + || StaticStruct::class.java.isAssignableFrom(value.componentType) + || FixedPointType::class.java.isAssignableFrom(value.componentType) + || DynamicBytes::class.java.isAssignableFrom(value.componentType)) +} + +private fun > arrayEncodePacked(values: Array): String { + if (isSupportingEncodedPacked(values)) { + if (values.value.isEmpty()) { + return "" + } + if (values is DynamicArray<*>) { + return TypeEncoder.encode(values).substring(64) + } else if (values is StaticArray<*>) { + return TypeEncoder.encode(values) + } + } + throw UnsupportedOperationException( + "Type cannot be packed encoded: " + values.javaClass + ) +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeTransferUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeTransferUtils.kt new file mode 100644 index 0000000..826256a --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/abi/EncodeTransferUtils.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.abi + +import com.circle.modularwallets.core.constants.ABI_ERC20 +import com.circle.modularwallets.core.constants.ABI_FUNCTION_TRANSFER +import com.circle.modularwallets.core.constants.CONTRACT_ADDRESS +import com.circle.modularwallets.core.models.EncodeCallDataArg +import com.circle.modularwallets.core.models.EncodeTransferResult +import com.circle.modularwallets.core.models.Token +import java.math.BigInteger + +/** + * Encodes an ERC20 token transfer into calldata for executing a User Operation. + * + * @param to The recipient address. + * @param token The token contract address or the name of [Token] enum . + * @param amount The amount to transfer. + * @return The encoded transfer abi and contract address. + */ +@Throws(Exception::class) +fun encodeTransfer( + to: String, + token: String, + amount: BigInteger +): EncodeTransferResult { + val abiParameters: Array = arrayOf(to, amount) + val encodedAbi = encodeFunctionData(ABI_FUNCTION_TRANSFER, ABI_ERC20, abiParameters) + return EncodeTransferResult(data = encodedAbi, to = CONTRACT_ADDRESS[token] ?: token) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/data/ConcatUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/data/ConcatUtils.kt new file mode 100644 index 0000000..6ea0a51 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/data/ConcatUtils.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.data + +/** + * Concatenates multiple strings, removing any "0x" prefixes, and returns the result with a "0x" prefix. + * + * @param values The strings to concatenate. + * @return The concatenated string with a "0x" prefix. + */ +fun concat(vararg values: String): String { + val concatenated = values.joinToString("") { it.replace("0x", "") } + return "0x$concatenated" +} + +/** + * Concatenates multiple byte arrays into a single byte array. + * + * @param values The byte arrays to concatenate. + * @return The concatenated byte array. + */ +fun concat(vararg values: ByteArray): ByteArray { + val totalLength = values.sumOf { it.size } + + val result = ByteArray(totalLength) + + var offset = 0 + for (arr in values) { + System.arraycopy(arr, 0, result, offset, arr.size) + offset += arr.size + } + return result +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/data/IsHexUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/data/IsHexUtils.kt new file mode 100644 index 0000000..d253c3f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/data/IsHexUtils.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.data + +/** + * Checks if the given value is a hexadecimal string. + * + * @param value The value to check. + * @param strict If true, the value must strictly match the hexadecimal pattern (starting with "0x" and followed by hexadecimal characters). If false, the value only needs to start with "0x". + * @return True if the value is a hexadecimal string, false otherwise. + */ + +@JvmOverloads +fun isHex(value: Any?, strict: Boolean = true): Boolean { + if (value == null) return false + if (value !is String) return false + return if (strict) "^0x[0-9a-fA-F]*$".toRegex().matches(value) else value.startsWith("0x") +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/data/PadUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/data/PadUtils.kt new file mode 100644 index 0000000..8860ddd --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/data/PadUtils.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.data + +import com.circle.modularwallets.core.errors.BaseError + +/** + * Pads the given byte array to the specified size. + * + * @param bytes The byte array to pad. + * @param size The size to pad the byte array to. Default is 32. + * @param isRight If true, padding is added to the right. If false, padding is added to the left. Default is false. + * @return The padded byte array. + * @throws BaseError if the size of the byte array exceeds the specified size. + */ +@JvmOverloads +@Throws(Exception::class) +fun pad(bytes: ByteArray, size: Int = 32, isRight: Boolean = false): ByteArray { + if (bytes.size > size) { + throw BaseError("SizeExceedsPaddingSizeError") + } + val paddedBytes = ByteArray(size) + + for (i in 0 until size) { + val padEnd = isRight + paddedBytes[if (padEnd) i else size - i - 1] = + if (padEnd) + bytes.getOrElse(i) { 0 } + else + bytes.getOrElse(bytes.size - i - 1) { 0 } + } + return paddedBytes +} + +/** + * Pads the given hexadecimal string to the specified size. + * + * @param hex The hexadecimal string to pad. + * @param size The size to pad the hexadecimal string to. Default is 32. + * @param isRight If true, padding is added to the right. If false, padding is added to the left. Default is false. + * @return The padded hexadecimal string with a "0x" prefix. + * @throws BaseError if the length of the hexadecimal string exceeds the specified size. + */ +@JvmOverloads +@Throws(Exception::class) +fun pad(hex: String?, size: Int = 32, isRight: Boolean = false): String { + hex ?: return "" + val hexCleaned = hex.removePrefix("0x").removePrefix("0X") + val length = size * 2 + if (hexCleaned.length > length) { + throw BaseError("SizeExceedsPaddingSizeError") + } + val result = "0x" + if (isRight) { + hexCleaned.padEnd(length, '0') + } else { + hexCleaned.padStart(length, '0') + } + return result +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/data/SliceUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/data/SliceUtils.kt new file mode 100644 index 0000000..0a0f467 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/data/SliceUtils.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.data + +/** + * Slices the given hexadecimal string from the specified start index to the end index. + * + * @param value The hexadecimal string to slice. + * @param start The start index for slicing (in bytes). Default is 0. + * @param end The end index for slicing (in bytes). Default is the length of the string divided by 2. + * @param strict If true, the sliced value must strictly match the hexadecimal pattern (starting with "0x" and followed by hexadecimal characters). Default is false. + * @return The sliced hexadecimal string with a "0x" prefix. + * @throws IllegalArgumentException if the sliced value does not match the hexadecimal pattern when strict is true. + */ +@JvmOverloads +fun slice( + value: String, + start: Int? = null, + end: Int? = null, + strict: Boolean = false +): String { + val cleanValue = value.removePrefix("0x") + val startIndex = (start ?: 0) * 2 + val endIndex = (end ?: (cleanValue.length / 2)) * 2 + val slicedValue = "0x${cleanValue.slice(startIndex until endIndex)}" + if (strict) { + require(slicedValue.matches(Regex("0x[0-9a-fA-F]*"))) { + "Invalid hexadecimal string" + } + } + + return slicedValue +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/HexConversionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/HexConversionUtils.kt new file mode 100644 index 0000000..00fe6c1 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/HexConversionUtils.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.encoding + +import com.circle.modularwallets.core.errors.IntegerOutOfRangeError +import java.math.BigInteger + +/** + * Converts a BigInteger to a hexadecimal string with a "0x" prefix. + * + * @param num The BigInteger to convert. + * @return The hexadecimal string representation of the BigInteger with a "0x" prefix, or null if the input is null. + */ +fun bigIntegerToHex(num: BigInteger?): String? { + num ?: return null + // Check num MUST NOT be negative + if (num.signum() < 0) { + throw IntegerOutOfRangeError() + } + return "0x${num.toString(16)}" +} + +/** + * Converts a hexadecimal string to a BigInteger. + * + * @param hex The hexadecimal string to convert. + * @return The BigInteger representation of the hexadecimal string, or null if the input is null. + */ +fun hexToBigInteger(hex: String?): BigInteger? { + hex ?: return null + return BigInteger(hex.removePrefix("0x").removePrefix("0X"), 16) +} + +/** + * Converts a string to its hexadecimal string representation with a "0x" prefix. + * + * @param string The string to convert. + * @return The hexadecimal string representation of the input string with a "0x" prefix. + */ +fun stringToHex(string: String): String { + return bytesToHex(string.toByteArray()) +} + +/** + * Converts a byte array to its hexadecimal string representation with a "0x" prefix. + * + * @param bytes The byte array to convert. + * @return The hexadecimal string representation of the byte array with a "0x" prefix. + */ +fun bytesToHex(bytes: ByteArray): String { + return "0x${bytes.joinToString("") { String.format("%02x", it) }}" +} + +/** + * Converts a hexadecimal string to a Long. + * + * @param hex The hexadecimal string to convert. + * @return The Long representation of the hexadecimal string. + */ +fun hexToLong(hex: String): Long { + return hex.removePrefix("0x").removePrefix("0X").toLong(16) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/ToBytesUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/ToBytesUtils.kt new file mode 100644 index 0000000..99050e6 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/encoding/ToBytesUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.encoding + +import com.circle.modularwallets.core.utils.data.isHex +import org.web3j.crypto.Hash +import org.web3j.utils.Numeric +import java.nio.charset.StandardCharsets + +/** + * Converts a string to a byte array. + * + * @param value The string to convert. If the string is a hexadecimal string, it will be converted to a byte array accordingly. + * @return The byte array representation of the input string. + */ +fun toBytes(value: String): ByteArray { + if(isHex(value)) { + return Numeric.hexStringToByteArray(value) + } + return value.toByteArray() +} + +/** + * Computes the SHA-3 (Keccak-256) hash of the given message and returns it as a byte array. + * + * @param message The message to hash. If the message is a hexadecimal string, it will be converted to a byte array before hashing. + * @return The SHA-3 hash of the input message as a byte array. + */ +fun toSha3Bytes(message: String): ByteArray { + val digest = if(isHex(message)) { + Hash.sha3(Numeric.hexStringToByteArray(message)) + } else { + Hash.sha3(message.toByteArray(StandardCharsets.UTF_8)) + } + return digest +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetBundlerErrorUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetBundlerErrorUtils.kt new file mode 100644 index 0000000..1e37a8d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetBundlerErrorUtils.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.error + +import com.circle.modularwallets.core.errors.AccountNotDeployedError +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.ExecutionRevertedError +import com.circle.modularwallets.core.errors.FailedToSendToBeneficiaryError +import com.circle.modularwallets.core.errors.GasValuesOverflowError +import com.circle.modularwallets.core.errors.HandleOpsOutOfGasError +import com.circle.modularwallets.core.errors.InitCodeFailedError +import com.circle.modularwallets.core.errors.InitCodeMustCreateSenderError +import com.circle.modularwallets.core.errors.InitCodeMustReturnSenderError +import com.circle.modularwallets.core.errors.InsufficientPrefundError +import com.circle.modularwallets.core.errors.InternalCallOnlyError +import com.circle.modularwallets.core.errors.InvalidAccountNonceError +import com.circle.modularwallets.core.errors.InvalidAggregatorError +import com.circle.modularwallets.core.errors.InvalidBeneficiaryError +import com.circle.modularwallets.core.errors.InvalidFieldsError +import com.circle.modularwallets.core.errors.InvalidPaymasterAndDataError +import com.circle.modularwallets.core.errors.PaymasterDepositTooLowError +import com.circle.modularwallets.core.errors.PaymasterFunctionRevertedError +import com.circle.modularwallets.core.errors.PaymasterNotDeployedError +import com.circle.modularwallets.core.errors.PaymasterPostOpFunctionRevertedError +import com.circle.modularwallets.core.errors.PaymasterRateLimitError +import com.circle.modularwallets.core.errors.PaymasterStakeTooLowError +import com.circle.modularwallets.core.errors.RpcRequestError +import com.circle.modularwallets.core.errors.SenderAlreadyConstructedError +import com.circle.modularwallets.core.errors.SignatureCheckFailedError +import com.circle.modularwallets.core.errors.SmartAccountFunctionRevertedError +import com.circle.modularwallets.core.errors.UnknownBundlerError +import com.circle.modularwallets.core.errors.UnsupportedSignatureAggregatorError +import com.circle.modularwallets.core.errors.UserOperationExpiredError +import com.circle.modularwallets.core.errors.UserOperationOutOfTimeRangeError +import com.circle.modularwallets.core.errors.UserOperationPaymasterExpiredError +import com.circle.modularwallets.core.errors.UserOperationPaymasterSignatureError +import com.circle.modularwallets.core.errors.UserOperationRejectedByEntryPointError +import com.circle.modularwallets.core.errors.UserOperationRejectedByOpCodeError +import com.circle.modularwallets.core.errors.UserOperationRejectedByPaymasterError +import com.circle.modularwallets.core.errors.UserOperationSignatureError +import com.circle.modularwallets.core.errors.VerificationGasLimitExceededError +import com.circle.modularwallets.core.errors.VerificationGasLimitTooLowError +import com.circle.modularwallets.core.models.UserOperationRpc +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger + +private val bundlerErrors = listOf( + ExecutionRevertedError(), + InvalidFieldsError(), + PaymasterDepositTooLowError(), + PaymasterRateLimitError(), + PaymasterStakeTooLowError(), + SignatureCheckFailedError(), + UnsupportedSignatureAggregatorError(), + UserOperationOutOfTimeRangeError(), + UserOperationRejectedByEntryPointError(), + UserOperationRejectedByPaymasterError(), + UserOperationRejectedByOpCodeError(), +) + +internal fun getBundlerError( + err: BaseError, + args: UserOperationRpc? +): BaseError { + val message = (err.details ?: "").lowercase() + val firstResult = when { + AccountNotDeployedError.message.toRegex().containsMatchIn(message) -> { + AccountNotDeployedError(cause = err) + } + + FailedToSendToBeneficiaryError.message.toRegex().containsMatchIn(message) -> { + FailedToSendToBeneficiaryError(cause = err) + } + + GasValuesOverflowError.message.toRegex().containsMatchIn(message) -> { + GasValuesOverflowError(cause = err) + } + + HandleOpsOutOfGasError.message.toRegex().containsMatchIn(message) -> { + HandleOpsOutOfGasError(cause = err) + } + + InitCodeFailedError.message.toRegex().containsMatchIn(message) -> { + InitCodeFailedError( + cause = err, + factory = args?.factory, + factoryData = args?.factoryData, + initCode = args?.initCode + ) + } + + InitCodeMustCreateSenderError.message.toRegex().containsMatchIn(message) -> { + InitCodeMustCreateSenderError( + cause = err, + factory = args?.factory, + factoryData = args?.factoryData, + initCode = args?.initCode + ) + } + + InitCodeMustReturnSenderError.message.toRegex().containsMatchIn(message) -> { + InitCodeMustReturnSenderError( + err, + factory = args?.factory, + factoryData = args?.factoryData, + initCode = args?.initCode, + sender = args?.sender + ) + } + + + InsufficientPrefundError.message.toRegex().containsMatchIn(message) -> { + InsufficientPrefundError(cause = err) + } + + InternalCallOnlyError.message.toRegex().containsMatchIn(message) -> { + InternalCallOnlyError(cause = err) + } + + InvalidAccountNonceError.message.toRegex().containsMatchIn(message) -> { + InvalidAccountNonceError( + err, + nonce = hexToBigInteger(args?.nonce) + ) + } + + InvalidAggregatorError.message.toRegex().containsMatchIn(message) -> { + InvalidAggregatorError(cause = err) + } + + InvalidBeneficiaryError.message.toRegex().containsMatchIn(message) -> { + InvalidBeneficiaryError(cause = err) + } + + InvalidPaymasterAndDataError.message.toRegex().containsMatchIn(message) -> { + InvalidPaymasterAndDataError(cause = err) + } + + PaymasterDepositTooLowError.message.toRegex().containsMatchIn(message) -> { + PaymasterDepositTooLowError(cause = err) + } + + PaymasterFunctionRevertedError.message.toRegex().containsMatchIn(message) -> { + PaymasterFunctionRevertedError(cause = err) + } + + PaymasterNotDeployedError.message.toRegex().containsMatchIn(message) -> { + PaymasterNotDeployedError(cause = err) + } + + PaymasterPostOpFunctionRevertedError.message.toRegex().containsMatchIn(message) -> { + PaymasterPostOpFunctionRevertedError(cause = err) + } + + SmartAccountFunctionRevertedError.message.toRegex().containsMatchIn(message) -> { + SmartAccountFunctionRevertedError(cause = err) + } + + SenderAlreadyConstructedError.message.toRegex().containsMatchIn(message) -> { + SenderAlreadyConstructedError( + cause = err, + factory = args?.factory, + factoryData = args?.factoryData, + initCode = args?.initCode + ) + } + + UserOperationExpiredError.message.toRegex().containsMatchIn(message) -> { + UserOperationExpiredError(cause = err) + } + + UserOperationPaymasterExpiredError.message.toRegex().containsMatchIn(message) -> { + UserOperationPaymasterExpiredError(cause = err) + } + + UserOperationPaymasterSignatureError.message.toRegex().containsMatchIn(message) -> { + UserOperationPaymasterSignatureError(cause = err) + } + + UserOperationSignatureError.message.toRegex().containsMatchIn(message) -> { + UserOperationSignatureError(cause = err) + } + + VerificationGasLimitExceededError.message.toRegex().containsMatchIn(message) -> { + VerificationGasLimitExceededError(cause = err) + } + + VerificationGasLimitTooLowError.message.toRegex().containsMatchIn(message) -> { + VerificationGasLimitTooLowError(cause = err) + } + + else -> null + } + + if (firstResult != null) { + return firstResult + } + + if (err is RpcRequestError) { + val rpcErrorCode = err.code + val secondResult = bundlerErrors.firstOrNull { error -> + when (error) { + is ExecutionRevertedError -> ExecutionRevertedError.code == rpcErrorCode + is InvalidFieldsError -> InvalidFieldsError.code == rpcErrorCode + is PaymasterDepositTooLowError -> PaymasterDepositTooLowError.code == rpcErrorCode + is PaymasterRateLimitError -> PaymasterRateLimitError.code == rpcErrorCode + is PaymasterStakeTooLowError -> PaymasterStakeTooLowError.code == rpcErrorCode + is SignatureCheckFailedError -> SignatureCheckFailedError.code == rpcErrorCode + is UnsupportedSignatureAggregatorError -> UnsupportedSignatureAggregatorError.code == rpcErrorCode + is UserOperationOutOfTimeRangeError -> UserOperationOutOfTimeRangeError.code == rpcErrorCode + is UserOperationRejectedByEntryPointError -> UserOperationRejectedByEntryPointError.code == rpcErrorCode + is UserOperationRejectedByPaymasterError -> UserOperationRejectedByPaymasterError.code == rpcErrorCode + is UserOperationRejectedByOpCodeError -> UserOperationRejectedByOpCodeError.code == rpcErrorCode + else -> false + } + } + if (secondResult != null) { + when (secondResult) { + is ExecutionRevertedError -> { + return ExecutionRevertedError( + cause = err, + message = err.details + ) + } + + is InvalidFieldsError -> { + return InvalidFieldsError(cause = err) + } + + is PaymasterDepositTooLowError -> { + return PaymasterDepositTooLowError(cause = err) + } + + is PaymasterRateLimitError -> { + return PaymasterRateLimitError(cause = err) + } + + is PaymasterStakeTooLowError -> { + return PaymasterStakeTooLowError(cause = err) + } + + is SignatureCheckFailedError -> { + return SignatureCheckFailedError(cause = err) + } + + is UnsupportedSignatureAggregatorError -> { + return UnsupportedSignatureAggregatorError(cause = err) + } + + is UserOperationOutOfTimeRangeError -> { + return UserOperationOutOfTimeRangeError(cause = err) + } + + is UserOperationRejectedByEntryPointError -> { + return UserOperationRejectedByEntryPointError(cause = err) + } + + is UserOperationRejectedByPaymasterError -> { + return UserOperationRejectedByPaymasterError(cause = err) + } + + is UserOperationRejectedByOpCodeError -> { + return UserOperationRejectedByOpCodeError(cause = err) + } + } + } + } + + return UnknownBundlerError(err) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetUserOperationErrorUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetUserOperationErrorUtils.kt new file mode 100644 index 0000000..8f6910f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/error/GetUserOperationErrorUtils.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.error + +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.UserOperationExecutionError +import com.circle.modularwallets.core.models.UserOperationRpc +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.models.toUserOperationV07 + +internal fun getUserOperationError( + err: BaseError, + args: UserOperationRpc?, +): BaseError { + val cause = getBundlerError(err, args) + return UserOperationExecutionError( + cause = cause, + userOp = args?.toUserOperationV07() ?: UserOperationV07() //use empty as default + ) +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/ReqRespUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/ReqRespUtils.kt new file mode 100644 index 0000000..7f5de00 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/ReqRespUtils.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.rpc + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.BaseErrorParameters +import com.circle.modularwallets.core.errors.ChainDisconnectedError +import com.circle.modularwallets.core.errors.HttpRequestError +import com.circle.modularwallets.core.errors.InternalRpcError +import com.circle.modularwallets.core.errors.InvalidInputRpcError +import com.circle.modularwallets.core.errors.InvalidParamsRpcError +import com.circle.modularwallets.core.errors.InvalidRequestRpcError +import com.circle.modularwallets.core.errors.JsonRpcVersionUnsupportedError +import com.circle.modularwallets.core.errors.LimitExceededRpcError +import com.circle.modularwallets.core.errors.MethodNotFoundRpcError +import com.circle.modularwallets.core.errors.MethodNotSupportedRpcError +import com.circle.modularwallets.core.errors.ParseRpcError +import com.circle.modularwallets.core.errors.ProviderDisconnectedError +import com.circle.modularwallets.core.errors.ResourceNotFoundRpcError +import com.circle.modularwallets.core.errors.ResourceUnavailableRpcError +import com.circle.modularwallets.core.errors.RpcError +import com.circle.modularwallets.core.errors.RpcRequestError +import com.circle.modularwallets.core.errors.SwitchChainError +import com.circle.modularwallets.core.errors.TransactionRejectedRpcError +import com.circle.modularwallets.core.errors.UnauthorizedProviderError +import com.circle.modularwallets.core.errors.UnknownRpcError +import com.circle.modularwallets.core.errors.UnsupportedProviderMethodError +import com.circle.modularwallets.core.errors.UserRejectedRequestError +import com.circle.modularwallets.core.transports.HttpError +import com.circle.modularwallets.core.transports.RpcRequest +import com.circle.modularwallets.core.transports.RpcResponse +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.Logger +import com.circle.modularwallets.core.utils.fromJson +import com.circle.modularwallets.core.utils.toJson +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okio.Buffer +import java.math.BigDecimal +import java.math.BigInteger + + +internal fun resultToTypeAndJson(result: Any, type: Class): Pair { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + val jsonAdapter = moshi.adapter(Map::class.java) + val jsonString = jsonAdapter.toJson(result as Map<*, *>?) + val adapter: JsonAdapter = moshi.adapter(type) + val obj = adapter.fromJson(jsonString) + obj ?: throw BaseError("Failed to transform from json: $jsonString") + return Pair(obj, jsonString) +} + +internal suspend fun performJsonRpcRequest( + transport: Transport, + rpcReq: RpcRequest, + type: Class, + notFoundError: BaseError? = null +): Pair { + val result = performJsonRpcRequest(transport, rpcReq, notFoundError) + return resultToTypeAndJson(result, type) +} + +internal fun getRpcError(error: RpcRequestError): RpcError { + return when (error.code) { + ParseRpcError.code -> ParseRpcError(error) + InvalidRequestRpcError.code -> InvalidRequestRpcError(error) + MethodNotFoundRpcError.code -> MethodNotFoundRpcError(error) + InvalidParamsRpcError.code -> InvalidParamsRpcError(error) + InternalRpcError.code -> InternalRpcError(error) + InvalidInputRpcError.code -> InvalidInputRpcError(error) + ResourceNotFoundRpcError.code -> ResourceNotFoundRpcError(error) + ResourceUnavailableRpcError.code -> ResourceUnavailableRpcError(error) + TransactionRejectedRpcError.code -> TransactionRejectedRpcError(error) + MethodNotSupportedRpcError.code -> MethodNotSupportedRpcError(error) + LimitExceededRpcError.code -> LimitExceededRpcError(error) + JsonRpcVersionUnsupportedError.code -> JsonRpcVersionUnsupportedError(error) + // CAIP-25: User Rejected Error + // https://docs.walletconnect.com/2.0/specs/clients/sign/error-codes#rejected-caip-25 + 5000, UserRejectedRequestError.code -> UserRejectedRequestError(error) + UnauthorizedProviderError.code -> UnauthorizedProviderError(error) + UnsupportedProviderMethodError.code -> UnsupportedProviderMethodError(error) + ProviderDisconnectedError.code -> ProviderDisconnectedError(error) + ChainDisconnectedError.code -> ChainDisconnectedError(error) + SwitchChainError.code -> SwitchChainError(error) + else -> UnknownRpcError(error) + } +} + +internal suspend fun performJsonRpcRequest( + transport: Transport, + rpcReq: RpcRequest, + notFoundError: BaseError? = null +): Any { + val call = transport.request(rpcReq) + val body = call.body() + body?.error?.let { + val req = call.raw().request + val rpcRequestError= RpcRequestError( + getBodyString(req.body), it, + url = req.url.toString() + ) + throw getRpcError(rpcRequestError) + } + return body?.result ?: throw notFoundError ?: BaseError( + "RPC result is null", + BaseErrorParameters( + metaMessages = mutableListOf( + "URL: ${call.raw().request.url}", + "Request body: ${toJson(getBodyString(call.raw().request.body))}" + ) + ) + ) +} + +internal fun getMoshi(): Moshi { + return Moshi.Builder() + .add(BigDecimalAdapter) + .add(BigIntegerAdapter) + .add(KotlinJsonAdapterFactory()) + .build() +} + +@ExcludeFromGeneratedCCReport +internal object BigDecimalAdapter { + @FromJson + fun fromJson(string: String) = BigDecimal(string) + + @ToJson + fun toJson(value: BigDecimal) = value.toString() +} + +@ExcludeFromGeneratedCCReport +internal object BigIntegerAdapter { + @FromJson + fun fromJson(string: String) = BigInteger(string) + + @ToJson + fun toJson(value: BigInteger) = value.toString() +} + +internal fun getBodyString(body: RequestBody?): String { + val buffer = Buffer() + body?.writeTo(buffer) + val plainText = buffer.readByteArray() + return String(plainText) +} + +internal object ParseInterceptor : Interceptor { + private const val MAX_RETRIES = 3 + private const val HTTP_TOO_MANY_REQUESTS = 429 + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + var response: Response = chain.proceed(request) + if (response.isSuccessful) { + return response + } + + var tryCount = 0 + while (tryCount < MAX_RETRIES && (response.code == HTTP_TOO_MANY_REQUESTS || response.code in 500..599)) { + tryCount++ + Thread.sleep(1000L * tryCount) + Logger.d("http", "Retrying request#$tryCount due to ${response.code} error") + response = chain.proceed(chain.request()) + if (response.isSuccessful) { + return response + } + } + + val responseBodyString = response.body?.string() ?: "" + val errorMessage: String? + try { + if (responseBodyString.contains("jsonrpc")) { + val data = fromJson(responseBodyString, RpcResponse::class.java) + errorMessage = data?.error?.message + } else { + val data: HttpError? = fromJson(responseBodyString, HttpError::class.java) + errorMessage = data?.error ?: data?.message + } + } catch (t: Throwable) { + throw HttpRequestError( + body = getBodyString(request.body), + cause = t, + details = "Receive error during parsing: $responseBodyString", + status = response.code, + headers = request.headers, + url = request.url.toString() + ) + } + throw HttpRequestError( + body = getBodyString(request.body), // response.body?.string() can only be called once only, + details = errorMessage ?: "No error and no message: $responseBodyString", + status = response.code, + headers = request.headers, + url = request.url.toString() + ) + } +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/RetrofitProviderUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/RetrofitProviderUtils.kt new file mode 100644 index 0000000..b9feb2f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/rpc/RetrofitProviderUtils.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.rpc + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import com.circle.modularwallets.core.BuildConfig +import java.security.MessageDigest + +internal fun getAppInfo(context: Context): String { + return "platform=android;version=${BuildConfig.version};package=${context.packageName};signature=${ + getSha256CertificateFingerprint( + context + ) + }" +} + +internal fun getSha256CertificateFingerprint(context: Context): String? { + try { + val packageInfo: PackageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + val signature = packageInfo.signingInfo.apkContentsSigners[0] + + val messageDigest = MessageDigest.getInstance("SHA-256") + val digest = messageDigest.digest(signature.toByteArray()) + val fingerprint = digest.joinToString(":") { String.format("%02X", it) } + return fingerprint + } catch (e: Exception) { + e.printStackTrace() + return null + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/Base64UrlConversionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/Base64UrlConversionUtils.kt new file mode 100644 index 0000000..54ea091 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/Base64UrlConversionUtils.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import android.util.Base64 +import java.nio.charset.StandardCharsets + +internal fun base64UrlToBase64(base64Url: String): String { + return base64Url.replace('-', '+').replace('_', '/') +} + +internal fun base64ToBase64Url(base64: String): String { + return base64.replace('+', '-') + .replace('/', '_') + .replace(Regex("=+$"), "") +} + +internal fun base64UrlToBytes(base64Url: String): ByteArray { + val base64 = base64UrlToBase64(base64Url) + return Base64.decode(base64, Base64.DEFAULT) +} + +internal fun bytesToBase64Url(bytes: ByteArray): String { + val base64 = Base64.encodeToString(bytes, Base64.DEFAULT) + return base64ToBase64Url(base64) +} + +internal fun base64DecodeToString(strEncoded: String): String { + val decodedBytes = Base64.decode(strEncoded, Base64.URL_SAFE) + return String(decodedBytes, StandardCharsets.UTF_8) +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashMessageUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashMessageUtils.kt new file mode 100644 index 0000000..21e0d60 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashMessageUtils.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import com.circle.modularwallets.core.utils.encoding.toBytes +import org.web3j.crypto.Sign +import org.web3j.utils.Numeric + +/** + * Hashes the given hex string using Ethereum's message hash algorithm. + * + * @param hex The hex string to hash. + * @return The hashed message as a hex string. + */ +fun hashMessage(hex: String): String { + val bytes = toBytes(hex) + return hashMessage(bytes) +} + +/** + * Hashes the given byte array using Ethereum's message hash algorithm. + * + * @param byteArray The byte array to hash. + * @return The hashed message as a hex string. + */ +fun hashMessage(byteArray: ByteArray): String { + return bytesToHex(Sign.getEthereumMessageHash(byteArray)) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashTypedDataUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashTypedDataUtils.kt new file mode 100644 index 0000000..87d2a9d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/HashTypedDataUtils.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import com.circle.modularwallets.core.models.EIP712Domain +import com.circle.modularwallets.core.models.EIP712Message +import com.circle.modularwallets.core.models.Entry +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import com.circle.modularwallets.core.utils.signature.StructuredDataEncoder.Companion.parseJSONMessage + +/** + * Hashes the given typed data using the EIP-712 standard. + * + * @param jsonData The JSON string representing the typed data. + * @param overrideData Optional EIP-712 message to override the parsed JSON data. + * @return The hashed typed data as a hex string. + */ + +@JvmOverloads +@Throws(Exception::class) +fun hashTypedData(jsonData: String, overrideData: EIP712Message? = null): String { + val typedData = parseJSONMessage(jsonData) + overrideData?.types?.let { + typedData.types = it + } + overrideData?.primaryType?.let { + typedData.primaryType = it + } + overrideData?.message?.let { + typedData.message = it + } + overrideData?.domain?.let { + typedData.domain = it + } + + typedData.types?.put("EIP712Domain", getTypesForEIP712Domain(typedData.domain)) + + val dataEncoder = StructuredDataEncoder(typedData) + val hashStructuredData = dataEncoder.hashStructuredData() + return bytesToHex(hashStructuredData) +} + +internal fun getTypesForEIP712Domain(domain: EIP712Domain?): MutableList { + val types = mutableListOf() + domain?.name?.let { types.add(Entry("name", "string")) } + domain?.version?.let { types.add(Entry("version", "string")) } + domain?.chainId?.let { types.add(Entry("chainId", "uint256")) } + domain?.verifyingContract?.let { types.add(Entry("verifyingContract", "address")) } + domain?.salt?.let { types.add(Entry("salt", "bytes32")) } + return types +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/PublicKeyConversionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/PublicKeyConversionUtils.kt new file mode 100644 index 0000000..7ebbc4b --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/PublicKeyConversionUtils.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import com.circle.modularwallets.core.utils.encoding.hexToBigInteger +import org.web3j.utils.Numeric.hexStringToByteArray +import java.math.BigInteger +import java.security.KeyFactory +import java.security.PublicKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECFieldFp +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.spec.EllipticCurve +import java.security.spec.X509EncodedKeySpec + +internal fun parseCredentialPublicKey(cPublicKey: String): ECPublicKey { + val bytes = base64UrlToBytes(cPublicKey) + val publicKey = toECPublicKey(bytes) + return publicKey +} + +internal fun toECPublicKey(bytes: ByteArray): ECPublicKey { + val keySpec = X509EncodedKeySpec(bytes) + val keyFactory = KeyFactory.getInstance("EC") + val publicKey = keyFactory.generatePublic(keySpec) + if (publicKey !is ECPublicKey) { + throw BaseError("PublicKey is not of type ECPublicKey") + } + return publicKey +} + +internal fun parsePublicKey(hexString: String): ECPublicKey { + val (x, y) = parseP256Signature(hexString) + /** + * References for p, a, b, gx, gy, n: + * https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf, p.101 + * https://www.secg.org/sec2-v2.pdf, p.14 + * */ + val p = BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951") + val a = BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16) + val b = BigInteger("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b", 16) + val gx = BigInteger("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16) + val gy = BigInteger("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", 16) + val n = BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369") + + val curve = EllipticCurve(ECFieldFp(p), a, b) + val g = ECPoint(gx, gy) + val ecParameterSpec = ECParameterSpec(curve, g, n, 1) + + val ecPoint = ECPoint(x, y) + val keySpec = ECPublicKeySpec(ecPoint, ecParameterSpec) + + val keyFactory = KeyFactory.getInstance("EC") + return keyFactory.generatePublic(keySpec) as ECPublicKey +} + +internal fun parseP256Signature(hex: String): Pair { + val xy = parseP256SignatureToBytes(hex) + return Pair(hexToBigInteger(bytesToHex(xy.first))!!, hexToBigInteger(bytesToHex(xy.second))!!) +} + +internal fun parseP256SignatureToBytes(hex: String): Pair { + // xy for public key, rs for signature + val bytes = hexStringToByteArray(hex) + val offset = if (bytes.size == 65) 1 else 0 + + val x = bytes.copyOfRange(offset, 32 + offset) + val y = bytes.copyOfRange(32 + offset, 64 + offset) + + return Pair(x, y) +} + +internal fun serializePublicKey(publicKey: ECPublicKey): String { + val point = publicKey.w + val x = hexToBigInteger(bytesToHex(point.affineX.toByteArray())) ?: throw BaseError("x is null") + val y = hexToBigInteger(bytesToHex(point.affineY.toByteArray())) ?: throw BaseError("y is null") + return serializePublicKey(x, y) +} + +internal fun serializePublicKey(x: BigInteger, y: BigInteger): String { + val result = mutableListOf() + result.addAll(numberToBytesBE(x, 32).toList()) + result.addAll(numberToBytesBE(y, 32).toList()) + return bytesToHex(result.toByteArray()) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/SignatureConversionUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/SignatureConversionUtils.kt new file mode 100644 index 0000000..2ee1bbc --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/SignatureConversionUtils.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + + +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.BaseErrorParameters +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import org.bouncycastle.asn1.ASN1InputStream +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.DERSequenceGenerator +import org.bouncycastle.asn1.DLSequence +import org.web3j.crypto.ECDSASignature +import org.web3j.utils.Numeric.hexStringToByteArray +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.math.BigInteger + + +internal val P256_N = BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16) +internal val P256_N_DIV_2 = P256_N.shiftRight(1) + +internal fun adjustSignature(r: BigInteger, s: BigInteger): Pair { + return if (s > P256_N_DIV_2) { + Pair(r, P256_N - s) + } else { + Pair(r, s) + } +} + +internal fun parseAsn1Signature(signature: String): ECDSASignature { + val ecdsaSignature = fromDerFormat(base64UrlToBytes(signature)) + return ecdsaSignature +} + +internal fun fromDerFormat(bytes: ByteArray?): ECDSASignature { + try { + ASN1InputStream(bytes).use { decoder -> + val seq: DLSequence = decoder.readObject() as DLSequence + val r: ASN1Integer + val s: ASN1Integer + try { + r = seq.getObjectAt(0) as ASN1Integer + s = seq.getObjectAt(1) as ASN1Integer + } catch (e: ClassCastException) { + throw BaseError("Invalid signature format", BaseErrorParameters(cause = e)) + } + return ECDSASignature(r.positiveValue, s.positiveValue) + } + } catch (e: IOException) { + throw BaseError("Invalid signature format", BaseErrorParameters(cause = e)) + } +} + +internal fun parseSignature(hexSignature: String): String { + val (r, s) = parseP256Signature(hexSignature) + val esdsaSignature = ECDSASignature(r, s) + val bytes = toDerFormat(esdsaSignature) + return bytesToBase64Url(bytes).replace("\n", "") +} + +internal fun toDerFormat(signature: ECDSASignature): ByteArray { + try { + ByteArrayOutputStream().use { baos -> + val seq: DERSequenceGenerator = DERSequenceGenerator(baos) + seq.addObject(ASN1Integer(signature.r)) + seq.addObject(ASN1Integer(signature.s)) + seq.close() + return baos.toByteArray() + } + } catch (ex: IOException) { + return ByteArray(0) + } +} + +internal fun serializeSignature(r: BigInteger, s: BigInteger): String { + val result = ByteArray(64).apply { + val rBytes = numberToBytesBE(r, 32) + val sBytes = numberToBytesBE(s, 32) + System.arraycopy(rBytes, 0, this, 0, rBytes.size) + System.arraycopy(sBytes, 0, this, rBytes.size, sBytes.size) + } + return bytesToHex(result) +} + +internal fun numberToBytesBE(n: BigInteger, len: Int): ByteArray { + val hex = n.toString(16).padStart(len * 2, '0') + return hexStringToByteArray(hex) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/StructuredDataEncoder.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/StructuredDataEncoder.kt new file mode 100644 index 0000000..e177cf5 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/StructuredDataEncoder.kt @@ -0,0 +1,558 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.models.EIP712Message +import com.circle.modularwallets.core.models.Entry +import com.fasterxml.jackson.databind.ObjectMapper +import org.web3j.abi.TypeEncoder +import org.web3j.abi.datatypes.AbiTypes +import org.web3j.crypto.Hash +import org.web3j.crypto.Pair +import org.web3j.utils.Numeric +import java.io.ByteArrayOutputStream +import java.lang.reflect.InvocationTargetException +import java.math.BigInteger +import java.util.Locale +import java.util.TreeSet +import java.util.function.Function +import java.util.regex.Pattern +import java.util.stream.Collectors + +internal class StructuredDataEncoder(val jsonMessageObject: EIP712Message) { + + internal companion object { + // Matches array declarations like arr[5][10], arr[][], arr[][34][], etc. + // Doesn't match array declarations where there is a 0 in any dimension. + // Eg- arr[0][5] is not matched. + const val arrayTypeRegex: String = "^([a-zA-Z_$][a-zA-Z_$0-9]*)((\\[([1-9]\\d*)?\\])+)$" + val arrayTypePattern: Pattern = Pattern.compile(arrayTypeRegex) + + const val bytesTypeRegex: String = "^bytes[0-9][0-9]?$" + val bytesTypePattern: Pattern = Pattern.compile(bytesTypeRegex) + + // This regex tries to extract the dimensions from the + // square brackets of an array declaration using the ``Regex Groups``. + // Eg- It extracts ``5, 6, 7`` from ``[5][6][7]`` + const val arrayDimensionRegex: String = "\\[([1-9]\\d*)?\\]" + val arrayDimensionPattern: Pattern = Pattern.compile(arrayDimensionRegex) + + // Fields of Entry Objects need to follow a regex pattern + // Type Regex matches to a valid name or an array declaration. + const val typeRegex: String = "^[a-zA-Z_$][a-zA-Z_$0-9]*(\\[([1-9]\\d*)*\\])*$" + val typePattern: Pattern = Pattern.compile(typeRegex) + + // Identifier Regex matches to a valid name, but can't be an array declaration. + const val identifierRegex: String = "^[a-zA-Z_$][a-zA-Z_$0-9]*$" + val identifierPattern: Pattern = Pattern.compile(identifierRegex) + + fun parseJSONMessage(jsonMessageInString: String?): EIP712Message { + val mapper = ObjectMapper() + val tempJSONMessageObject = + mapper.readValue(jsonMessageInString, EIP712Message::class.java) + return tempJSONMessageObject + } + + @Throws(java.lang.RuntimeException::class) + fun validateStructuredData(jsonMessageObject: EIP712Message) { + jsonMessageObject.types?.let { + for (structName in it.keys) { + val fields: List = it[structName] ?: continue + for ((name, type) in fields) { + if (name != null && !identifierPattern.matcher(name).find()) { + // raise Error + throw BaseError( + String.format( + "Invalid Identifier %s in %s", name, structName + ) + ) + } + if (type != null && !typePattern.matcher(type).find()) { + // raise Error + throw BaseError( + String.format("Invalid Type %s in %s", type, structName) + ) + } + } + } + } + } + } + + fun getDependencies(primaryType: String): HashSet { + // Find all the dependencies of a type + val deps = java.util.HashSet() + val types: MutableMap> = jsonMessageObject.types ?: return deps + + if (!types.containsKey(primaryType)) { + return deps + } + + val remainingTypes: MutableList = ArrayList() + remainingTypes.add(primaryType) + + while (remainingTypes.size > 0) { + val structName = remainingTypes[remainingTypes.size - 1] ?: continue + remainingTypes.removeAt(remainingTypes.size - 1) + deps.add(structName) + + for ((_, type) in types[primaryType]!!) { + if (!types.containsKey(type)) { + // Don't expand on non-user defined types + } else if (deps.contains(type)) { + // Skip types which are already expanded + } else { + // Encountered a user defined type + remainingTypes.add(type) + } + } + } + + return deps + } + + fun encodeStruct(structName: String): String { + val types: MutableMap>? = jsonMessageObject.types + + var structRepresentation = StringBuilder("$structName(") + for ((name, type) in types!![structName]!!) { + structRepresentation.append(String.format("%s %s,", type, name)) + } + if (!types[structName].isNullOrEmpty()) { + structRepresentation = + StringBuilder( + structRepresentation.substring(0, structRepresentation.length - 1) + ) + } + structRepresentation.append(")") + + return structRepresentation.toString() + } + + fun encodeType(primaryType: String): String { + val deps = getDependencies(primaryType) + deps.remove(primaryType) + + // Sort the other dependencies based on Alphabetical Order and finally add the primaryType + val depsAsList: MutableList = mutableListOf() + depsAsList.addAll(deps.toList()) + deps.sorted() + depsAsList.add(0, primaryType) + + val result = java.lang.StringBuilder() + for (structName in depsAsList) { + result.append(encodeStruct(structName)) + } + + return result.toString() + } + + fun typeHash(primaryType: String): ByteArray { + return Numeric.hexStringToByteArray( + Hash.sha3String( + encodeType( + primaryType + ) + ) + ) + } + + fun getArrayDimensionsFromDeclaration(declaration: String?): List { + // Get the dimensions which were declared in Schema. + // If any dimension is empty, then it's value is set to -1. + val arrayTypeMatcher = arrayTypePattern.matcher(declaration ?: "") + arrayTypeMatcher.find() + val dimensionTypeMatcher = arrayDimensionPattern.matcher(declaration ?: "") + val dimensions: MutableList = java.util.ArrayList() + while (dimensionTypeMatcher.find()) { + val currentDimension = dimensionTypeMatcher.group(1) + if (currentDimension == null) { + dimensions.add("-1".toInt()) + } else { + dimensions.add(currentDimension.toInt()) + } + } + + return dimensions + } + + fun getDepthsAndDimensions(data: Any?, depth: Int): List { + if (data !is List<*>) { + // Nothing more to recurse, since the data is no more an array + return emptyList() + } + + val list: MutableList = mutableListOf() + val dataAsArray = data.filterIsInstance() + + list.add(Pair(depth, dataAsArray.size)) + for (subdimensionalData in dataAsArray) { + list.addAll(getDepthsAndDimensions(subdimensionalData, depth + 1)) + } + + return list + } + + @Throws(RuntimeException::class) + fun getArrayDimensionsFromData(data: Any?): List { + val depthsAndDimensions = getDepthsAndDimensions(data, 0) + // groupedByDepth has key as depth and value as List(pair(Depth, Dimension)) + val groupedByDepth = + depthsAndDimensions.stream().collect( + Collectors.groupingBy( + Function { obj: Pair -> obj.first }) + ) + + // depthDimensionsMap is aimed to have key as depth and value as List(Dimension) + val depthDimensionsMap: MutableMap> = HashMap() + for ((key, value) in groupedByDepth) { + val pureDimensions: MutableList = java.util.ArrayList() + for (depthDimensionPair in value) { + pureDimensions.add(depthDimensionPair.second as Int) + } + depthDimensionsMap[key as Int] = pureDimensions + } + + val dimensions: MutableList = java.util.ArrayList() + for ((key, value) in depthDimensionsMap) { + val setOfDimensionsInParticularDepth: Set = TreeSet( + value + ) + if (setOfDimensionsInParticularDepth.size != 1) { + throw BaseError( + String.format( + "Depth %d of array data has more than one dimensions", + key + ) + ) + } + dimensions.add(setOfDimensionsInParticularDepth.stream().findFirst().get()) + } + + return dimensions + } + + fun flattenMultidimensionalArray(data: Any): List { + if (data !is List<*>) { + return ArrayList().apply { add(data) } + } + + val flattenedArray = mutableListOf() + for (arrayItem in data) { + arrayItem?.let { + flattenedArray.addAll(flattenMultidimensionalArray(arrayItem)) + } + } + + return flattenedArray + } + + private fun convertToEncodedItem(baseType: String, data: Any): ByteArray { + var hashBytes: ByteArray + try { + if (baseType.lowercase(Locale.getDefault()).startsWith("uint") + || baseType.lowercase(Locale.getDefault()).startsWith("int") + ) { + hashBytes = convertToBigInt(data).toByteArray() + } else if (baseType == "string") { + hashBytes = (data as String).toByteArray() + } else if (baseType == "bytes") { + hashBytes = Numeric.hexStringToByteArray(data as String) + } else { + val b = convertArgToBytes(data as String) + val bi = BigInteger(1, b) + hashBytes = Numeric.toBytesPadded(bi, 32) + } + } catch (e: Exception) { + e.printStackTrace() + hashBytes = ByteArray(0) + } + + return hashBytes + } + + @Throws(NumberFormatException::class, NullPointerException::class) + private fun convertToBigInt(value: Any): BigInteger { + return if (value.toString().startsWith("0x")) { + Numeric.toBigInt(value.toString()) + } else { + BigInteger(value.toString()) + } + } + + @Throws(java.lang.Exception::class) + private fun convertArgToBytes(inputValue: String): ByteArray { + var hexValue = inputValue + if (!Numeric.containsHexPrefix(inputValue)) { + val value = try { + BigInteger(inputValue) + } catch (e: java.lang.NumberFormatException) { + BigInteger(inputValue, 16) + } + + hexValue = Numeric.toHexStringNoPrefix(value.toByteArray()) + // fix sign condition + if (hexValue.length > 64 && hexValue.startsWith("00")) { + hexValue = hexValue.substring(2) + } + } + + return Numeric.hexStringToByteArray(hexValue) + } + + private fun getArrayItems(field: Entry, value: Any): List { + val expectedDimensions = getArrayDimensionsFromDeclaration(field.type) + // This function will itself give out errors in case + // that the data is not a proper array + val dataDimensions = getArrayDimensionsFromData(value) + + val format = String.format( + "Array Data %s has dimensions %s, " + "but expected dimensions are %s", + value.toString(), dataDimensions.toString(), expectedDimensions.toString() + ) + if (expectedDimensions.size != dataDimensions.size) { + // Ex: Expected a 3d array, but got only a 2d array + throw BaseError(format) + } + for (i in expectedDimensions.indices) { + if (expectedDimensions[i] == -1) { + // Skip empty or dynamically declared dimensions + continue + } + if (expectedDimensions[i] != dataDimensions[i]) { + throw BaseError(format) + } + } + + return flattenMultidimensionalArray(value) + } + + @Throws(java.lang.RuntimeException::class) + fun encodeData(primaryType: String, data: Map): ByteArray { + val types: MutableMap> = + jsonMessageObject.types ?: return ByteArrayOutputStream().toByteArray() + + val encTypes: MutableList = mutableListOf() + val encValues: MutableList = mutableListOf() + + // Add typehash + encTypes.add("bytes32") + encValues.add(typeHash(primaryType)) + + types[primaryType]?.let { + // Add field contents + for (field in it) { + val value = data[field.name] ?: continue + val fieldType = field.type ?: continue + if (fieldType == "string") { + encTypes.add("bytes32") + val hashedValue = Numeric.hexStringToByteArray(Hash.sha3String(value as String)) + encValues.add(hashedValue) + } else if (fieldType == "bytes") { + encTypes.add(("bytes32")) + encValues.add(Hash.sha3(Numeric.hexStringToByteArray(value as String))) + } else if (types.containsKey(fieldType)) { + if (value is Map<*, *>) { + val safeMap = value.mapKeys { + if (it.key !is String) { + throw BaseError("Invalid key type: Expected String but got ${it.key?.javaClass}") + } + it.key as String + }.mapValues { + if (it.value == null) { + throw BaseError("Invalid value: Expected non-null Any but got null for key ${it.key}") + } + it.value as Any + } + + val hashedValue = Hash.sha3(encodeData(fieldType, safeMap)) + encTypes.add("bytes32") + encValues.add(hashedValue) + } else { + throw BaseError("Expected a Map but got ${value.javaClass}") + } + } else if (bytesTypePattern.matcher(fieldType).find()) { + encTypes.add(fieldType) + encValues.add(Numeric.hexStringToByteArray(value as String)) + } else if (arrayTypePattern.matcher(fieldType).find()) { + val baseTypeName = fieldType.substring(0, fieldType.indexOf('[')) + val arrayItems = getArrayItems(field, value) + val concatenatedArrayEncodingBuffer = ByteArrayOutputStream() + + for (arrayItem in arrayItems) { + val arrayItemEncoding = if (types.containsKey(baseTypeName)) { + val safeMap = safeCastToMap(arrayItem) + Hash.sha3(encodeData(baseTypeName, safeMap)) + } else { + convertToEncodedItem( + baseTypeName, + arrayItem + ) // add raw item, packed to 32 bytes + } + + concatenatedArrayEncodingBuffer.write( + arrayItemEncoding, 0, arrayItemEncoding.size + ) + } + + val concatenatedArrayEncodings = concatenatedArrayEncodingBuffer.toByteArray() + val hashedValue = Hash.sha3(concatenatedArrayEncodings) + encTypes.add("bytes32") + encValues.add(hashedValue) + } else if (fieldType.startsWith("uint") || fieldType.startsWith("int")) { + encTypes.add(fieldType) + // convert to BigInteger for ABI constructor compatibility + try { + encValues.add(convertToBigInt(value)) + } catch (e: java.lang.NumberFormatException) { + encValues.add( + value + ) // value null or failed to convert, fallback to add string as + // before + } catch (e: java.lang.NullPointerException) { + encValues.add( + value + ) + } + } else { + encTypes.add(fieldType) + encValues.add(value) + } + } + } + + val baos = ByteArrayOutputStream() + for (i in encTypes.indices) { + val typeClazz = AbiTypes.getType(encTypes[i]) + ?: throw BaseError("Unsupported or invalid type ${encTypes[i]}") + + var atleastOneConstructorExistsForGivenParametersType = false + // Using the Reflection API to get the types of the parameters + val constructors = typeClazz.constructors + for (constructor in constructors) { + // Check which constructor matches + try { + val parameterTypes = constructor.parameterTypes + val temp = + Numeric.hexStringToByteArray( + TypeEncoder.encode( + typeClazz + .getDeclaredConstructor(*parameterTypes) + .newInstance(encValues[i]) + ) + ) + baos.write(temp, 0, temp.size) + atleastOneConstructorExistsForGivenParametersType = true + break + } catch (ignored: IllegalArgumentException) { + } catch (ignored: NoSuchMethodException) { + } catch (ignored: InstantiationException) { + } catch (ignored: IllegalAccessException) { + } catch (ignored: InvocationTargetException) { + } + } + + if (!atleastOneConstructorExistsForGivenParametersType) { + throw BaseError( + String.format( + "Received an invalid argument for which no constructor" + + " exists for the ABI Class %s", + typeClazz.simpleName + ) + ) + } + } + + return baos.toByteArray() + } + + fun safeCastToMap(arrayItem: Any) = if (arrayItem is Map<*, *>) { + arrayItem.mapKeys { entry -> + if (entry.key !is String) { + throw BaseError("Invalid key type: Expected String but got ${entry.key?.javaClass}") + } + entry.key as String + }.mapValues { entry -> + if (entry.value == null) { + throw BaseError("Invalid value: Expected non-null Any but got null for key ${entry.key}") + } + entry.value as Any + } + } else { + throw BaseError("Expected a Map but got ${arrayItem.javaClass}") + } + + @Throws(java.lang.RuntimeException::class) + fun hashMessage(primaryType: String, data: MutableMap): ByteArray { + return Hash.sha3(encodeData(primaryType, data)) + } + + @Throws(java.lang.RuntimeException::class) + fun hashDomain(): ByteArray { + val data = hashMapOf() + jsonMessageObject.domain?.name?.let { + data.put("name", it) + } + jsonMessageObject.domain?.version?.let { + data.put("version", it) + } + jsonMessageObject.domain?.chainId?.let { + data.put("chainId", it) + } + jsonMessageObject.domain?.verifyingContract?.let { + data.put("verifyingContract", it) + } + jsonMessageObject.domain?.salt?.let { + data.put("salt", it) + } + return Hash.sha3(encodeData("EIP712Domain", data)) + } + + @Throws(java.lang.RuntimeException::class) + fun getStructuredData(): ByteArray { + val baos = ByteArrayOutputStream() + + val messagePrefix = "\u0019\u0001" + val prefix = messagePrefix.toByteArray() + baos.write(prefix, 0, prefix.size) + + val domainHash = hashDomain() + baos.write(domainHash, 0, domainHash.size) + if (jsonMessageObject.primaryType != "EIP712Domain" && jsonMessageObject.primaryType != null && jsonMessageObject.message != null) { + val dataHash = + hashMessage( + jsonMessageObject.primaryType!!, + jsonMessageObject.message!! + ) + baos.write(dataHash, 0, dataHash.size) + } + + return baos.toByteArray() + } + + @Throws(java.lang.RuntimeException::class) + fun hashStructuredData(): ByteArray { + return Hash.sha3(getStructuredData()) + } +} + + +// hashStructuredData > getStructuredData > hashDomain, hashMessage +// > encodeData > typeHash > encodeType > getDependencies \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/signature/VerifyUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/VerifyUtils.kt new file mode 100644 index 0000000..f4a1035 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/signature/VerifyUtils.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.circle.modularwallets.core.utils.signature + +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.models.WebAuthnData +import com.circle.modularwallets.core.utils.encoding.bytesToHex +import org.web3j.utils.Numeric.hexStringToByteArray +import java.security.MessageDigest +import java.security.PublicKey +import java.security.Signature + +@ExcludeFromGeneratedCCReport +internal fun verifyBasic( + hash: String, + authenticatorDataBytes: ByteArray, + webAuthn: WebAuthnData +): Boolean { + // Check length of `authenticatorData`. + if (authenticatorDataBytes.size < 37) return false + + val flag = authenticatorDataBytes[32].toInt() + + // Verify that the UP bit of the flags in authData is set. + if ((flag and 0x01) != 0x01) return false + + // If user verification was determined to be required, verify that + // the UV bit of the flags in authData is set. Otherwise, ignore the + // value of the UV flag. + if (webAuthn.userVerificationRequired && (flag and 0x04) != 0x04) return false + + // If the BE bit of the flags in authData is not set, verify that + // the BS bit is not set. + if ((flag and 0x08) != 0x08 && (flag and 0x10) == 0x10) return false + + // Check that response is for an authentication assertion + val expectedType = "\"type\":\"webauthn.get\"" + val typeSlice = webAuthn.clientDataJSON.substring( + webAuthn.typeIndex, + webAuthn.typeIndex + expectedType.length + ) + if (typeSlice != expectedType) return false + + // Check that hash is in the clientDataJSON. + val challenge = + extractChallenge(webAuthn.clientDataJSON, webAuthn.challengeIndex) ?: return false + // Validate the challenge in the clientDataJSON. + return bytesToHex(base64UrlToBytes(challenge)) == hash +} + +@ExcludeFromGeneratedCCReport +internal fun verifyRaw( + publicKey: PublicKey, + signature: String, + clientDataJSON: String, + authenticatorData: String +): Boolean { + val signatureBytes = base64UrlToBytes(signature) + val sig = Signature.getInstance("SHA256withECDSA") + sig.initVerify(publicKey) + val md = MessageDigest.getInstance("SHA-256") + val clientDataHash = md.digest(base64UrlToBytes(clientDataJSON)) + val signatureBase = base64UrlToBytes(authenticatorData) + clientDataHash + sig.update(signatureBase) + return sig.verify(signatureBytes) +} + +@ExcludeFromGeneratedCCReport +internal fun verify( + hash: String, + publicKey: String, + hexSignature: String, + webauthn: WebAuthnData +): Boolean { + val ecPublicKey = parsePublicKey(publicKey) + val signature = parseSignature(hexSignature) + val clientDataJSON = + bytesToBase64Url(webauthn.clientDataJSON.toByteArray()).replace("\n", "") + val authenticatorDataBytes = hexStringToByteArray(webauthn.authenticatorData) + val authenticatorData = + bytesToBase64Url(authenticatorDataBytes).replace("\n", "") + if (!verifyBasic(hash, authenticatorDataBytes, webauthn)) { + return false + } + return verifyRaw(ecPublicKey, signature, clientDataJSON, authenticatorData) +} + +@ExcludeFromGeneratedCCReport +private fun extractChallenge(clientDataJSON: String, challengeIndex: Int): String? { + val regex = """"challenge":"([^"]+)"""".toRegex() + val challengeSubstring = clientDataJSON.substring(challengeIndex) + val matchResult = regex.find(challengeSubstring) + return matchResult?.groups?.get(1)?.value +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetMinimumVerificationGasLimitUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetMinimumVerificationGasLimitUtils.kt new file mode 100644 index 0000000..032527a --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetMinimumVerificationGasLimitUtils.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.smartAccount + +import com.circle.modularwallets.core.chains.Mainnet +import com.circle.modularwallets.core.chains.Sepolia +import com.circle.modularwallets.core.constants.MAINNET_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.MAINNET_MINIMUM_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.MINIMUM_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.SEPOLIA_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.SEPOLIA_MINIMUM_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.gweiUnits +import java.math.BigInteger + +/** + * Gets the minimum verification gas limit for a given chain. + * + * @param deployed Whether the smart account is deployed. + * @param chainId The chain ID. + * @return The chain-specific minimum verification gas limit or the default value if the chain is not supported. + */ +@Throws(Exception::class) +fun getMinimumVerificationGasLimit(deployed: Boolean, chainId: Long): BigInteger { + when(chainId){ + Sepolia.chainId -> { + return if(deployed){ + SEPOLIA_MINIMUM_VERIFICATION_GAS_LIMIT + } else{ + SEPOLIA_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT + } + } + Mainnet.chainId -> { + return if(deployed){ + MAINNET_MINIMUM_VERIFICATION_GAS_LIMIT + } else{ + MAINNET_MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT + } + } + else -> { + return if(deployed){ + MINIMUM_VERIFICATION_GAS_LIMIT + } else{ + MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT + } + } + } +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/unit/FormatUnitUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/unit/FormatUnitUtils.kt new file mode 100644 index 0000000..8db9189 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/unit/FormatUnitUtils.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.unit + +import com.circle.modularwallets.core.constants.gweiUnits +import java.math.BigInteger + +/** + * Converts a numerical value in wei to a string representation in gwei. + * + * @param wei The numerical value in wei. + * @param unit The unit of the value (default is "wei"). + * @return The string representation of the value in gwei. + */ +@Throws(Exception::class) +@JvmOverloads +fun formatGwei(wei: BigInteger?, unit: String = "wei"): String? { + wei ?: return null + val decimals = gweiUnits[unit] ?: 0 + return formatUnits(wei, decimals) +} + +/** + * Converts a numerical value in wei to a string representation in a specified unit. + * + * @param value The numerical value in wei. + * @param decimals The number of decimal places to consider for the unit. + * @return The string representation of the value in the specified unit. + */ +@Throws(Exception::class) +fun formatUnits(value: BigInteger, decimals: Int): String { + + var display = value.toString() + + + val negative = display.startsWith('-') + if (negative) display = display.substring(1) + + + display = display.padStart(decimals, '0') + + + val integerPart = display.substring(0, display.length - decimals) + var fractionPart = display.substring(display.length - decimals) + + + fractionPart = fractionPart.replace(Regex("0+$"), "") + + + return "${if (negative) "-" else ""}${if (integerPart.isEmpty()) "0" else integerPart}" + + if (fractionPart.isNotEmpty()) ".$fractionPart" else "" +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/unit/ParseUnitUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/unit/ParseUnitUtils.kt new file mode 100644 index 0000000..a898b0f --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/unit/ParseUnitUtils.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.unit + +import com.circle.modularwallets.core.constants.etherUnits +import com.circle.modularwallets.core.constants.gweiUnits +import com.circle.modularwallets.core.errors.BaseError +import java.math.BigInteger +import kotlin.math.round + +/** + * Converts a string representation of gwei to numerical wei. + * + * @param value The string representation of gwei. + * @param unit The unit of the value (default is "wei"). + * @return The numerical value in wei. + * @throws IllegalArgumentException If the unit is invalid. + * @throws BaseError If the value is not a valid decimal number. + */ +@JvmOverloads +fun parseGwei(value: String, unit: String = "wei"): BigInteger { + val decimals = gweiUnits[unit] ?: throw BaseError("Invalid unit: $unit") + return parseUnits(value, decimals) +} + +/** + * Converts a string representation of ether to numerical wei. + * + * @param value The string representation of ether. + * @param unit The unit of the value (default is "wei"). + * @return The numerical value in wei. + */ +@JvmOverloads +@Throws(Exception::class) +fun parseEther(value: String, unit: String = "wei"): BigInteger { + val decimals = etherUnits[unit] ?: throw BaseError("Invalid unit: $unit") + return parseUnits(value, decimals) +} + +/** + * Multiplies a string representation of a number by a given exponent of base 10 (10^exponent). + * + * @param value The string representation of the number. + * @param decimals The exponent of base 10. + * @return The numerical value as a BigInteger. + * @throws IllegalArgumentException If the decimals value is invalid. + */ +@Throws(Exception::class) +fun parseUnits(value: String, decimals: Int): BigInteger { + // Validate the input string against the regex + if (!Regex("""^(-?)([0-9]*)\.?([0-9]*)$""").matches(value)) { + throw BaseError("InvalidDecimalNumberError $value") + } + + // Split the input into integer and fraction parts + var (integerPart, fractionPart) = value.split('.').let { + it[0] to (it.getOrNull(1) ?: "0") + } + + val negative = integerPart.startsWith('-') + if (negative) integerPart = integerPart.substring(1) + + // Trim trailing zeros + var fraction = fractionPart.replace(Regex("(0+)$"), "") + + // Handle case when decimals is zero + if (decimals == 0) { + if (round("0.$fraction".toDouble()) == 1.0) { + val updatedInteger = BigInteger(integerPart) + BigInteger.ONE + return BigInteger("${if (negative) "-" else ""}${updatedInteger}") + } + fraction = "" + } else if (fraction.length > decimals) { + val left = fraction.substring(0, decimals - 1) + val unit = fraction.substring(decimals - 1, decimals) + val right = fraction.substring(decimals) + + val rounded = round("$unit.$right".toDouble()).toInt() + + if (rounded > 9) { + fraction = + BigInteger(left).add(BigInteger.ONE).toString() + "0".padStart(left.length + 1, '0') + } else { + fraction = "$left$rounded" + } + + if (fraction.length > decimals) { + fraction = fraction.substring(1) + val updatedInteger = BigInteger(integerPart) + BigInteger.ONE + integerPart = updatedInteger.toString() + } + + fraction = fraction.substring(0, decimals) + } else { + fraction = fraction.padEnd(decimals, '0') + } + + return BigInteger("${if (negative) "-" else ""}$integerPart$fraction") +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/userOperation/GetUserOperationHashUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/userOperation/GetUserOperationHashUtils.kt new file mode 100644 index 0000000..15cc4d8 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/userOperation/GetUserOperationHashUtils.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.userOperation + +import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.UserOperationV07 +import com.circle.modularwallets.core.utils.data.concat +import com.circle.modularwallets.core.utils.data.pad +import com.circle.modularwallets.core.utils.encoding.bigIntegerToHex +import org.web3j.abi.DefaultFunctionEncoder +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Type +import org.web3j.abi.datatypes.generated.Bytes32 +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.crypto.Hash +import org.web3j.utils.Numeric +import java.math.BigInteger + +/** + * Generates the hash of a user operation. + * + * @param chainId The ID of the blockchain network. + * @param entryPointAddress The address of the entry point contract. Default is the address of EntryPoint.V07. + * @param userOp The user operation to hash. + * @return The hash of the user operation as a hex string. + */ + +@JvmOverloads +fun getUserOperationHash( + chainId: Long, + entryPointAddress: String = EntryPoint.V07.address, + userOp: UserOperationV07 +): String { + val accountGasLimits = Numeric.hexStringToByteArray( + concat( + pad(bigIntegerToHex(userOp.verificationGasLimit), 16), + pad(bigIntegerToHex(userOp.callGasLimit), 16) + ) + ) + val callDataHashed = Hash.sha3(Numeric.hexStringToByteArray(userOp.callData)) + val gasFees = Numeric.hexStringToByteArray( + concat( + pad(bigIntegerToHex(userOp.maxPriorityFeePerGas), 16), + pad(bigIntegerToHex(userOp.maxFeePerGas), 16) + ) + ) + val initCodeHashed = Hash.sha3( + Numeric.hexStringToByteArray( + if (userOp.factory != null && userOp.factoryData != null) concat( + userOp.factory!!, + userOp.factoryData!! + ) else "0x" + ) + ) + val paymasterAndDataHashed = Hash.sha3(Numeric.hexStringToByteArray(userOp.paymaster?.let { + concat( + it, + pad(bigIntegerToHex(userOp.paymasterVerificationGasLimit ?: BigInteger.ZERO), 16), + pad(bigIntegerToHex(userOp.paymasterPostOpGasLimit ?: BigInteger.ZERO), 16), + userOp.paymasterData ?: "0x" + ) + } ?: "0x")) + val encoder = DefaultFunctionEncoder() + val packedUserOp = encoder.encodeParameters( + listOf>( + Address(userOp.sender), + Uint256(userOp.nonce), + Bytes32(initCodeHashed), + Bytes32(callDataHashed), + Bytes32(accountGasLimits), + Uint256(userOp.preVerificationGas), + Bytes32(gasFees), + Bytes32(paymasterAndDataHashed), + ) + ) + return Hash.sha3( + encoder.encodeParameters( + listOf>( + Bytes32(Hash.sha3(Numeric.hexStringToByteArray(packedUserOp))), + Address(entryPointAddress), + Uint256(chainId) + ) + ) + ) +} + + +internal fun parseFactoryAddressAndData(initCode: String): Pair { + val factoryAddress = initCode.substring(0, 42) + val factoryData = ("0x${initCode.substring(42)}") + return Pair(factoryAddress, factoryData) +} diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/CredentialManagerUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/CredentialManagerUtils.kt new file mode 100644 index 0000000..6b5067c --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/CredentialManagerUtils.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.webauthn + +import android.content.Context +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.BaseErrorParameters + +@ExcludeFromGeneratedCCReport +internal suspend fun createPasskey( + context: Context, + registrationJson: String +): CreatePublicKeyCredentialResponse { + try { + val request = CreatePublicKeyCredentialRequest(registrationJson) + val credentialManager = CredentialManager.create(context) + val response: CreatePublicKeyCredentialResponse = credentialManager.createCredential( + context, + request + ) as CreatePublicKeyCredentialResponse + + PublicKeyCredential(response.registrationResponseJson) + return response + } catch (e: CreateCredentialException) { + e.message?.let{ + if(it.contains("User cancelled the selector")) { + throw BaseError(it, BaseErrorParameters(e)) + } + } + throw BaseError("credential request failed.", BaseErrorParameters(e, registrationJson)) + } +} + +@ExcludeFromGeneratedCCReport +internal suspend fun getSavedCredentials(context: Context, authJson: String): String { + val getPublicKeyCredentialOption = + GetPublicKeyCredentialOption(authJson, null) + val credentialManager = CredentialManager.create(context) + val result = try { + credentialManager.getCredential( + context, + GetCredentialRequest( + listOf( + getPublicKeyCredentialOption, + GetPasswordOption() + ) + ) + ) + } catch (e: Exception) { + e.message?.let{ + if(it.contains("User cancelled the selector")) { + throw BaseError(it, BaseErrorParameters(e)) + } + } + throw BaseError("credential request failed.", BaseErrorParameters(e)) + } + + if (result.credential is PublicKeyCredential) { + val cred = result.credential as PublicKeyCredential + return cred.authenticationResponseJson + } + throw BaseError("credential request failed. No PublicKeyCredential") +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/OptionsUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/OptionsUtils.kt new file mode 100644 index 0000000..11263a9 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/webauthn/OptionsUtils.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at. + * + * Http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.circle.modularwallets.core.utils.webauthn + +import android.util.Base64 +import com.circle.modularwallets.core.apis.rp.PublicKeyCredentialDescriptor +import com.circle.modularwallets.core.apis.rp.PublicKeyCredentialRequestOptions +import com.circle.modularwallets.core.utils.signature.bytesToBase64Url +import com.circle.modularwallets.core.utils.toJson +import org.web3j.utils.Numeric.hexStringToByteArray +import java.security.SecureRandom + +internal fun getRequestOptions( + rpId: String, + allowCredentialId: String? = null, + hex: String +): Pair { + val challenge: String = bytesToBase64Url(hexStringToByteArray(hex)) //getEncodedChallenge() + val allowCredentials = if (allowCredentialId?.isNotBlank() == true) mutableListOf( + PublicKeyCredentialDescriptor(allowCredentialId) + ) else null + val options = PublicKeyCredentialRequestOptions(challenge, rpId, allowCredentials) + return Pair(options, toJson(options)) +} + +private fun getEncodedUserId(): String { + val random = SecureRandom() + val bytes = ByteArray(64) + random.nextBytes(bytes) + return Base64.encodeToString( + bytes, + Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING + ) +} + +internal fun getEncodedChallenge(): String { + val random = SecureRandom() + val bytes = ByteArray(32) + random.nextBytes(bytes) + return Base64.encodeToString( + bytes, + Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f0c6c07 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "CircleModularWalletsCore" +include(":lib")