Send tokens
This commit is contained in:
@@ -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>
|
||||
|
||||
34
src/Managing.Api/Models/Requests/SendTokenRequest.cs
Normal file
34
src/Managing.Api/Models/Requests/SendTokenRequest.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user