Send tokens

This commit is contained in:
2025-07-06 14:39:01 +07:00
parent c7dec76809
commit f973be2e08
13 changed files with 693 additions and 12 deletions

View File

@@ -114,6 +114,28 @@ namespace Managing.Api.Controllers
return Ok(result);
}
/// <summary>
/// Sends tokens from a specific account to a recipient address.
/// </summary>
/// <param name="name">The name of the account to send tokens from.</param>
/// <param name="request">The token sending request containing recipient address, ticker, and amount.</param>
/// <returns>The transaction response with details.</returns>
[HttpPost]
[Route("{name}/send-token")]
public async Task<ActionResult<SwapInfos>> 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);
}
/// <summary>
/// Deletes a specific account by name for the authenticated user.
/// </summary>

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using static Managing.Common.Enums;
namespace Managing.Api.Models.Requests;
/// <summary>
/// Request model for token sending operations
/// </summary>
public class SendTokenRequest
{
/// <summary>
/// The recipient's wallet address
/// </summary>
[Required]
public string RecipientAddress { get; set; }
/// <summary>
/// The ticker symbol of the token to send
/// </summary>
[Required]
public Ticker Ticker { get; set; }
/// <summary>
/// The amount to send
/// </summary>
[Required]
[Range(0.000001, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
public decimal Amount { get; set; }
/// <summary>
/// The chain ID where the transaction will be executed (optional, defaults to ARBITRUM)
/// </summary>
public int? ChainId { get; set; }
}

View File

@@ -18,4 +18,6 @@ public interface IAccountService
Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker,
double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
Task<SwapInfos> SendTokenAsync(User user, string accountName, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null);
}

View File

@@ -13,5 +13,7 @@ namespace Managing.Application.Abstractions.Services
Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount,
string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
Task<SwapInfos> SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null);
}
}

View File

@@ -239,6 +239,55 @@ public class AccountService : IAccountService
}
}
public async Task<SwapInfos> 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)

View File

@@ -126,4 +126,16 @@ namespace Managing.Infrastructure.Evm.Models.Proxy
Error = error;
}
}
/// <summary>
/// Response for token sending operations
/// </summary>
public class Web3ProxyTokenSendResponse : Web3ProxyResponse
{
/// <summary>
/// Transaction hash if successful
/// </summary>
[JsonPropertyName("hash")]
public string Hash { get; set; }
}
}

View File

