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 d5deeaf..a8bb9b8 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 @@ -44,7 +44,7 @@ import com.circle.modularwallets.core.utils.NonceManagerSource import com.circle.modularwallets.core.utils.abi.encodeCallData import com.circle.modularwallets.core.utils.signature.hashMessage import com.circle.modularwallets.core.utils.signature.hashTypedData -import com.circle.modularwallets.core.utils.smartAccount.getMinimumVerificationGasLimit +import com.circle.modularwallets.core.utils.smartAccount.getDefaultVerificationGasLimit import com.circle.modularwallets.core.utils.userOperation.getUserOperationHash import com.circle.modularwallets.core.utils.userOperation.parseFactoryAddressAndData import org.web3j.utils.Numeric @@ -167,8 +167,7 @@ class CircleSmartAccount( */ override var userOperation: UserOperationConfiguration? = UserOperationConfiguration { userOperation -> - val minimumVerificationGasLimit = - getMinimumVerificationGasLimit(isDeployed(), client.chain.chainId) + val minimumVerificationGasLimit = getDefaultVerificationGasLimit(isDeployed(), client.transport) EstimateUserOperationGasResult( verificationGasLimit = minimumVerificationGasLimit .max(userOperation.verificationGasLimit ?: BigInteger.ZERO) 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 index 9963c01..7fb31ca 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnCredential.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/accounts/WebAuthnCredential.kt @@ -45,7 +45,6 @@ import com.circle.modularwallets.core.utils.webauthn.getSavedCredentials * Throws: BaseError if userName is null for WebAuthnMode.Register. * */ - @ExcludeFromGeneratedCCReport @Throws(Exception::class) @JvmOverloads 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 index 757b4b4..1b8622a 100644 --- 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 @@ -19,7 +19,8 @@ package com.circle.modularwallets.core.apis.modular import com.circle.modularwallets.core.models.AddressMappingOwner -import com.circle.modularwallets.core.models.CreateAddressMappingResult +import com.circle.modularwallets.core.models.AddressMappingResult +import com.circle.modularwallets.core.models.GetUserOperationGasPriceResult import com.circle.modularwallets.core.transports.Transport internal interface ModularApi { @@ -28,6 +29,20 @@ internal interface ModularApi { transport: Transport, walletAddress: String, owners: Array - ): Array + ): Array + suspend fun getAddressMapping( + transport: Transport, + owner: AddressMappingOwner + ): Array + + /** + * Gets the gas price options for a user operation. + * + * @param transport The transport to use for the request. + * @return The gas price options with low, medium, high tiers and optional verificationGasLimit. + */ + suspend fun getUserOperationGasPrice( + transport: Transport + ): GetUserOperationGasPriceResult } \ 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 index ebacb8a..102075e 100644 --- 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 @@ -20,8 +20,9 @@ package com.circle.modularwallets.core.apis.modular import com.circle.modularwallets.core.errors.BaseError import com.circle.modularwallets.core.models.AddressMappingOwner -import com.circle.modularwallets.core.models.CreateAddressMappingResult +import com.circle.modularwallets.core.models.AddressMappingResult import com.circle.modularwallets.core.models.EoaAddressMappingOwner +import com.circle.modularwallets.core.models.GetUserOperationGasPriceResult import com.circle.modularwallets.core.models.WebAuthnAddressMappingOwner import com.circle.modularwallets.core.transports.RpcRequest import com.circle.modularwallets.core.transports.Transport @@ -43,7 +44,7 @@ internal object ModularApiImpl : ModularApi { transport: Transport, walletAddress: String, owners: Array - ): Array { + ): Array { if (!isAddress(walletAddress)) { throw BaseError("walletAddress is invalid") } @@ -75,10 +76,50 @@ internal object ModularApiImpl : ModularApi { listOf(CreateAddressMappingReq(walletAddress, owners)) ) val rawList = performJsonRpcRequest(transport, req) as ArrayList<*> - val result: Array = rawList.mapNotNull { item -> - resultToTypeAndJson(item, CreateAddressMappingResult::class.java).first - }.toTypedArray() + val result: Array = processAddressMappingResponse(rawList) return result } + + override suspend fun getAddressMapping( + transport: Transport, + owner: AddressMappingOwner + ): Array { + val req = RpcRequest( + "circle_getAddressMapping", + listOf(GetAddressMappingReq(owner)) + ) + val rawList = performJsonRpcRequest(transport, req) as ArrayList<*> + val result: Array = processAddressMappingResponse(rawList) + return result + } + + private fun processAddressMappingResponse(rawList: ArrayList<*>): + Array { + return rawList.mapNotNull { item -> + resultToTypeAndJson(item, AddressMappingResult::class.java).first + }.toTypedArray() + } + /** + * Gets the gas price options for a user operation. + * + * @param transport The transport to use for the request. + * @return The gas price options with low, medium, high tiers and optional verificationGasLimit. + */ + override suspend fun getUserOperationGasPrice( + transport: Transport, + ): GetUserOperationGasPriceResult { + + val req = RpcRequest( + "circle_getUserOperationGasPrice" + ) + + val result = performJsonRpcRequest( + transport, + req, + GetUserOperationGasPriceResult::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 index 54a1196..53ed25d 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 @@ -148,3 +148,8 @@ internal data class CreateAddressMappingReq( @Json(name = "walletAddress") val walletAddress: String, @Json(name = "owners") val owners: Array, ) + +@JsonClass(generateAdapter = true) +internal data class GetAddressMappingReq( + @Json(name = "owner") val owner: AddressMappingOwner, +) 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 79cab96..4b15ffd 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 @@ -68,4 +68,11 @@ internal interface UtilApi { account: String, ownerToCheck: String, ): Boolean + + suspend fun isOwnerOf( + transport: Transport, + account: String, + ownerXToCheck: String, + ownerYToCheck: 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 0ab954f..72d24fe 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 @@ -36,6 +36,7 @@ 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.StaticStruct import org.web3j.abi.datatypes.Type import org.web3j.abi.datatypes.generated.Bytes32 import org.web3j.abi.datatypes.generated.Bytes4 @@ -187,6 +188,48 @@ internal object UtilApiImpl : UtilApi { } return decoded[0].value as Boolean } + @ExcludeFromGeneratedCCReport + override suspend fun isOwnerOf( + transport: Transport, + account: String, + xOfOwnerToCheck: String, + yOfOwnerToCheck: String, + ): Boolean { + val publicKeys = StaticStruct( + Uint256(BigInteger(xOfOwnerToCheck)), + Uint256(BigInteger(yOfOwnerToCheck)) + ) + val function = Function( + "isOwnerOf", + listOf>( + Address(account), + publicKeys + ), + listOf>( + object : TypeReference() {}) + ) + val data = FunctionEncoder.encode(function) + Logger.d( + msg = """ + isOwnerOf > call + Account: $account + xOfOwnerToCheck: $xOfOwnerToCheck + yOfOwnerToCheck: $yOfOwnerToCheck + """.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, 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 c368cb5..bfed527 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 @@ -20,6 +20,7 @@ package com.circle.modularwallets.core.clients import android.content.Context import com.circle.modularwallets.core.accounts.SmartAccount +import com.circle.modularwallets.core.accounts.WebAuthnCredential import com.circle.modularwallets.core.annotation.ExcludeFromGeneratedCCReport import com.circle.modularwallets.core.apis.bundler.BundlerApi import com.circle.modularwallets.core.apis.bundler.BundlerApiImpl @@ -34,28 +35,36 @@ 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.BaseErrorParameters 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.AddressMappingResult 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.GetUserOperationGasPriceResult 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.WebAuthnAddressMappingOwner +import com.circle.modularwallets.core.models.WebAuthnIdentifier 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.getAddOwnersData 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 com.circle.modularwallets.core.utils.signature.parseP256Signature +import org.web3j.abi.datatypes.StaticStruct +import org.web3j.abi.datatypes.generated.Uint256 import java.math.BigInteger class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transport) { @@ -72,7 +81,6 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor * @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( @@ -97,6 +105,83 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor return api.estimateUserOperationGas(transport, userOp, account.entryPoint) } + /** + * Estimates the gas required to execute and finalize the recovery process. + * + * @param account The Account to use for User Operation execution. + * @param credential The newly registered passkey credential. + * @param partialUserOp A partially constructed UserOperation object that can include custom gas parameters. + * The `callData` field, if provided, will be **overwritten internally** + * with the encoded `addOwners` call data based on `credential`. + * @param paymaster Sets Paymaster configuration for the User Operation. + * @param estimateFeesPerGas Prepares fee properties for the User Operation request. Not available in Java. + + * @return An estimate of gas values necessary to execute recovery. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + @JvmOverloads + suspend fun estimateExecuteRecoveryGas( + account: SmartAccount, + credential: WebAuthnCredential, + partialUserOp: UserOperationV07 = UserOperationV07(), + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): EstimateUserOperationGasResult { + val addOwnersData = getAddOwnersData(credential) + partialUserOp.callData = addOwnersData + val userOp = api.prepareUserOperation( + transport, + account, + calls = null, + partialUserOp, + paymaster, + this, + estimateFeesPerGas + ) + return api.estimateUserOperationGas(transport, userOp, account.entryPoint) + } + + /** + * Estimates the gas required to register a recovery address during the recovery process. + * + * @param account The Account to use for User Operation execution. + * @param recoveryAddress The derived address of the recovery key. + * @param partialUserOp A partially constructed UserOperation object that can include custom gas parameters. + * 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 An estimate of gas values necessary to register a recovery address. + */ + @ExcludeFromGeneratedCCReport + @Throws(Exception::class) + @JvmOverloads + suspend fun estimateRegisterRecoveryAddressGas( + account: SmartAccount, + recoveryAddress: String, + partialUserOp: UserOperationV07 = UserOperationV07(), + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): EstimateUserOperationGasResult { + if (!isAddress(recoveryAddress)) { + throw BaseError("Invalid recovery address format") + } + val addOwnersData = getAddOwnersData(recoveryAddress) + partialUserOp.callData = addOwnersData + val userOp = api.prepareUserOperation( + transport, + account, + calls = null, + partialUserOp, + paymaster, + this, + estimateFeesPerGas + ) + return api.estimateUserOperationGas(transport, userOp, account.entryPoint) + } + /** * Returns the chain ID associated with the current network * @@ -218,35 +303,100 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor paymaster: Paymaster? = null, estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null ): String? { - if(!isAddress(recoveryAddress)){ + 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))) + val owners: Array = + arrayOf(EoaAddressMappingOwner(EOAIdentifier(recoveryAddress))) try { createAddressMapping(account.getAddress(), owners) - } catch (error: InvalidParamsRpcError){ + } 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.") + if (!isMappedError(error)) { + throw BaseError("Failed to register the recovery address. Please try again.", BaseErrorParameters(error)) } } /** 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 + val addOwnersData = getAddOwnersData(recoveryAddress) + + /** 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 + } + } + + /** + * Executes and finalizes 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 credential The newly registered passkey credential. + * @param partialUserOp A partially constructed UserOperation object. + * The `callData` field, if provided, will be **overwritten internally** + * with the encoded `addOwners` call data based on `credential`. + * @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 executeRecovery( + context: Context, + account: SmartAccount, + credential: WebAuthnCredential, + partialUserOp: UserOperationV07 = UserOperationV07(), + paymaster: Paymaster? = null, + estimateFeesPerGas: (suspend (SmartAccount, BundlerClient, UserOperationV07) -> EstimateFeesPerGasResult)? = null + ): String? { + if (credential.publicKey.isEmpty()) { + throw BaseError("WebAuthn credential has missing public key") + } + val (x, y) = try { + parseP256Signature(credential.publicKey) + } catch (e: Exception) { + throw BaseError("Invalid public key: failed to parse P256 signature", BaseErrorParameters(e)) + } + /** Step 1: Create a mapping between the MSCA address and the WebAuthn credential */ + val owners: Array = arrayOf( + WebAuthnAddressMappingOwner( + WebAuthnIdentifier(x.toString(), y.toString()) ) ) + 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.", BaseErrorParameters(error)) + } + } + + /** Step 2: Encode the function call for the userOp */ + val addOwnersData = getAddOwnersData(credential) /** Step 3: Send user operation to store the recovery address onchain */ try { @@ -259,10 +409,11 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor paymaster, estimateFeesPerGas ) - } catch (error: UserOperationExecutionError){ - if(error.details == ExecutionRevertedError.message){ - val isOwner = UtilApiImpl.isOwnerOf(transport, account.getAddress(), recoveryAddress) - if(isOwner) return null + } catch (error: UserOperationExecutionError) { + if (error.details == ExecutionRevertedError.message) { + val isOwner = + UtilApiImpl.isOwnerOf(transport, account.getAddress(), x.toString(), y.toString()) + if (isOwner) return null } throw error } @@ -489,7 +640,32 @@ class BundlerClient(chain: Chain, transport: Transport) : Client(chain, transpor suspend fun createAddressMapping( walletAddress: String, owners: Array - ): Array { + ): Array { return ModularApiImpl.createAddressMapping(transport, walletAddress, owners) } + + /** + * Gets the address mapping for a given owner. + * + * @param owner The owner information. + * @return An array of address mappings associated with the given owner. + */ + @Throws(Exception::class) + @JvmOverloads + suspend fun getAddressMapping( + owner: AddressMappingOwner + ): Array { + return ModularApiImpl.getAddressMapping(transport, owner) + } + + /** + * Gets the user operation gas price. + * + * @return The user operation gas price. See [GetUserOperationGasPriceResult]. + */ + @Throws(Exception::class) + @JvmOverloads + suspend fun getUserOperationGasPrice(): GetUserOperationGasPriceResult { + return ModularApiImpl.getUserOperationGasPrice(transport) + } } \ 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 index 3721bcf..2e4fc04 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 @@ -26,11 +26,6 @@ import java.math.BigInteger // 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) -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 { diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/OwnerModels.kt b/lib/src/main/java/com/circle/modularwallets/core/models/OwnerModels.kt index a2ea5e4..36136ad 100644 --- a/lib/src/main/java/com/circle/modularwallets/core/models/OwnerModels.kt +++ b/lib/src/main/java/com/circle/modularwallets/core/models/OwnerModels.kt @@ -79,7 +79,7 @@ data class WebAuthnAddressMappingOwner( * The response from adding an address mapping. */ @JsonClass(generateAdapter = true) -data class CreateAddressMappingResult( +data class AddressMappingResult( /** * The mapping ID. */ diff --git a/lib/src/main/java/com/circle/modularwallets/core/models/UserOperationGasPrice.kt b/lib/src/main/java/com/circle/modularwallets/core/models/UserOperationGasPrice.kt new file mode 100644 index 0000000..a7f1e03 --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/models/UserOperationGasPrice.kt @@ -0,0 +1,70 @@ +/* + * 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 + +/** + * The get user operation gas price response. + */ +@JsonClass(generateAdapter = true) +data class GetUserOperationGasPriceResult @JvmOverloads constructor( + /** + * The low gas price option. + */ + @Json(name = "low") val low: GasPriceOption, + + /** + * The medium gas price option. + */ + @Json(name = "medium") val medium: GasPriceOption, + + /** + * The high gas price option. + */ + @Json(name = "high") val high: GasPriceOption, + + /** + * The gas limit for deployed accounts. + */ + @Json(name = "deployed") val deployed: BigInteger? = null, + + /** + * The gas limit for not deployed accounts. + */ + @Json(name = "notDeployed") val notDeployed: BigInteger? = null +) + +/** + * The gas price option. + */ +@JsonClass(generateAdapter = true) +data class GasPriceOption @JvmOverloads constructor( + /** + * The maximum fee per gas. + */ + @Json(name = "maxFeePerGas") val maxFeePerGas: BigInteger, + + /** + * The maximum priority fee per gas. + */ + @Json(name = "maxPriorityFeePerGas") val maxPriorityFeePerGas: BigInteger +) \ 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 index 0a5eba5..57f687f 100644 --- 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 @@ -18,12 +18,15 @@ package com.circle.modularwallets.core.utils.abi +import com.circle.modularwallets.core.accounts.WebAuthnCredential +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.BaseErrorParameters +import com.circle.modularwallets.core.utils.signature.parseP256Signature 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 @@ -182,6 +185,7 @@ internal fun encodeFunctionData( 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) @@ -237,7 +241,6 @@ private fun inputFormat( } - type.startsWith("tuple") -> { getTuple(params[i]) } @@ -257,7 +260,8 @@ internal fun getTuple(param: Any): Type<*> { is DynamicStruct -> param is List<*> -> { val tupleElements = param.filterIsInstance>() - val hasDynamicType = param.any { it is DynamicBytes || it is Utf8String || it is DynamicArray<*> } + val hasDynamicType = + param.any { it is DynamicBytes || it is Utf8String || it is DynamicArray<*> } if (hasDynamicType) { DynamicStruct(tupleElements) } else { @@ -268,7 +272,8 @@ internal fun getTuple(param: Any): Type<*> { 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<*> } + val hasDynamicType = + tupleElements.any { it is DynamicBytes || it is Utf8String || it is DynamicArray<*> } if (hasDynamicType) { DynamicStruct(tupleElements) } else { @@ -761,3 +766,64 @@ private fun getAbiDefinition(name: String, contractAbi: String?): AbiDefinition? return result } + +internal fun getAddOwnersData( + credential: WebAuthnCredential +): String { + if (credential.publicKey.isEmpty()) { + throw BaseError("WebAuthn credential has missing public key") + } + val (x, y) = try { + parseP256Signature(credential.publicKey) + } catch (e: Exception) { + throw BaseError("Invalid public key: failed to parse P256 signature", BaseErrorParameters(e)) + } + val publicKeys = StaticStruct( + Uint256(BigInteger(x.toString())), + Uint256(BigInteger(y.toString())) + ) + return getAddOwnersData( + emptyArray(), + emptyArray(), + arrayOf( + publicKeys + ), + arrayOf(OWNER_WEIGHT) + ) +} + +internal fun getAddOwnersData( + ownerToAdd: String +): String { + return getAddOwnersData( + arrayOf(ownerToAdd), + arrayOf(OWNER_WEIGHT), + emptyArray(), + emptyArray(), + ) +} + +internal fun getAddOwnersData( + ownersToAdd: Array, + weightsToAdd: Array, + publicKeyOwnersToAdd: Array, + publicKeyWeightsToAdd: Array, +): String { + if (ownersToAdd.isNotEmpty() && ownersToAdd.size != weightsToAdd.size) { + throw BaseError("Number of owners must match the number of weights") + } + if (publicKeyOwnersToAdd.isNotEmpty() && publicKeyOwnersToAdd.size != publicKeyWeightsToAdd.size) { + throw BaseError("Number of public key owners must match the number of weights") + } + return encodeFunctionData( + "addOwners", + CIRCLE_PLUGIN_ADD_OWNERS_ABI, + arrayOf( + ownersToAdd, + weightsToAdd, + publicKeyOwnersToAdd, + publicKeyWeightsToAdd, + 0, // newThresholdWeight, 0 means no change + ) + ) +} \ No newline at end of file diff --git a/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetDefaultVerificationGasLimitUtils.kt b/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetDefaultVerificationGasLimitUtils.kt new file mode 100644 index 0000000..d459d5d --- /dev/null +++ b/lib/src/main/java/com/circle/modularwallets/core/utils/smartAccount/GetDefaultVerificationGasLimitUtils.kt @@ -0,0 +1,54 @@ +/* + * 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.apis.modular.ModularApiImpl +import com.circle.modularwallets.core.constants.MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT +import com.circle.modularwallets.core.constants.MINIMUM_VERIFICATION_GAS_LIMIT +import java.math.BigInteger +import com.circle.modularwallets.core.transports.Transport +import com.circle.modularwallets.core.models.GetUserOperationGasPriceResult + +/** + * Gets the minimum verification gas limit for a given chain. + * + * @param deployed Whether the smart account is deployed. + * @param transport The transport to use for RPC calls. + * @return The minimum verification gas limit, using backend value if available, otherwise fallback to hardcoded value. + */ +suspend fun getDefaultVerificationGasLimit( + deployed: Boolean, + transport: Transport +): BigInteger { + return try { + val result: GetUserOperationGasPriceResult = ModularApiImpl.getUserOperationGasPrice(transport) + if (deployed) { + result.deployed ?: fallbackGasLimit(deployed) + } else { + result.notDeployed ?: fallbackGasLimit(deployed) + } + } catch (e: Exception) { + fallbackGasLimit(deployed) + } +} + +private fun fallbackGasLimit(deployed: Boolean): BigInteger { + return if (deployed) MINIMUM_VERIFICATION_GAS_LIMIT else MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT +}