diff --git a/backend/CHANGELOG.md b/backend/CHANGELOG.md index 32e999d..1fa9055 100644 --- a/backend/CHANGELOG.md +++ b/backend/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +## [4.2.3] - 2026-04-05 +- Added endpoints to withdraw stake and deposit. + ## [4.2.2] - 2025-12-09 - Default all the apiKey which would be saved hereafter and update the supportedNetworks to null to make the system only use config.json as default - skips the getDeposit call from cronJob if the network is testnet diff --git a/backend/package.json b/backend/package.json index aee5140..f4132ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "arka", - "version": "4.2.2", + "version": "4.2.3", "description": "ARKA - (Albanian for Cashier's case) is the first open source Paymaster as a service software", "type": "module", "directories": { diff --git a/backend/src/constants/ErrorMessage.ts b/backend/src/constants/ErrorMessage.ts index 081490f..8da84bb 100644 --- a/backend/src/constants/ErrorMessage.ts +++ b/backend/src/constants/ErrorMessage.ts @@ -53,6 +53,9 @@ export default { VP_ALREADY_DEPLOYED: 'Verifying paymaster already deployed', FAILED_TO_DEPLOY_VP: 'Failed to deploy verifying paymaster', FAILED_TO_ADD_STAKE: 'Failed to add stake', + INVALID_WITHDRAW_ADDRESS: 'Invalid withdraw address', + FAILED_TO_WITHDRAW_STAKE: 'Failed to withdraw stake', + FAILED_TO_WITHDRAW_DEPOSIT: 'Failed to withdraw deposit', INVALID_AMOUNT_TO_STAKE: 'Invalid amount to stake', NO_KEY_SET: 'No MTP key set', MULTI_NOT_DEPLOYED: 'Token Paymaster not deployed on the current chainID: ', diff --git a/backend/src/paymaster/index.ts b/backend/src/paymaster/index.ts index cd06675..6ccdb61 100644 --- a/backend/src/paymaster/index.ts +++ b/backend/src/paymaster/index.ts @@ -1255,6 +1255,67 @@ export class Paymaster { } } + async withdrawDeposit( + withdrawAddress: string, + amount: string, + paymasterAddress: string, + bundlerRpc: string, + relayerKey: string, + chainId: number, + log?: FastifyBaseLogger + ) { + try { + const viemChain = getViemChainDef(chainId); + const publicClient = createPublicClient({ chain: viemChain, transport: http(bundlerRpc) }); + const walletClient = createWalletClient({ chain: viemChain, transport: http(bundlerRpc), account: privateKeyToAccount(relayerKey as Hex) }); + const amountInWei = parseEther(amount.toString()); + const encodedData = encodeFunctionData({ + abi: parseAbi(['function withdrawTo(address withdrawAddress, uint256 amount)']), + functionName: 'withdrawTo', + args: [withdrawAddress as Address, amountInWei] + }); + + const etherscanFeeData = await getGasFee(chainId, bundlerRpc, log); + const feeData = { gasPrice: BigInt(0), maxFeePerGas: BigInt(0), maxPriorityFeePerGas: BigInt(0) }; + if (etherscanFeeData) { + const response = etherscanFeeData; + feeData.gasPrice = response.gasPrice ? response.gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxFeePerGas = response.maxFeePerGas ? response.maxFeePerGas + this.feeMarkUp : BigInt(0); + feeData.maxPriorityFeePerGas = response.maxPriorityFeePerGas ? response.maxPriorityFeePerGas + this.feeMarkUp : BigInt(0); + } else { + const gasPrice = await publicClient.getGasPrice(); + feeData.gasPrice = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxFeePerGas = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxPriorityFeePerGas = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + } + + let tx; + if (!feeData.maxFeePerGas || this.skipType2Txns.includes(chainId.toString()) || feeData.maxFeePerGas === BigInt(0)) { + tx = await walletClient.sendTransaction({ + to: paymasterAddress as Address, + data: encodedData, + gasPrice: feeData.gasPrice ?? undefined, + type: 'legacy', + } as TransactionRequest); + } else { + tx = await walletClient.sendTransaction({ + to: paymasterAddress as Address, + data: encodedData, + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + type: 'eip1559', + }); + } + + return { + message: `Successfully withdrew deposit with transaction Hash ${tx}` + }; + } catch (error) { + log?.error(`error while withdrawing deposit from paymaster ${error}`); + throw new Error(ErrorMessage.FAILED_TO_WITHDRAW_DEPOSIT); + } + } + async deployVp( privateKey: string, bundlerRpcUrl: string, @@ -1387,6 +1448,63 @@ export class Paymaster { } } + async withdrawStake( + privateKey: string, + bundlerRpcUrl: string, + withdrawAddress: string, + paymasterAddress: string, + chainId: number, + log?: FastifyBaseLogger + ) { + try { + const viemChain = getViemChainDef(chainId) + const publicClient = createPublicClient({ chain: viemChain, transport: http(bundlerRpcUrl) }); + const walletClient = createWalletClient({ chain: viemChain, transport: http(bundlerRpcUrl), account: privateKeyToAccount(privateKey as Hex) }); + + const etherscanFeeData = await getGasFee(chainId, bundlerRpcUrl, log); + const feeData = { gasPrice: BigInt(0), maxFeePerGas: BigInt(0), maxPriorityFeePerGas: BigInt(0) }; + if (etherscanFeeData) { + const response = etherscanFeeData; + feeData.gasPrice = response.gasPrice ? response.gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxFeePerGas = response.maxFeePerGas ? response.maxFeePerGas + this.feeMarkUp : BigInt(0); + feeData.maxPriorityFeePerGas = response.maxPriorityFeePerGas ? response.maxPriorityFeePerGas + this.feeMarkUp : BigInt(0); + } else { + const gasPrice = await publicClient.getGasPrice(); + feeData.gasPrice = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxFeePerGas = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + feeData.maxPriorityFeePerGas = gasPrice ? gasPrice + this.feeMarkUp : BigInt(0); + } + + let tx; + if (!feeData.maxFeePerGas || this.skipType2Txns.includes(chainId.toString())) { + tx = await walletClient.writeContract({ + address: paymasterAddress as Address, + abi: verifyingPaymasterAbi, + functionName: 'withdrawStake', + args: [withdrawAddress], + type: "legacy", + gasPrice: feeData.gasPrice ?? undefined, + }); + } else { + tx = await walletClient.writeContract({ + address: paymasterAddress as Address, + abi: verifyingPaymasterAbi, + functionName: 'withdrawStake', + args: [withdrawAddress], + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + type: "eip1559" + }); + } + return { + message: `Successfully withdrew stake with transaction Hash ${tx}` + }; + } catch (error) { + log?.error(`error while withdrawing stake from verifying paymaster ${error}`); + throw new Error(ErrorMessage.FAILED_TO_WITHDRAW_STAKE); + } + } + async getPriceFromCoingecko(chainId: number, tokenAddress: string, ETHUSDPrice: any, ETHUSDPriceDecimal: any, log?: FastifyBaseLogger): Promise { const cacheKey = `${chainId}-${tokenAddress}`; const cache = this.coingeckoPrice.get(cacheKey); diff --git a/backend/src/routes/admin-routes.ts b/backend/src/routes/admin-routes.ts index 4f8bc83..d303722 100644 --- a/backend/src/routes/admin-routes.ts +++ b/backend/src/routes/admin-routes.ts @@ -7,7 +7,8 @@ import { http, getAddress, parseEther, - getContract + getContract, + isAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { ethers } from "ethers"; @@ -607,6 +608,109 @@ const adminRoutes: FastifyPluginAsync = async (server) => { return reply.code(ReturnCode.FAILURE).send({ error: error.message ?? ErrorMessage.FAILED_TO_PROCESS }); } }); + + server.post('/withdrawStake', async (request, reply) => { + try { + if (!request.body) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.MISSING_PARAMS }); + + const body: any = request.body; + const query: any = request.query; + const chainId = query['chainId']; + const apiKey = query['apiKey']; + const epVersion = body.params?.[0]; + const withdrawAddress = body.params?.[1]; + + if (!chainId || isNaN(chainId) || !apiKey) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_DATA }); + } + + if(!withdrawAddress || !isAddress(withdrawAddress)) { + return reply.code(ReturnCode.FAILURE).send({error: ErrorMessage.INVALID_WITHDRAW_ADDRESS}); + } + + if (!epVersion || (epVersion !== EPVersions.EPV_06 && epVersion !== EPVersions.EPV_07 && epVersion !== EPVersions.EPV_08)) { + return reply.code(ReturnCode.FAILURE).send({error: ErrorMessage.INVALID_EP_VERSION}); + } + + const apiKeyEntity: APIKey | null = await server.apiKeyRepository.findOneByApiKey(apiKey); + if (!apiKeyEntity) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }); + + let verifyingPaymasters, supportedEPs; + + if(epVersion === EPVersions.EPV_06) { + verifyingPaymasters = apiKeyEntity.verifyingPaymasters ? JSON.parse(apiKeyEntity.verifyingPaymasters) : {}; + supportedEPs = SUPPORTED_ENTRYPOINTS.EPV_06; + } else if (epVersion === EPVersions.EPV_07) { + verifyingPaymasters = apiKeyEntity.verifyingPaymastersV2 ? JSON.parse(apiKeyEntity.verifyingPaymastersV2) : {}; + supportedEPs = SUPPORTED_ENTRYPOINTS.EPV_07; + } else { + verifyingPaymasters = apiKeyEntity.verifyingPaymastersV3 ? JSON.parse(apiKeyEntity.verifyingPaymastersV3) : {}; + supportedEPs = SUPPORTED_ENTRYPOINTS.EPV_08; + } + + if (!verifyingPaymasters[chainId]) { + return reply.code(ReturnCode.FAILURE).send( + {error: `${ErrorMessage.VP_NOT_DEPLOYED}`} + ); + } + + let privateKey; + let bundlerApiKey = apiKey; + let supportedNetworks; + + if (!unsafeMode) { + const AWSresponse = await client.send( + new GetSecretValueCommand({ + SecretId: prefixSecretId + apiKey, + }) + ); + const secrets = JSON.parse(AWSresponse.SecretString ?? '{}'); + if (!secrets['PRIVATE_KEY']) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }); + } + if (secrets['BUNDLER_API_KEY']) { + bundlerApiKey = secrets['BUNDLER_API_KEY']; + } + privateKey = secrets['PRIVATE_KEY']; + supportedNetworks = secrets['SUPPORTED_NETWORKS']; + } else { + privateKey = decode(apiKeyEntity.privateKey, server.config.HMAC_SECRET); + supportedNetworks = apiKeyEntity.supportedNetworks; + if (apiKeyEntity.bundlerApiKey) { + bundlerApiKey = apiKeyEntity.bundlerApiKey; + } + } + + if (server.config.SUPPORTED_NETWORKS == '' && !SupportedNetworks) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.UNSUPPORTED_NETWORK }); + } + const networkConfig = getNetworkConfig( + chainId, + supportedNetworks ?? '', + supportedEPs + ); + if (!networkConfig) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.UNSUPPORTED_NETWORK }); + } + let bundlerUrl = networkConfig.bundler; + if (networkConfig.bundler.includes('etherspot.io')) { + bundlerUrl = `${networkConfig.bundler}?api-key=${bundlerApiKey}`; + } + + const tx = await paymaster.withdrawStake( + privateKey, + bundlerUrl, + withdrawAddress, + verifyingPaymasters[chainId], + chainId, + server.log + ); + return reply.code(ReturnCode.SUCCESS).send(tx); + } catch (error: any) { + request.log.error(error); + return reply.code(ReturnCode.FAILURE).send({ error: error.message ?? ErrorMessage.FAILED_TO_PROCESS }); + } + }); }; export default adminRoutes; diff --git a/backend/src/routes/deposit-route.ts b/backend/src/routes/deposit-route.ts index 1d409f4..4a26aaa 100644 --- a/backend/src/routes/deposit-route.ts +++ b/backend/src/routes/deposit-route.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { isAddress } from "viem"; import { Paymaster } from "../paymaster/index.js"; import SupportedNetworks from "../../config.json"; import ErrorMessage from "../constants/ErrorMessage.js"; @@ -137,6 +138,97 @@ const depositRoutes: FastifyPluginAsync = async (server) => { } } + async function withdrawDeposit(request: FastifyRequest, reply: FastifyReply, epVersion: EPVersions) { + try { + const body: any = request.body; + if (!body) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.EMPTY_BODY }) + } + const query: any = request.query; + const withdrawAddress = body.withdrawAddress; + const withdrawAmount = body.withdrawAmount; + const useVp = query['useVp'] ?? false; + const chainId = query['chainId'] ?? body.params?.[1]; + const api_key = query['apiKey'] ?? body.params?.[2]; + + if (!withdrawAddress || !isAddress(withdrawAddress)) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_WITHDRAW_ADDRESS }); + } + if ( + withdrawAmount === undefined || + withdrawAmount === null || + withdrawAmount === '' || + isNaN(Number(withdrawAmount)) || + !chainId || + isNaN(chainId) + ) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_DATA }); + } + if (!api_key || typeof (api_key) !== "string") + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }) + + let privateKey = ''; + let bundlerApiKey = api_key; + const apiKeyEntity: APIKey | null = await server.apiKeyRepository.findOneByApiKey(api_key); + if (!apiKeyEntity) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }) + if (!unsafeMode) { + const AWSresponse = await client.send( + new GetSecretValueCommand({ + SecretId: prefixSecretId + api_key, + }) + ); + const secrets = JSON.parse(AWSresponse.SecretString ?? '{}'); + if (!secrets['PRIVATE_KEY']) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }) + privateKey = secrets['PRIVATE_KEY']; + } else { + privateKey = decode(apiKeyEntity.privateKey, server.config.HMAC_SECRET); + } + const supportedNetworks = apiKeyEntity.supportedNetworks; + if (apiKeyEntity.bundlerApiKey) { + bundlerApiKey = apiKeyEntity.bundlerApiKey; + } + if (server.config.SUPPORTED_NETWORKS == '' && !SupportedNetworks) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.UNSUPPORTED_NETWORK }); + } + + let networkConfig; + let vpAddr; + if (EPVersions.EPV_06 == epVersion) { + networkConfig = getNetworkConfig(chainId, supportedNetworks ?? '', SUPPORTED_ENTRYPOINTS.EPV_06); + vpAddr = apiKeyEntity.verifyingPaymasters ? + JSON.parse(apiKeyEntity.verifyingPaymasters)[chainId] : + undefined; + } else if (EPVersions.EPV_07 == epVersion) { + networkConfig = getNetworkConfig(chainId, supportedNetworks ?? '', SUPPORTED_ENTRYPOINTS.EPV_07); + vpAddr = apiKeyEntity.verifyingPaymastersV2 ? + JSON.parse(apiKeyEntity.verifyingPaymastersV2)[chainId] : + undefined; + } else if (EPVersions.EPV_08 == epVersion) { + networkConfig = getNetworkConfig(chainId, supportedNetworks ?? '', SUPPORTED_ENTRYPOINTS.EPV_08); + vpAddr = apiKeyEntity.verifyingPaymastersV3 ? + JSON.parse(apiKeyEntity.verifyingPaymastersV3)[chainId] : + undefined; + } + if (!networkConfig) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.UNSUPPORTED_NETWORK }); + let bundlerUrl = networkConfig.bundler; + if (networkConfig.bundler.includes('etherspot.io')) bundlerUrl = `${networkConfig.bundler}?api-key=${bundlerApiKey}`; + + if (!useVp) { + return await paymaster.withdrawDeposit(withdrawAddress, withdrawAmount, networkConfig.contracts.etherspotPaymasterAddress, bundlerUrl, privateKey, chainId, server.log); + } + if (!vpAddr) { + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.VP_NOT_DEPLOYED }) + } + + return await paymaster.withdrawDeposit(withdrawAddress, withdrawAmount, vpAddr, bundlerUrl, privateKey, chainId, server.log); + } catch (err: any) { + request.log.error(err); + if (err.name == "ResourceNotFoundException") + return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY }); + return reply.code(ReturnCode.FAILURE).send({ error: err.message ?? ErrorMessage.FAILED_TO_PROCESS }) + } + } + server.post("/deposit", ResponseSchema, async function (request, reply) { @@ -160,6 +252,30 @@ const depositRoutes: FastifyPluginAsync = async (server) => { return await deposit(request, reply, EPVersions.EPV_08); } ) + + server.post("/withdrawDeposit", + ResponseSchema, + async function (request, reply) { + printRequest("/withdrawDeposit", request, server.log); + return await withdrawDeposit(request, reply, EPVersions.EPV_06); + } + ) + + server.post("/withdrawDeposit/v2", + ResponseSchema, + async function (request, reply) { + printRequest("/withdrawDeposit/v2", request, server.log); + return await withdrawDeposit(request, reply, EPVersions.EPV_07); + } + ) + + server.post("/withdrawDeposit/v3", + ResponseSchema, + async function (request, reply) { + printRequest("/withdrawDeposit/v3", request, server.log); + return await withdrawDeposit(request, reply, EPVersions.EPV_08); + } + ) }; -export default depositRoutes; \ No newline at end of file +export default depositRoutes;