diff --git a/src/Managing.Api/Controllers/AccountController.cs b/src/Managing.Api/Controllers/AccountController.cs index be94f26..d5a553f 100644 --- a/src/Managing.Api/Controllers/AccountController.cs +++ b/src/Managing.Api/Controllers/AccountController.cs @@ -114,6 +114,28 @@ namespace Managing.Api.Controllers return Ok(result); } + /// + /// Sends tokens from a specific account to a recipient address. + /// + /// The name of the account to send tokens from. + /// The token sending request containing recipient address, ticker, and amount. + /// The transaction response with details. + [HttpPost] + [Route("{name}/send-token")] + public async Task> SendToken(string name, [FromBody] SendTokenRequest request) + { + var user = await GetUser(); + var result = await _AccountService.SendTokenAsync( + user, + name, + request.RecipientAddress, + request.Ticker, + request.Amount, + request.ChainId + ); + return Ok(result); + } + /// /// Deletes a specific account by name for the authenticated user. /// diff --git a/src/Managing.Api/Models/Requests/SendTokenRequest.cs b/src/Managing.Api/Models/Requests/SendTokenRequest.cs new file mode 100644 index 0000000..5ff5c12 --- /dev/null +++ b/src/Managing.Api/Models/Requests/SendTokenRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using static Managing.Common.Enums; + +namespace Managing.Api.Models.Requests; + +/// +/// Request model for token sending operations +/// +public class SendTokenRequest +{ + /// + /// The recipient's wallet address + /// + [Required] + public string RecipientAddress { get; set; } + + /// + /// The ticker symbol of the token to send + /// + [Required] + public Ticker Ticker { get; set; } + + /// + /// The amount to send + /// + [Required] + [Range(0.000001, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] + public decimal Amount { get; set; } + + /// + /// The chain ID where the transaction will be executed (optional, defaults to ARBITRUM) + /// + public int? ChainId { get; set; } +} \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IAccountService.cs b/src/Managing.Application.Abstractions/Services/IAccountService.cs index e71a083..39f84a3 100644 --- a/src/Managing.Application.Abstractions/Services/IAccountService.cs +++ b/src/Managing.Application.Abstractions/Services/IAccountService.cs @@ -18,4 +18,6 @@ public interface IAccountService Task SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5); + + Task SendTokenAsync(User user, string accountName, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs index cbfe73e..0a7b984 100644 --- a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs +++ b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs @@ -13,5 +13,7 @@ namespace Managing.Application.Abstractions.Services Task SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5); + + Task SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null); } } \ No newline at end of file diff --git a/src/Managing.Application/Accounts/AccountService.cs b/src/Managing.Application/Accounts/AccountService.cs index cb9416f..6040426 100644 --- a/src/Managing.Application/Accounts/AccountService.cs +++ b/src/Managing.Application/Accounts/AccountService.cs @@ -239,6 +239,55 @@ public class AccountService : IAccountService } } + public async Task SendTokenAsync(User user, string accountName, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null) + { + // Get the account for the user + var account = await GetAccountByUser(user, accountName, true, false); + + if (account == null) + { + throw new ArgumentException($"Account '{accountName}' not found for user '{user.Name}'"); + } + + // Ensure the account has a valid address/key + if (string.IsNullOrEmpty(account.Key)) + { + throw new ArgumentException($"Account '{accountName}' does not have a valid address"); + } + + // Validate recipient address + if (string.IsNullOrEmpty(recipientAddress)) + { + throw new ArgumentException("Recipient address is required"); + } + + // Validate amount + if (amount <= 0) + { + throw new ArgumentException("Amount must be greater than zero"); + } + + try + { + // Call the Web3ProxyService to send tokens + var swapInfos = await _web3ProxyService.SendTokenAsync( + account.Key, + recipientAddress, + ticker, + amount, + chainId + ); + + return swapInfos; + } + catch (Exception ex) when (!(ex is ArgumentException || ex is InvalidOperationException)) + { + _logger.LogError(ex, "Error sending tokens for account {AccountName} and user {UserName}", + accountName, user.Name); + throw new InvalidOperationException($"Failed to send tokens: {ex.Message}", ex); + } + } + private void ManageProperties(bool hideSecrets, bool getBalance, Account account) { if (account != null) diff --git a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs index b701cef..66b7a52 100644 --- a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs +++ b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs @@ -126,4 +126,16 @@ namespace Managing.Infrastructure.Evm.Models.Proxy Error = error; } } + + /// + /// Response for token sending operations + /// + public class Web3ProxyTokenSendResponse : Web3ProxyResponse + { + /// + /// Transaction hash if successful + /// + [JsonPropertyName("hash")] + public string Hash { get; set; } + } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index 48dbfee..0891c63 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -213,6 +213,36 @@ namespace Managing.Infrastructure.Evm.Services }; } + public async Task SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null) + { + var payload = new + { + senderAddress, + recipientAddress, + ticker = ticker.ToString(), + amount = amount.ToString(), // Convert decimal to string for bigint compatibility + chainId + }; + + var response = await CallPrivyServiceAsync("/send-token", payload); + + if (response == null) + { + throw new Web3ProxyException("Token send response is null"); + } + + // Map from infrastructure model to domain model + return new SwapInfos + { + Success = response.Success, + Hash = response.Hash, + Message = null, // Web3ProxyTokenSendResponse doesn't have Message property + Error = response.Error, + ErrorType = null, // Web3ProxyTokenSendResponse doesn't have ErrorType property + Suggestion = null // Web3ProxyTokenSendResponse doesn't have Suggestion property + }; + } + private async Task HandleErrorResponse(HttpResponseMessage response) { var statusCode = (int)response.StatusCode; diff --git a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts index 139d7a4..dfd1874 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts @@ -44,6 +44,7 @@ declare module 'fastify' { signPrivyMessage: typeof signPrivyMessage; approveToken: typeof approveToken; initAddress: typeof initAddress; + sendToken: typeof sendToken; } } @@ -186,6 +187,15 @@ const tokenApprovalSchema = z.object({ chainId: z.number().positive().optional() }); +// Schema for token-sending request +const tokenSendSchema = z.object({ + senderAddress: z.string().nonempty(), + recipientAddress: z.string().nonempty(), + ticker: z.string().nonempty(), + amount: z.bigint().positive(), + chainId: z.number().positive().optional() +}); + /** * Gets the chain name based on chain ID * @param chainId The chain ID @@ -642,6 +652,141 @@ export async function initAddress( } } +/** + * Sends tokens from one address to another using Privy wallet (implementation) + * @param senderAddress The sender's wallet address + * @param recipientAddress The recipient's wallet address + * @param ticker The token ticker or enum value + * @param amount The amount to send + * @param chainId The chain ID (optional, defaults to ARBITRUM) + * @returns The transaction hash + */ +export const sendTokenImpl = async ( + senderAddress: string, + recipientAddress: string, + ticker: string, + amount: bigint, + chainId?: number, +): Promise => { + try { + // Get token data from ticker + const tokenData = GetToken(ticker); + + // Check if sender has sufficient allowance for the token transfer + const senderAllowance = await getTokenAllowance(senderAddress, tokenData.address, senderAddress); + + // If insufficient allowance, approve the token first + if (senderAllowance < amount) { + console.log(`Insufficient allowance (${senderAllowance}). Approving token for amount: ${amount}`); + await approveContractImpl( + senderAddress, + tokenData.address, + senderAddress, // Approve self to spend tokens + chainId ?? ARBITRUM, + amount + ); + console.log('Token approval completed'); + } + + // Create contract interface for ERC20 token + const contractInterface = new ethers.Interface(Token.abi); + + // Convert amount to the correct decimal format + const transferAmount = ethers.parseUnits(amount.toString(), tokenData.decimals); + + // Encode the transfer function call + const data = contractInterface.encodeFunctionData("transfer", [recipientAddress, transferAmount]); + + chainId = chainId ?? ARBITRUM; + + // Get chain name in CAIP-2 format + const networkName = getChainName(chainId); + const privy = getPrivyClient(); + + // Send the transaction + const { hash } = await privy.walletApi.ethereum.sendTransaction({ + address: senderAddress as Address, + chainType: 'ethereum', + caip2: networkName as string, + transaction: { + to: tokenData.address as Address, + data: data, + chainId: chainId, + }, + } as any); + + return hash; + } catch (error) { + console.error('Error sending token:', error); + throw new Error(`Failed to send token: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Sends tokens from one address to another using Privy wallet + * @param this The FastifyRequest instance + * @param reply The FastifyReply instance + * @param senderAddress The sender's wallet address + * @param recipientAddress The recipient's wallet address + * @param ticker The token ticker or enum value + * @param amount The amount to send + * @param chainId The chain ID (optional, defaults to ARBITRUM) + * @returns The response object with success status and transaction hash + */ +export async function sendToken( + this: FastifyRequest, + reply: FastifyReply, + senderAddress: string, + recipientAddress: string, + ticker: string, + amount: bigint, + chainId?: number +) { + try { + // Validate the request parameters + tokenSendSchema.parse({ + senderAddress, + recipientAddress, + ticker, + amount, + chainId + }); + + if (!senderAddress) { + throw new Error('Sender address is required for token transfer'); + } + + if (!recipientAddress) { + throw new Error('Recipient address is required for token transfer'); + } + + if (!ticker) { + throw new Error('Token ticker is required for token transfer'); + } + + if (!amount || amount <= 0n) { + throw new Error('Valid amount is required for token transfer'); + } + + // Call the sendTokenImpl function + const hash = await sendTokenImpl(senderAddress, recipientAddress, ticker, amount, chainId); + + return { + success: true, + hash: hash + }; + } catch (error) { + this.log.error(error); + + // Return appropriate error response + reply.status(error instanceof z.ZodError ? 400 : 500); + return { + success: false, + error: error instanceof Error ? error.message : 'An unknown error occurred' + }; + } +} + /** * The use of fastify-plugin is required to be able * to export the decorators to the outer scope @@ -662,6 +807,10 @@ export default fp(async (fastify) => { return initAddress.call(this, reply, address); }); + fastify.decorateRequest('sendToken', async function(this: FastifyRequest, reply: FastifyReply, senderAddress: string, recipientAddress: string, ticker: string, amount: bigint, chainId?: number) { + return sendToken.call(this, reply, senderAddress, recipientAddress, ticker, amount, chainId); + }); + // Test the Privy client initialization try { const testClient = getPrivyClient(fastify); diff --git a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts index ba46bba..a7f50d9 100644 --- a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts +++ b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts @@ -1,5 +1,5 @@ import {FastifyPluginAsyncTypebox, Type} from '@fastify/type-provider-typebox' -import { handleError } from '../../../utils/errorHandler.js' +import {handleError} from '../../../utils/errorHandler.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.post( @@ -75,6 +75,47 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } } ) + + fastify.post( + '/send-token', + { + schema: { + body: Type.Object({ + senderAddress: Type.String(), + recipientAddress: Type.String(), + ticker: Type.String(), + amount: Type.String(), // Using string for bigint compatibility + chainId: Type.Optional(Type.Number()) + }), + response: { + 200: Type.Object({ + success: Type.Boolean(), + hash: Type.Optional(Type.String()), + error: Type.Optional(Type.String()) + }), + 400: Type.Object({ + success: Type.Boolean(), + error: Type.String() + }), + 500: Type.Object({ + success: Type.Boolean(), + error: Type.String() + }) + }, + tags: ['Privy'] + } + }, + async function (request, reply) { + try { + const { senderAddress, recipientAddress, ticker, amount, chainId } = request.body; + // Convert amount string to bigint + const amountBigInt = BigInt(amount); + return await request.sendToken(reply, senderAddress, recipientAddress, ticker, amountBigInt, chainId); + } catch (error) { + return handleError(request, reply, error, 'privy/send-token'); + } + } + ) } export default plugin \ No newline at end of file diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 37aa499..ef92852 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -284,7 +284,48 @@ export class AccountClient extends AuthorizedApiBase { }); } else if (status !== 200 && status !== 204) { return response.text().then((_responseText) => { - console.log(_responseText) + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + account_SendToken(name: string, request: SendTokenRequest): Promise { + let url_ = this.baseUrl + "/Account/{name}/send-token"; + if (name === undefined || name === null) + throw new Error("The parameter 'name' must be defined."); + url_ = url_.replace("{name}", encodeURIComponent("" + name)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processAccount_SendToken(_response); + }); + } + + protected processAccount_SendToken(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SwapInfos; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } @@ -3041,6 +3082,13 @@ export enum Ticker { Unknown = "Unknown", } +export interface SendTokenRequest { + recipientAddress: string; + ticker: Ticker; + amount: number; + chainId?: number | null; +} + export interface Backtest { id: string; finalPnl: number; diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index f22cac3..1e2206b 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -210,6 +210,13 @@ export enum Ticker { Unknown = "Unknown", } +export interface SendTokenRequest { + recipientAddress: string; + ticker: Ticker; + amount: number; + chainId?: number | null; +} + export interface Backtest { id: string; finalPnl: number; diff --git a/src/Managing.WebApp/src/pages/settingsPage/account/SendTokenModal.tsx b/src/Managing.WebApp/src/pages/settingsPage/account/SendTokenModal.tsx new file mode 100644 index 0000000..566499e --- /dev/null +++ b/src/Managing.WebApp/src/pages/settingsPage/account/SendTokenModal.tsx @@ -0,0 +1,232 @@ +import React, {useState} from 'react' +import type {SubmitHandler} from 'react-hook-form' +import {useForm} from 'react-hook-form' +import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi' +import Modal from '../../../components/mollecules/Modal/Modal' +import useApiUrlStore from '../../../app/store/apiStore' +import {FormInput, Toast} from '../../../components/mollecules' +import {useApiError} from '../../../hooks/useApiError' + +interface SendTokenModalProps { + isOpen: boolean + onClose: () => void + account: Account + fromTicker: Ticker + availableAmount: number +} + +interface SendTokenFormInput { + recipientAddress: string + ticker: Ticker + amount: number + chainId?: number +} + +const SendTokenModal: React.FC = ({ + isOpen, + onClose, + account, + fromTicker, + availableAmount, +}) => { + const [isLoading, setIsLoading] = useState(false) + const { error, setError, handleApiErrorWithToast } = useApiError() + const { apiUrl } = useApiUrlStore() + const client = new AccountClient({}, apiUrl) + + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + recipientAddress: '', + ticker: fromTicker, + amount: availableAmount * 0.1, // Start with 10% of available amount + chainId: 42161, // Default to ARBITRUM + } + }) + + const watchedAmount = watch('amount') + const watchedRecipientAddress = watch('recipientAddress') + + const onSubmit: SubmitHandler = async (form) => { + const t = new Toast(`Sending ${form.amount} ${form.ticker} to ${form.recipientAddress} from ${account.name}`) + setIsLoading(true) + setError(null) + + try { + const result = await client.account_SendToken( + account.name, + { + recipientAddress: form.recipientAddress, + ticker: form.ticker, + amount: form.amount, + chainId: form.chainId, + } + ) + + if (result.success) { + t.update('success', `Token sent successfully! Hash: ${result.hash}`) + onClose() + } else { + console.log(result) + const errorMessage = result.error || result.message || 'Send token failed' + setError(errorMessage) + t.update('error', `Send token failed: ${errorMessage}`) + } + } catch (err) { + handleApiErrorWithToast(err, t) + } finally { + setIsLoading(false) + } + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + handleSubmit(onSubmit)(e) + } + + const modalContent = ( + <> + {isLoading ? ( +
+ +

