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 index 4a7e7e7..d5deeaf 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/accounts/CircleSmartAccount.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/CircleSmartAccount.kt @@ -21,57 +21,32 @@ package com.circle.modularwallets.core.accounts import android.content.Context import com.circle.modularwallets.core.BuildConfig +import com.circle.modularwallets.core.accounts.implementations.CircleSmartAccountDelegate +import com.circle.modularwallets.core.accounts.implementations.LocalCircleSmartAccountDelegate +import com.circle.modularwallets.core.accounts.implementations.WebAuthnCircleSmartAccountDelegate 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.FACTORY -import com.circle.modularwallets.core.constants.PUBLIC_KEY_OWN_WEIGHT -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.pad -import com.circle.modularwallets.core.utils.data.slice -import com.circle.modularwallets.core.utils.encoding.stringToHex 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.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 @@ -79,31 +54,6 @@ 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") @@ -111,8 +61,44 @@ internal fun getCurrentDateTime(): String { return dateFormat.format(currentDate) } -internal fun getDefaultWalletName(): String { - return "passkey-${getCurrentDateTime()}" +internal fun getDefaultWalletName(prefix: String): String { + return "$prefix-${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 defaults to passkey-{datetime}. + * @return The created Circle smart account. + */ + +@Throws(Exception::class) +@JvmOverloads +suspend fun toCircleSmartAccount( + client: Client, + owner: WebAuthnAccount, + version: String = CIRCLE_SMART_ACCOUNT_VERSION_V1, + name: String = getDefaultWalletName(WebAuthnCircleSmartAccountDelegate.WALLET_PREFIX) +): CircleSmartAccount { + val delegate = WebAuthnCircleSmartAccountDelegate(owner) + val actualVersion = CIRCLE_SMART_ACCOUNT_VERSION[version] ?: version + val wallet = + try { + delegate.getModularWalletAddress(client.transport, actualVersion, name) + } catch (e: Throwable) { + if (BuildConfig.INTERNAL_BUILD) { + delegate.getComputeWallet(client, actualVersion) + } else { + throw e + } + } + val account = CircleSmartAccount( + client, delegate, wallet + ) + return account } /** @@ -121,7 +107,7 @@ internal fun getDefaultWalletName(): String { * @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. + * @param name The wallet name assigned to the newly registered account defaults to wallet-{datetime}. * @return The created Circle smart account. */ @@ -129,23 +115,24 @@ internal fun getDefaultWalletName(): String { @JvmOverloads suspend fun toCircleSmartAccount( client: Client, - owner: Account, + owner: LocalAccount, version: String = CIRCLE_SMART_ACCOUNT_VERSION_V1, - name: String = getDefaultWalletName() + name: String = getDefaultWalletName(LocalCircleSmartAccountDelegate.WALLET_PREFIX) ): CircleSmartAccount { + val delegate = LocalCircleSmartAccountDelegate(owner) val actualVersion = CIRCLE_SMART_ACCOUNT_VERSION[version] ?: version val wallet = try { - getModularWalletAddress(client.transport, owner.getAddress(), actualVersion, name) + delegate.getModularWalletAddress(client.transport, actualVersion, name) } catch (e: Throwable) { if (BuildConfig.INTERNAL_BUILD) { - getComputeWallet(client, owner, actualVersion) + delegate.getComputeWallet(client, actualVersion) } else { throw e } } val account = CircleSmartAccount( - client, owner, wallet + client, delegate, wallet ) return account } @@ -154,17 +141,17 @@ suspend fun toCircleSmartAccount( * 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, + private val delegate: CircleSmartAccountDelegate, 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 { @@ -219,7 +206,7 @@ class CircleSmartAccount( wallet.scaConfiguration.initCode?.let { return parseFactoryAddressAndData(it) } - return Pair(FACTORY.address, getFactoryData(owner.getAddress())) + return Pair(FACTORY.address, delegate.getFactoryData()) } /** @@ -269,23 +256,18 @@ class CircleSmartAccount( } /** - * Signs a hash via the Smart Account's owner. + * messageHash The hash to sign. * * @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 messageHash The hash to sign. + * @param messageHash The message to sign. * @return The signed data. */ @ExcludeFromGeneratedCCReport @Throws(Exception::class) override suspend fun sign(context: Context, messageHash: String): String { - val replaySafeMessageHash = UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), messageHash) - val signResult = owner.sign(context, replaySafeMessageHash) - val signature = encodePackedForSignature( - signResult, - owner.getAddress(), - false, - ) - return signature + val replaySafeMessageHash = + UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), messageHash) + return delegate.signAndWrap(context, replaySafeMessageHash, false) } /** @@ -299,18 +281,13 @@ class CircleSmartAccount( @Throws(Exception::class) override suspend fun signMessage(context: Context, message: String): String { val hashedMessage = hashMessage(message.toByteArray()) - val replaySafeMessageHash = UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), hashedMessage) - val signResult = owner.sign(context, replaySafeMessageHash) - val signature = encodePackedForSignature( - signResult, - owner.getAddress(), - false, - ) - return signature + val replaySafeMessageHash = + UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), hashedMessage) + return delegate.signAndWrap(context, replaySafeMessageHash, false) } /** - * Signs the given typed data. + * Signs a 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. @@ -320,14 +297,9 @@ class CircleSmartAccount( @Throws(Exception::class) override suspend fun signTypedData(context: Context, typedData: String): String { val hashedTypedData = hashTypedData(typedData) - val replaySafeMessageHash = UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), hashedTypedData) - val signResult = owner.sign(context, replaySafeMessageHash) - val signature = encodePackedForSignature( - signResult, - owner.getAddress(), - false, - ) - return signature + val replaySafeMessageHash = + UtilApiImpl.getReplaySafeMessageHash(client.transport, getAddress(), hashedTypedData) + return delegate.signAndWrap(context, replaySafeMessageHash, false) } /** @@ -345,14 +317,7 @@ class CircleSmartAccount( ): 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 + return delegate.signAndWrap(context, userOpHash, true) } /** @@ -365,156 +330,3 @@ class CircleSmartAccount( } } - -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/LocalAccount.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/LocalAccount.kt new file mode 100644 index 0000000..cf70429 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/LocalAccount.kt @@ -0,0 +1,93 @@ +/* + * 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.accounts.implementations.Web3jLocalAccount +import org.web3j.crypto.Credentials + +/** + * Creates a [LocalAccount] instance from a hexadecimal private key string. + * + * The underlying account implementation is [Web3jLocalAccount]. + * + * @param privateKey The private key as a hexadecimal string. + * @return A [LocalAccount] instance derived from the provided private key. + */ +fun privateKeyToAccount(privateKey: String): LocalAccount { + val credentials = Credentials.create(privateKey) + return LocalAccount(Web3jLocalAccount(credentials)) +} + +/** + * Represents a local account with signing capabilities. + * + * Instances are typically created via factory functions like [mnemonicToAccount] or [privateKeyToAccount]. + * + * @param delegate The underlying [Account] instance that this [LocalAccount] will delegate + * its operations to. This delegate is responsible for the actual cryptographic + * operations and key management. + */ +class LocalAccount( + private val delegate: Account +) : Account() { + + /** + * Retrieves the address of the local account. + * + * @return The address of the local account. + */ + override fun getAddress(): String { + return delegate.getAddress() + } + + /** + * Signs the given hex string. + * + * @param context The context in which the signing operation is performed. + * @param hex The hex string to be signed. + * @return The signed hex string. + */ + override suspend fun sign(context: Context, hex: String): String { + return delegate.sign(context, hex) + } + + /** + * Signs the given message. + * + * @param context The context in which the signing operation is performed. + * @param message The message to be signed. + * @return The signed message. + */ + override suspend fun signMessage(context: Context, message: String): String { + return delegate.signMessage(context, message) + } + + /** + * Signs the given typed data. + * + * @param context The context in which the signing operation is performed. + * @param typedData The typed data to be signed. + * @return The signed typed data. + */ + override suspend fun signTypedData(context: Context, typedData: String): String { + return delegate.signTypedData(context, typedData) + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/CircleSmartAccountDelegate.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/CircleSmartAccountDelegate.kt new file mode 100644 index 0000000..9adb96e --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/CircleSmartAccountDelegate.kt @@ -0,0 +1,44 @@ +/* + * 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.implementations + +import android.content.Context +import com.circle.modularwallets.core.apis.modular.ModularWallet +import com.circle.modularwallets.core.clients.Client +import com.circle.modularwallets.core.transports.Transport + +interface CircleSmartAccountDelegate { + + suspend fun getModularWalletAddress( + transport: Transport, version: String, name: String? = null + ): ModularWallet + + suspend fun getComputeWallet( + client: Client, + version: String + ): ModularWallet + + fun getFactoryData(): String + + suspend fun signAndWrap( + context: Context, + hash: String, + hasUserOpGas: Boolean + ): String +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/LocalCircleSmartAccountDelegate.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/LocalCircleSmartAccountDelegate.kt new file mode 100644 index 0000000..e458446 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/LocalCircleSmartAccountDelegate.kt @@ -0,0 +1,190 @@ +/* + * 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.implementations + +import android.content.Context +import com.circle.modularwallets.core.accounts.LocalAccount +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.util.UtilApiImpl +import com.circle.modularwallets.core.clients.Client +import com.circle.modularwallets.core.constants.CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN +import com.circle.modularwallets.core.constants.FACTORY +import com.circle.modularwallets.core.constants.OWNER_WEIGHT +import com.circle.modularwallets.core.constants.SALT +import com.circle.modularwallets.core.constants.SIG_TYPE_FLAG_DIGEST +import com.circle.modularwallets.core.constants.THRESHOLD_WEIGHT +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.abi.encodeAbiParameters +import com.circle.modularwallets.core.utils.abi.encodePacked +import com.circle.modularwallets.core.utils.data.pad +import com.circle.modularwallets.core.utils.signature.deserializeSignature +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.DynamicArray +import org.web3j.abi.datatypes.DynamicBytes +import org.web3j.abi.datatypes.StaticStruct +import org.web3j.abi.datatypes.Type +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.Sign +import org.web3j.utils.Numeric + +internal class LocalCircleSmartAccountDelegate(val owner: LocalAccount) : + CircleSmartAccountDelegate { + + override suspend fun getModularWalletAddress( + transport: Transport, + version: String, + name: String? + ): ModularWallet { + return getModularWalletAddress(transport, owner.getAddress(), version, name) + } + + override suspend fun getComputeWallet(client: Client, version: String): ModularWallet { + return ModularWallet( + address = getAddressFromLocalOwner(client.transport, owner.getAddress()), + scaConfiguration = ScaConfiguration( + scaCore = version, + ), + ) + } + + override fun getFactoryData(): String { + return getFactoryData(owner.getAddress()) + } + + override suspend fun signAndWrap( + context: Context, + hash: String, + hasUserOpGas: Boolean + ): String { + val signature = + if (hasUserOpGas) owner.signMessage(context, hash) else owner.sign(context, hash) + val signatureData = deserializeSignature(signature) + return encodePackedForSignature(signatureData, hasUserOpGas) + } + + companion object { + const val WALLET_PREFIX = "wallet" + internal suspend fun getModularWalletAddress( + transport: Transport, address: String, version: String, name: String? = null + ): ModularWallet { + val wallet = + ModularApiImpl.getAddress( + transport, + getCreateWalletReq(address, version, name) + ) + return wallet + } + + internal suspend fun getAddressFromLocalOwner( + transport: Transport, + address: String + ): String { + val sender = pad(address) + val initializeUpgradableMSCAParams = getInitializeUpgradableMSCAParams(address) + + /** address, mixedSalt */ + val result = UtilApiImpl.getAddress( + transport, + FACTORY.address, + Numeric.hexStringToByteArray(sender), + SALT, + Numeric.hexStringToByteArray(initializeUpgradableMSCAParams) + ) + return result.first + } + + private fun getInitializeUpgradableMSCAParams(address: String): String { + val pluginInstallParams = getPluginInstallParams(address) + 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 + } + + private fun getPluginInstallParams(address: String): String { + val encoded = encodeAbiParameters( + listOf( + DynamicArray(Address::class.java, Address(address)), + DynamicArray( + Uint256::class.java, Uint256(OWNER_WEIGHT) + ), + DynamicArray(StaticStruct::class.java), + DynamicArray(Uint256::class.java), + Uint256(THRESHOLD_WEIGHT) + ) + ) + return encoded + } + + private fun getFactoryData(address: String): String { + val sender = pad(address) + val initializeUpgradableMSCAParams = getInitializeUpgradableMSCAParams(address) + 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 + } + + /** + * Wraps a raw Secp256k1 signature into the ABI-encoded format expected by smart contract. + * */ + internal fun encodePackedForSignature( + signatureData: Sign.SignatureData, + hasUserOpGas: Boolean + ): String { + val sigType: Long = + if (hasUserOpGas) signatureData.v[0].toLong() + SIG_TYPE_FLAG_DIGEST else signatureData.v[0].toLong() + val encoded = + encodePacked( + listOf>( + Bytes32(signatureData.r), + Bytes32(signatureData.s), + Uint8(sigType), + ) + ) + + return encoded + } + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/Web3jLocalAccount.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/Web3jLocalAccount.kt new file mode 100644 index 0000000..4a856b5 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/Web3jLocalAccount.kt @@ -0,0 +1,93 @@ +/* + * 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.implementations + +import android.content.Context +import com.circle.modularwallets.core.accounts.Account +import com.circle.modularwallets.core.utils.encoding.toBytes +import com.circle.modularwallets.core.utils.signature.hashTypedData +import com.circle.modularwallets.core.utils.signature.serializeSignature +import org.web3j.crypto.Credentials +import org.web3j.crypto.Sign + +/** + * An internal implementation of [Account] that uses Web3j [Credentials] + * for cryptographic operations such as signing messages and transactions. + * This class directly handles interactions with the Web3j library for local accounts. + * + * @param credentials The Web3j [Credentials] associated with this local account, + * containing the key pair used for signing. + */ +internal class Web3jLocalAccount(private val credentials: Credentials) : Account() { + + /** + * Retrieves the Ethereum address associated with the [credentials]. + * + * @return The Ethereum address for the current local account. + */ + override fun getAddress(): String { + return credentials.address + } + + /** + * Signs the given hexadecimal string, typically representing a pre-hashed message or transaction hash. + * This method uses `Sign.signMessage` from Web3j, which does not apply the Ethereum message prefix. + * + * @param context The Android [Context]. Currently not used in this specific signing operation + * but available for potential future extensions (e.g., hardware-backed signing). + * @param hex The hexadecimal string (e.g., a 32-byte hash) to be signed. + * @return The ECDSA signature as a serialized hex string (r + s + v). + */ + override suspend fun sign(context: Context, hex: String): String { + val signatureData = Sign.signMessage(toBytes(hex), credentials.ecKeyPair, false) + return serializeSignature(signatureData) + } + + /** + * Signs the given message string after applying the standard Ethereum message prefix + * (`\x19Ethereum Signed Message:\n` + message length). + * This method uses `Sign.signPrefixedMessage` from Web3j. + * + * @param context The Android [Context]. Currently not used in this specific signing operation + * but available for potential future extensions. + * @param message The plain string message to be signed. It will be UTF-8 encoded. + * @return The ECDSA signature as a serialized hex string (r + s + v). + */ + override suspend fun signMessage(context: Context, message: String): String { + val signatureData = Sign.signPrefixedMessage(toBytes(message), credentials.ecKeyPair) + return serializeSignature(signatureData) + } + + /** + * Signs the given EIP-712 typed data. + * The input [typedData] string is expected to be a JSON representation compliant with EIP-712. + * This method first computes the EIP-712 hash of the typed data and then signs this hash + * using the [sign] method (which does not add an additional Ethereum prefix). + * + * @param context The Android [Context]. Currently not used in this specific signing operation + * but available for potential future extensions. + * @param typedData The EIP-712 typed data as a JSON string. + * @return The ECDSA signature as a serialized hex string (r + s + v). + */ + override suspend fun signTypedData(context: Context, typedData: String): String { + val hash = hashTypedData(typedData) + return sign(context, hash) + } +} + diff --git a/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/WebAuthnCircleSmartAccountDelegate.kt b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/WebAuthnCircleSmartAccountDelegate.kt new file mode 100644 index 0000000..bdf01cc --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/implementations/WebAuthnCircleSmartAccountDelegate.kt @@ -0,0 +1,277 @@ +/* + * 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.implementations + +import android.content.Context +import com.circle.modularwallets.core.accounts.WebAuthnAccount +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.util.UtilApiImpl +import com.circle.modularwallets.core.clients.Client +import com.circle.modularwallets.core.constants.CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN +import com.circle.modularwallets.core.constants.FACTORY +import com.circle.modularwallets.core.constants.OWNER_WEIGHT +import com.circle.modularwallets.core.constants.SALT +import com.circle.modularwallets.core.constants.SIG_TYPE_SECP256R1 +import com.circle.modularwallets.core.constants.SIG_TYPE_SECP256R1_DIGEST +import com.circle.modularwallets.core.constants.THRESHOLD_WEIGHT +import com.circle.modularwallets.core.models.SignResult +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.utils.abi.encodeAbiParameters +import com.circle.modularwallets.core.utils.abi.encodePacked +import com.circle.modularwallets.core.utils.data.pad +import com.circle.modularwallets.core.utils.data.slice +import com.circle.modularwallets.core.utils.encoding.stringToHex +import com.circle.modularwallets.core.utils.signature.hashMessage +import com.circle.modularwallets.core.utils.signature.parseP256Signature +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.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 + +internal class WebAuthnCircleSmartAccountDelegate(val owner: WebAuthnAccount) : + CircleSmartAccountDelegate { + + override suspend fun getModularWalletAddress( + transport: Transport, version: String, name: String? + ): ModularWallet { + return getModularWalletAddress(transport, owner.getAddress(), version, name) + } + + + override suspend fun getComputeWallet( + client: Client, + version: String + ): ModularWallet { + return ModularWallet( + address = getAddressFromWebAuthnOwner(client.transport, owner.getAddress()), + scaConfiguration = ScaConfiguration( + scaCore = version, + ), + ) + } + + override fun getFactoryData(): String { + return getFactoryData(owner.getAddress()) + } + + override suspend fun signAndWrap( + context: Context, + hash: String, + hasUserOpGas: Boolean + ): String { + val targetHash = if (hasUserOpGas) hashMessage(hash) else hash + val signResult = owner.sign(context, targetHash) + val signature = encodePackedForSignature( + signResult, + owner.getAddress(), + hasUserOpGas, + ) + return signature + } + + companion object { + const val WALLET_PREFIX = "passkey" + const val DYNAMIC_POSITION = 65L + 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 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) SIG_TYPE_SECP256R1_DIGEST else SIG_TYPE_SECP256R1 + val encoded = + encodePacked( + listOf>( + Bytes32(formattedSender), + Uint256(DYNAMIC_POSITION), + Uint8(sigType), + Uint256(sigBytes.size.toLong()), + DynamicBytes(sigBytes), + ) + ) + + return encoded + } + + private 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))) + } + + private 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(OWNER_WEIGHT) + ), + Uint256(THRESHOLD_WEIGHT) + ) + ) + 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 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 + } + + 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 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 + } + } +} \ No newline at end of file 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 index 05c16bd..54a1196 100644 --- 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 @@ -20,6 +20,8 @@ package com.circle.modularwallets.core.apis.modular import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport +import com.circle.modularwallets.core.constants.OWNER_WEIGHT +import com.circle.modularwallets.core.constants.THRESHOLD_WEIGHT import com.circle.modularwallets.core.models.AddressMappingOwner import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -77,23 +79,23 @@ data class InitialOwnershipConfiguration( @JsonClass(generateAdapter = true) data class WeightedMultiSig( - @Json(name = "webauthnOwners") val webauthnOwners: Array, - @Json(name = "thresholdWeight") val thresholdWeight: Int, + @Json(name = "webauthnOwners") val webauthnOwners: Array? = null, @Json(name = "owners") val owners: Array? = null, + @Json(name = "thresholdWeight") val thresholdWeight: Long, ) @ExcludeFromGeneratedCCReport @JsonClass(generateAdapter = true) data class EoaOwner( @Json(name = "address") val address: String, - @Json(name = "weight") val weight: Int, + @Json(name = "weight") val weight: Long, ) @JsonClass(generateAdapter = true) data class WebauthnOwner( @Json(name = "publicKeyX") val publicKeyX: String, @Json(name = "publicKeyY") val publicKeyY: String, - @Json(name = "weight") val weight: Int, + @Json(name = "weight") val weight: Long, ) internal fun getCreateWalletReq( @@ -108,9 +110,31 @@ internal fun getCreateWalletReq( WeightedMultiSig( arrayOf( WebauthnOwner( - publicKeyX, publicKeyY, 1 + publicKeyX, publicKeyY, OWNER_WEIGHT ) - ), 1 + ), thresholdWeight = THRESHOLD_WEIGHT + ) + ), + version + ), + Metadata(name) + ) +} + +internal fun getCreateWalletReq( + address: String, + version: String, + name: String? = null +): GetAddressReq { + return GetAddressReq( + ScaConfiguration( + InitialOwnershipConfiguration( + WeightedMultiSig( + null, arrayOf( + EoaOwner( + address, OWNER_WEIGHT + ) + ), THRESHOLD_WEIGHT ) ), version 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 index e2e7fe9..79cab96 100644 --- 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 @@ -63,4 +63,9 @@ internal interface UtilApi { hash: String, ): String + suspend fun isOwnerOf( + transport: Transport, + account: String, + ownerToCheck: String, + ): 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 index 659479a..0ab954f 100644 --- 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 @@ -20,6 +20,7 @@ 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.CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN import com.circle.modularwallets.core.constants.EIP1271_VALID_SIGNATURE import com.circle.modularwallets.core.errors.BaseError import com.circle.modularwallets.core.transports.RpcRequest @@ -32,6 +33,7 @@ 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.Bool import org.web3j.abi.datatypes.DynamicBytes import org.web3j.abi.datatypes.Function import org.web3j.abi.datatypes.Type @@ -149,6 +151,43 @@ internal object UtilApiImpl : UtilApi { return EIP1271_VALID_SIGNATURE.contentEquals(decoded[0].value as ByteArray) } + @ExcludeFromGeneratedCCReport + override suspend fun isOwnerOf( + transport: Transport, + account: String, + ownerToCheck: String, + ): Boolean { + val function = Function( + "isOwnerOf", + listOf>( + Address(account), + Address(ownerToCheck) + ), + listOf>( + object : TypeReference() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d( + msg = """ + isOwnerOf > call + Account: $account + OwnerToCheck: $ownerToCheck + """.trimIndent() + ) + val resp = call( + transport, + from = account, + to = CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN.address, + data + ) + val decoded = FunctionReturnDecoder.decode(resp, function.outputParameters) + if (decoded.isEmpty()) { + Logger.w(msg = "Empty response from isOwner call") + return false + } + return decoded[0].value as Boolean + } + override suspend fun getReplaySafeMessageHash( transport: Transport, account: String, 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 index 93ed258..c368cb5 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/clients/BundlerClient.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/clients/BundlerClient.kt @@ -31,11 +31,19 @@ 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.constants.CIRCLE_PLUGIN_ADD_OWNERS_ABI +import com.circle.modularwallets.core.constants.OWNER_WEIGHT +import com.circle.modularwallets.core.errors.BaseError +import com.circle.modularwallets.core.errors.ExecutionRevertedError +import com.circle.modularwallets.core.errors.InvalidParamsRpcError +import com.circle.modularwallets.core.errors.UserOperationExecutionError import com.circle.modularwallets.core.models.AddressMappingOwner import com.circle.modularwallets.core.models.Block import com.circle.modularwallets.core.models.CreateAddressMappingResult +import com.circle.modularwallets.core.models.EOAIdentifier import com.circle.modularwallets.core.models.EncodeCallDataArg import com.circle.modularwallets.core.models.EntryPoint +import com.circle.modularwallets.core.models.EoaAddressMappingOwner import com.circle.modularwallets.core.models.EstimateFeesPerGasResult import com.circle.modularwallets.core.models.EstimateUserOperationGasResult import com.circle.modularwallets.core.models.GetUserOperationResult @@ -44,7 +52,10 @@ 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.abi.encodeFunctionData +import com.circle.modularwallets.core.utils.abi.isAddress import com.circle.modularwallets.core.utils.encoding.hexToLong +import com.circle.modularwallets.core.utils.error.isMappedError import java.math.BigInteger class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transport) { @@ -183,6 +194,80 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor ) } + /** + * Registers a recovery address during the recovery process. + * + * @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 recoveryAddress The recovery address. + * @param partialUserOp A partially constructed UserOperation object. + * The `callData` field, if provided, will be **overwritten internally** + * with the encoded `addOwners` call data based on `recoveryAddress`. + * @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, or `null` if no operation was sent because the recovery address already exists. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + @JvmOverloads + suspend fun registerRecoveryAddress( + context: Context, + account: SmartAccount, + recoveryAddress: String, + partialUserOp: UserOperationV07 = UserOperationV07(), + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): String? { + if(!isAddress(recoveryAddress)){ + throw BaseError("Invalid recovery address format") + } + /** Step 1: Create a mapping between the MSCA address and the recovery address */ + val owners: Array = arrayOf(EoaAddressMappingOwner(EOAIdentifier(recoveryAddress))) + try { + createAddressMapping(account.getAddress(), owners) + } catch (error: InvalidParamsRpcError){ + /** + * Ignore "address mapping already exists" errors to ensure idempotency and allow safe retries. + * This prevents inconsistent states between RPC calls and onchain transactions. + */ + if(!isMappedError(error)){ + throw BaseError("Failed to register the recovery address. Please try again.") + } + } + + /** Step 2: Encode the function call for the userOp */ + val addOwnersData = encodeFunctionData( + "addOwners", + CIRCLE_PLUGIN_ADD_OWNERS_ABI, + arrayOf( + arrayOf(recoveryAddress), // recovery address + arrayOf(OWNER_WEIGHT), // weightsToAdd + emptyArray(), // publicKeyOwnersToAdd + emptyArray(), // publicKeyWeightsToAdd + 0, // newThresholdWeight, 0 means no change + ) + ) + + /** Step 3: Send user operation to store the recovery address onchain */ + try { + partialUserOp.callData = addOwnersData + return sendUserOperation( + context, + account, + calls = null,// Set to null since callData is assigned directly. + partialUserOp, + paymaster, + estimateFeesPerGas + ) + } catch (error: UserOperationExecutionError){ + if(error.details == ExecutionRevertedError.message){ + val isOwner = UtilApiImpl.isOwnerOf(transport, account.getAddress(), recoveryAddress) + if(isOwner) return null + } + throw error + } + } + /** * Prepares a User Operation for execution and fills in missing properties. * 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 index 8ba1f03..2ea464b 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/constants/AbiConstants.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/constants/AbiConstants.kt @@ -70,7 +70,7 @@ val CIRCLE_PLUGIN_ADD_OWNERS_ABI = """ }, { "internalType": "uint256[]", - "name": "pubicKeyWeightsToAdd", + "name": "publicKeyWeightsToAdd", "type": "uint256[]" }, { 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 index 9384208..3721bcf 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/constants/MscaConstants.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/constants/MscaConstants.kt @@ -22,7 +22,9 @@ 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) +// Lowered to 100,000 due to pre-op gas limit efficiency issues on multiple testnets. +// See CCS-1984: https://circlepay.atlassian.net/browse/CCS-1984 +val MINIMUM_VERIFICATION_GAS_LIMIT = BigInteger.valueOf(100_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) @@ -58,8 +60,8 @@ 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 default owner weight. */ +val OWNER_WEIGHT = 1L /** The threshold weight. */ val THRESHOLD_WEIGHT = 1L @@ -68,4 +70,11 @@ val STUB_SIGNATURE = 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 +) + +/* Signature flag */ +internal const val SIG_TYPE_FLAG_DIGEST = 32L +/* Base type */ +internal const val SIG_TYPE_SECP256R1 = 2L +/* Digest-flagged type */ +internal const val SIG_TYPE_SECP256R1_DIGEST = 34L \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/error/CircleErrorUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/error/CircleErrorUtils.kt new file mode 100644 index 0000000..761cb39 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/error/CircleErrorUtils.kt @@ -0,0 +1,44 @@ +/* + * 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.InvalidParamsRpcError + +/** + * Error description string for a known backend RPC error. + * + * Refer to backend error definition: + * ExtStatusCodeConflictWalletIdentifierMapping + * + * This string is used to detect cases where the wallet-to-identifier mapping + * already exists and should be optionally ignored by clients. + */ +const val ERROR_DESC_CONFLICT_WALLET_IDENTIFIER_MAPPING = + "The wallet to identifier map already exists" + +/** + * Determines whether the given error represents an already-existing wallet-to-identifier mapping. + * Matches error detail string based on backend-defined message. + */ +internal fun isMappedError(err: Exception): Boolean { + if (err is InvalidParamsRpcError) { + return err.details?.contains(ERROR_DESC_CONFLICT_WALLET_IDENTIFIER_MAPPING, ignoreCase = true) == true + } + return false +} \ No newline at end of file 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 index 2ee1bbc..b3759da 100644 --- 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 @@ -27,13 +27,16 @@ import org.bouncycastle.asn1.ASN1Integer import org.bouncycastle.asn1.DERSequenceGenerator import org.bouncycastle.asn1.DLSequence import org.web3j.crypto.ECDSASignature +import org.web3j.crypto.Sign +import org.web3j.utils.Numeric 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 = + BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16) internal val P256_N_DIV_2 = P256_N.shiftRight(1) internal fun adjustSignature(r: BigInteger, s: BigInteger): Pair { @@ -103,3 +106,52 @@ internal fun numberToBytesBE(n: BigInteger, len: Int): ByteArray { val hex = n.toString(16).padStart(len * 2, '0') return hexStringToByteArray(hex) } + +fun deserializeSignature(signature: String): Sign.SignatureData { + try { + val signatureBytes = hexStringToByteArray(signature) + + if (signatureBytes.size != 65) { + throw BaseError("Invalid signature length: ${signatureBytes.size}. Expected 65 bytes.") + } + + val r = ByteArray(32) + val s = ByteArray(32) + val v = ByteArray(1) + + System.arraycopy(signatureBytes, 0, r, 0, 32) + System.arraycopy(signatureBytes, 32, s, 0, 32) + System.arraycopy(signatureBytes, 64, v, 0, 1) + + return Sign.SignatureData(v, r, s) + + } catch (e: NumberFormatException) { + throw BaseError("Invalid hex string for signature: ${e.message}", BaseErrorParameters(e)) + } catch (e: IllegalArgumentException) { + throw BaseError( + "Invalid input for signature deserialization: ${e.message}", + BaseErrorParameters(e) + ) + } +} + + +fun serializeSignature(signatureData: Sign.SignatureData): String { + val rBytes = signatureData.r + val sBytes = signatureData.s + + // Ensure r and s are 32 bytes long by padding + val paddedR = ByteArray(32) + val paddedS = ByteArray(32) + + System.arraycopy(rBytes, 0, paddedR, 32 - rBytes.size, rBytes.size) + System.arraycopy(sBytes, 0, paddedS, 32 - sBytes.size, sBytes.size) + + val signature = ByteArray(65) + System.arraycopy(paddedR, 0, signature, 0, 32) + System.arraycopy(paddedS, 0, signature, 32, 32) + System.arraycopy(signatureData.v, 0, signature, 64, 1) + + // Convert to hex string + return Numeric.toHexString(signature) +} \ No newline at end of file