@@ -213,6 +213,36 @@ namespace Managing.Infrastructure.Evm.Services
};
}
public async Task<SwapInfos> 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<Web3ProxyTokenSendResponse>("/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;

View File

@@ -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<string> => {
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);

View File

@@ -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

View File

@@ -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<SwapInfos>(null as any);
}
account_SendToken(name: string, request: SendTokenRequest): Promise<SwapInfos> {
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<SwapInfos> {
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;

View File

@@ -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;

View File

@@ -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<SendTokenModalProps> = ({
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<SendTokenFormInput>({
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<SendTokenFormInput> = 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 ? (
<div className="text-center py-4">
<span className="loading loading-spinner loading-md"></span>
<p>Processing token transfer...</p>
</div>
) : error ? (
<div className="alert alert-error mb-4">
<p>{error}</p>
</div>
) : (
<>
<div className="mb-4">
<p className="mb-2">
<strong>Account:</strong> {account.name}
</p>
<p className="mb-2">
<strong>Token:</strong> {fromTicker}
</p>
<p className="mb-2">
<strong>Available:</strong> {availableAmount.toFixed(6)} {fromTicker}
</p>
</div>
<form onSubmit={handleFormSubmit}>
<div className="space-y-4 mb-4">
<FormInput label="Recipient Address" htmlFor="recipientAddress">
<input
type="text"
placeholder="0x..."
className="input input-bordered w-full"
{...register('recipientAddress', {
required: true,
pattern: {
value: /^0x[a-fA-F0-9]{40}$/,
message: 'Please enter a valid Ethereum address'
}
})}
/>
</FormInput>
<FormInput label="Token" htmlFor="ticker">
<select
className="select select-bordered w-full"
{...register('ticker')}
defaultValue={fromTicker}
>
{Object.values(Ticker).map((ticker) => (
<option key={ticker} value={ticker}>
{ticker}
</option>
))}
</select>
</FormInput>
<FormInput label="Amount" htmlFor="amount">
<div className="w-full">
<input
type="number"
step="any"
placeholder="Enter amount to send"
className="input input-bordered w-full mb-2"
{...register('amount', {
valueAsNumber: true,
min: 0.0001,
max: availableAmount,
required: true
})}
/>
<div className="w-full">
<input
type="range"
min="0"
max={availableAmount}
step={availableAmount / 100}
className="range range-primary w-full"
value={watchedAmount || 0}
onChange={(e) => {
const value = parseFloat(e.target.value)
setValue('amount', value)
}}
/>
<div className="text-center text-xs text-gray-500 mt-1">
{watchedAmount && availableAmount > 0 ? (
<span>{((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance</span>
) : (
<span>0% of available balance</span>
)}
</div>
</div>
</div>
</FormInput>
<FormInput label="Chain ID (Optional)" htmlFor="chainId">
<select
className="select select-bordered w-full"
{...register('chainId', { valueAsNumber: true })}
defaultValue={42161}
>
<option value={1}>Ethereum Mainnet (1)</option>
<option value={42161}>Arbitrum One (42161)</option>
<option value={421613}>Arbitrum Goerli (421613)</option>
<option value={8453}>Base (8453)</option>
<option value={84531}>Base Goerli (84531)</option>
</select>
</FormInput>
<button
type="submit"
className="btn btn-primary w-full mt-2"
disabled={isLoading || !watchedAmount || watchedAmount <= 0 || !watchedRecipientAddress}
>
{isLoading ? (
<span className="loading loading-spinner"></span>
) : (
`Send ${watchedAmount || 0} ${fromTicker} to ${watchedRecipientAddress ? watchedRecipientAddress.slice(0, 6) + '...' + watchedRecipientAddress.slice(-4) : 'recipient'}`
)}
</button>
</div>
</form>
<div className="mt-4">
<div className="alert alert-warning">
<p className="text-sm">
<strong>Warning:</strong> This will send tokens directly to the specified address. Make sure the address is correct as this action cannot be undone.
</p>
</div>
</div>
</>
)}
</>
)
return (
<Modal
showModal={isOpen}
onClose={onClose}
titleHeader="Send Tokens"
>
{modalContent}
</Modal>
)
}
export default SendTokenModal

View File

@@ -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<IAccountRowDetailProps> = ({
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<IAccountRowDetailProps> = ({
}
}
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<IAccountRowDetailProps> = ({
})
}
const closeSendTokenModal = () => {
setSendTokenModalState({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
}
const columns = [
{
Header: 'Chain',
@@ -92,16 +125,26 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
const balance = cell.row.original as Balance
return (
<div className="flex justify-center">
<div className="flex justify-center gap-1">
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && (
<button
className="btn btn-xs btn-outline btn-info"
onClick={() => handleSwapClick(balance)}
title={`Swap ${balance.tokenName}`}
>
<FiRefreshCw className="h-3 w-3" />
<span className="ml-1">Swap</span>
</button>
<>
<button
className="btn btn-xs btn-outline btn-info"
onClick={() => handleSwapClick(balance)}
title={`Swap ${balance.tokenName}`}
>
<FiRefreshCw className="h-3 w-3" />
<span className="ml-1">Swap</span>
</button>
<button
className="btn btn-xs btn-outline btn-success"
onClick={() => handleSendClick(balance)}
title={`Send ${balance.tokenName}`}
>
<FiSend className="h-3 w-3" />
<span className="ml-1">Send</span>
</button>
</>
)}
</div>
)
@@ -131,6 +174,16 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
availableAmount={swapModalState.availableAmount}
/>
)}
{sendTokenModalState.isOpen && sendTokenModalState.fromTicker && (
<SendTokenModal
isOpen={sendTokenModalState.isOpen}
onClose={closeSendTokenModal}
account={account}
fromTicker={sendTokenModalState.fromTicker}
availableAmount={sendTokenModalState.availableAmount}
/>
)}
</>
)
}