Send tokens
This commit is contained in:
@@ -114,6 +114,28 @@ namespace Managing.Api.Controllers
|
|||||||
return Ok(result);
|
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>
|
/// <summary>
|
||||||
/// Deletes a specific account by name for the authenticated user.
|
/// Deletes a specific account by name for the authenticated user.
|
||||||
/// </summary>
|
/// </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,
|
Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker,
|
||||||
double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
|
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,
|
Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount,
|
||||||
string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
|
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)
|
private void ManageProperties(bool hideSecrets, bool getBalance, Account account)
|
||||||
{
|
{
|
||||||
if (account != null)
|
if (account != null)
|
||||||
|
|||||||
@@ -126,4 +126,16 @@ namespace Managing.Infrastructure.Evm.Models.Proxy
|
|||||||
Error = error;
|
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)
|
private async Task HandleErrorResponse(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
var statusCode = (int)response.StatusCode;
|
var statusCode = (int)response.StatusCode;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ declare module 'fastify' {
|
|||||||
signPrivyMessage: typeof signPrivyMessage;
|
signPrivyMessage: typeof signPrivyMessage;
|
||||||
approveToken: typeof approveToken;
|
approveToken: typeof approveToken;
|
||||||
initAddress: typeof initAddress;
|
initAddress: typeof initAddress;
|
||||||
|
sendToken: typeof sendToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +187,15 @@ const tokenApprovalSchema = z.object({
|
|||||||
chainId: z.number().positive().optional()
|
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
|
* Gets the chain name based on chain ID
|
||||||
* @param chainId The 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
|
* The use of fastify-plugin is required to be able
|
||||||
* to export the decorators to the outer scope
|
* to export the decorators to the outer scope
|
||||||
@@ -662,6 +807,10 @@ export default fp(async (fastify) => {
|
|||||||
return initAddress.call(this, reply, address);
|
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
|
// Test the Privy client initialization
|
||||||
try {
|
try {
|
||||||
const testClient = getPrivyClient(fastify);
|
const testClient = getPrivyClient(fastify);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {FastifyPluginAsyncTypebox, Type} from '@fastify/type-provider-typebox'
|
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) => {
|
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
|
||||||
fastify.post(
|
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
|
export default plugin
|
||||||
@@ -284,7 +284,48 @@ export class AccountClient extends AuthorizedApiBase {
|
|||||||
});
|
});
|
||||||
} else if (status !== 200 && status !== 204) {
|
} else if (status !== 200 && status !== 204) {
|
||||||
return response.text().then((_responseText) => {
|
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);
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3041,6 +3082,13 @@ export enum Ticker {
|
|||||||
Unknown = "Unknown",
|
Unknown = "Unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendTokenRequest {
|
||||||
|
recipientAddress: string;
|
||||||
|
ticker: Ticker;
|
||||||
|
amount: number;
|
||||||
|
chainId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Backtest {
|
export interface Backtest {
|
||||||
id: string;
|
id: string;
|
||||||
finalPnl: number;
|
finalPnl: number;
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ export enum Ticker {
|
|||||||
Unknown = "Unknown",
|
Unknown = "Unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendTokenRequest {
|
||||||
|
recipientAddress: string;
|
||||||
|
ticker: Ticker;
|
||||||
|
amount: number;
|
||||||
|
chainId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Backtest {
|
export interface Backtest {
|
||||||
id: string;
|
id: string;
|
||||||
finalPnl: number;
|
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 React, {useState} from 'react'
|
||||||
import {FiRefreshCw} from 'react-icons/fi'
|
import {FiRefreshCw, FiSend} from 'react-icons/fi'
|
||||||
|
|
||||||
import {SelectColumnFilter, Table} from '../../../components/mollecules'
|
import {SelectColumnFilter, Table} from '../../../components/mollecules'
|
||||||
import type {IAccountRowDetail} from '../../../global/type.tsx'
|
import type {IAccountRowDetail} from '../../../global/type.tsx'
|
||||||
import type {Account, Balance} from '../../../generated/ManagingApi'
|
import type {Account, Balance} from '../../../generated/ManagingApi'
|
||||||
import {Ticker} from '../../../generated/ManagingApi'
|
import {Ticker} from '../../../generated/ManagingApi'
|
||||||
import SwapModal from './SwapModal'
|
import SwapModal from './SwapModal'
|
||||||
|
import SendTokenModal from './SendTokenModal'
|
||||||
|
|
||||||
interface IAccountRowDetailProps extends IAccountRowDetail {
|
interface IAccountRowDetailProps extends IAccountRowDetail {
|
||||||
account: Account
|
account: Account
|
||||||
@@ -26,6 +27,16 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
|
|||||||
availableAmount: 0,
|
availableAmount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [sendTokenModalState, setSendTokenModalState] = useState<{
|
||||||
|
isOpen: boolean
|
||||||
|
fromTicker: Ticker | null
|
||||||
|
availableAmount: number
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
fromTicker: null,
|
||||||
|
availableAmount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
const handleSwapClick = (balance: Balance) => {
|
const handleSwapClick = (balance: Balance) => {
|
||||||
if (balance.tokenName && balance.amount) {
|
if (balance.tokenName && balance.amount) {
|
||||||
// Convert tokenName to Ticker enum
|
// 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 = () => {
|
const closeSwapModal = () => {
|
||||||
setSwapModalState({
|
setSwapModalState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@@ -48,6 +73,14 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeSendTokenModal = () => {
|
||||||
|
setSendTokenModalState({
|
||||||
|
isOpen: false,
|
||||||
|
fromTicker: null,
|
||||||
|
availableAmount: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
Header: 'Chain',
|
Header: 'Chain',
|
||||||
@@ -92,8 +125,9 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
|
|||||||
const balance = cell.row.original as Balance
|
const balance = cell.row.original as Balance
|
||||||
|
|
||||||
return (
|
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) && (
|
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-outline btn-info"
|
className="btn btn-xs btn-outline btn-info"
|
||||||
onClick={() => handleSwapClick(balance)}
|
onClick={() => handleSwapClick(balance)}
|
||||||
@@ -102,6 +136,15 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
|
|||||||
<FiRefreshCw className="h-3 w-3" />
|
<FiRefreshCw className="h-3 w-3" />
|
||||||
<span className="ml-1">Swap</span>
|
<span className="ml-1">Swap</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -131,6 +174,16 @@ const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
|
|||||||
availableAmount={swapModalState.availableAmount}
|
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