Processing token transfer...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> +
+

+ Account: {account.name} +

+

+ Token: {fromTicker} +

+

+ Available: {availableAmount.toFixed(6)} {fromTicker} +

+
+ +
+
+ + + + + + + + + +
+ +
+ { + const value = parseFloat(e.target.value) + setValue('amount', value) + }} + /> + +
+ {watchedAmount && availableAmount > 0 ? ( + {((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance + ) : ( + 0% of available balance + )} +
+
+
+
+ + + + + + +
+
+ +
+
+

+ Warning: This will send tokens directly to the specified address. Make sure the address is correct as this action cannot be undone. +

+
+
+ + )} + + ) + + return ( + + {modalContent} + + ) +} + +export default SendTokenModal \ No newline at end of file diff --git a/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx b/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx index 437de17..3295d60 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/account/accountRowDetails.tsx @@ -1,11 +1,12 @@ import React, {useState} from 'react' -import {FiRefreshCw} from 'react-icons/fi' +import {FiRefreshCw, FiSend} from 'react-icons/fi' import {SelectColumnFilter, Table} from '../../../components/mollecules' import type {IAccountRowDetail} from '../../../global/type.tsx' import type {Account, Balance} from '../../../generated/ManagingApi' import {Ticker} from '../../../generated/ManagingApi' import SwapModal from './SwapModal' +import SendTokenModal from './SendTokenModal' interface IAccountRowDetailProps extends IAccountRowDetail { account: Account @@ -26,6 +27,16 @@ const AccountRowDetails: React.FC = ({ availableAmount: 0, }) + const [sendTokenModalState, setSendTokenModalState] = useState<{ + isOpen: boolean + fromTicker: Ticker | null + availableAmount: number + }>({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + const handleSwapClick = (balance: Balance) => { if (balance.tokenName && balance.amount) { // Convert tokenName to Ticker enum @@ -40,6 +51,20 @@ const AccountRowDetails: React.FC = ({ } } + const handleSendClick = (balance: Balance) => { + if (balance.tokenName && balance.amount) { + // Convert tokenName to Ticker enum + const ticker = balance.tokenName.toUpperCase() as Ticker + if (Object.values(Ticker).includes(ticker)) { + setSendTokenModalState({ + isOpen: true, + fromTicker: ticker, + availableAmount: balance.amount, + }) + } + } + } + const closeSwapModal = () => { setSwapModalState({ isOpen: false, @@ -48,6 +73,14 @@ const AccountRowDetails: React.FC = ({ }) } + const closeSendTokenModal = () => { + setSendTokenModalState({ + isOpen: false, + fromTicker: null, + availableAmount: 0, + }) + } + const columns = [ { Header: 'Chain', @@ -92,16 +125,26 @@ const AccountRowDetails: React.FC = ({ const balance = cell.row.original as Balance return ( -
+
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && ( - + <> + + + )}
) @@ -131,6 +174,16 @@ const AccountRowDetails: React.FC = ({ availableAmount={swapModalState.availableAmount} /> )} + + {sendTokenModalState.isOpen && sendTokenModalState.fromTicker && ( + + )} ) }