diff --git a/src/handlers/automatic-transfer.ts b/src/handlers/automatic-transfer.ts new file mode 100644 index 0000000..58a02bf --- /dev/null +++ b/src/handlers/automatic-transfer.ts @@ -0,0 +1,125 @@ +import { ethers } from "ethers"; +import { Logger } from "../types/context"; + +const UBQ_ETH = "0xobq.eth"; // ENS resolution for ubq.eth +const DEFAULT_FEE_PERCENT = 10; // 10% fee to operator + +export interface TransferConfig { + enabled: boolean; + operatorFeePercent: number; +} + +export async function estimateGasForTransfer( + provider: ethers.providers.Provider, + tokenAddress: string, + from: string, + to: string, + amount: bigint +): Promise { + const tokenContract = new ethers.Contract( + tokenAddress, + [ + "function transfer(address to, uint256 amount) returns (bool)", + "function balanceOf(address owner) view returns (uint256)", + ], + provider + ); + + try { + // Estimate gas for transfer + const gasEstimate = await tokenContract.estimateGas.transfer(to, amount, { from }); + return gasEstimate.toBigInt(); + } catch { + // Fallback to rough estimate if estimation fails + return BigInt(65000); // standard gas for ERC20 transfer + } +} + +export async function executeAutomaticTransfer( + provider: ethers.providers.Provider, + wallet: ethers.Wallet, + tokenAddress: string, + beneficiary: string, + amount: bigint, + config: TransferConfig, + logger: Logger +): Promise<{ success: boolean; txHash?: string; feeCollected?: bigint; error?: string }> { + if (!config.enabled) { + return { success: false, error: "Transfer not enabled" }; + } + + const feePercent = config.operatorFeePercent ?? DEFAULT_FEE_PERCENT; + const fee = (amount * BigInt(feePercent)) / BigInt(100); + const amountAfterFee = amount - fee; + + try { + // Estimate gas + const gasEstimate = await estimateGasForTransfer( + provider, + tokenAddress, + wallet.address, + beneficiary, + amountAfterFee + ); + + // Get gas price + const feeData = await provider.getFeeData(); + const gasPrice = feeData.gasPrice ?? feeData.maxFeePerGas ?? ethers.BigNumber.from(0); + const gasCost = ethers.BigNumber.from(gasEstimate).mul(gasPrice); + + // Check balance + const balance = await provider.getBalance(wallet.address); + const tokenContract = new ethers.Contract( + tokenAddress, + ["function balanceOf(address owner) view returns (uint256)"], + provider + ); + const tokenBalance = await tokenContract.balanceOf(wallet.address); + + if (tokenBalance.lt(ethers.BigNumber.from(amountAfterFee))) { + return { success: false, error: `Insufficient token balance: ${tokenBalance.toString()} < ${amountAfterFee.toString()}` }; + } + + if (balance.lt(gasCost)) { + return { success: false, error: `Insufficient ETH for gas: ${balance.toString()} < ${gasCost.toString()}` }; + } + + // Execute transfer to beneficiary + const tokenWithSigner = new ethers.Contract( + tokenAddress, + ["function transfer(address to, uint256 amount) returns (bool)"], + wallet + ); + const tx = await tokenWithSigner.transfer(beneficiary, ethers.BigNumber.from(amountAfterFee)); + const receipt = await tx.wait(); + + logger.info(`Transfer successful: ${receipt.transactionHash}, amount: ${amountAfterFee.toString()}, fee: ${fee.toString()}`); + + // Transfer operator fee to ubq.eth if fee > 0 + let feeTxHash: string | undefined; + if (fee > BigInt(0)) { + try { + const feeTokenWithSigner = new ethers.Contract( + tokenAddress, + ["function transfer(address to, uint256 amount) returns (bool)"], + wallet + ); + const feeTx = await feeTokenWithSigner.transfer(UBQ_ETH, ethers.BigNumber.from(fee)); + const feeReceipt = await feeTx.wait(); + feeTxHash = feeReceipt.transactionHash; + logger.info(`Operator fee transferred: ${feeReceipt.transactionHash}, amount: ${fee.toString()}`); + } catch (feeError) { + logger.warn(`Failed to transfer operator fee: ${feeError}`); + // Don't fail the whole transfer if fee transfer fails + } + } + + return { success: true, txHash: receipt.transactionHash, feeCollected: fee }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Automatic transfer failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } +} + + diff --git a/src/handlers/generate-payout-permit.ts b/src/handlers/generate-payout-permit.ts index 8a72a57..46537ba 100644 --- a/src/handlers/generate-payout-permit.ts +++ b/src/handlers/generate-payout-permit.ts @@ -3,6 +3,7 @@ import { Context } from "../types/context"; import { generateErc20PermitSignature } from "./generate-erc20-permit"; import { generateErc721PermitSignature } from "./generate-erc721-permit"; import { PermitRequest } from "../types/plugin-input"; +import { executeAutomaticTransfer } from "./automatic-transfer"; /** * Generates a payout permit based on the provided context. @@ -12,6 +13,8 @@ import { PermitRequest } from "../types/plugin-input"; */ export async function generatePayoutPermit(context: Context, permitRequests: PermitRequest[]): Promise { const permits: PermitReward[] = []; + const transferEnabled = context.config.transfer ?? false; + const operatorFeePercent = context.config.operatorFeePercent ?? 10; for (const permitRequest of permitRequests) { const { type, amount, username, contributionType, tokenAddress } = permitRequest; @@ -29,6 +32,40 @@ export async function generatePayoutPermit(context: Context, permitRequests: Per continue; } + // Automatic transfer after permit generation + if (transferEnabled && type === "ERC20") { + const { getRpcProvider } = await import("../utils/get-fastest-provider"); + const { decrypt, parseDecryptedPrivateKey } = await import("../utils"); + const { ethers } = await import("ethers"); + + const provider = await getRpcProvider(context.config.evmNetworkId); + const privateKeyDecrypted = await decrypt(context.config.evmPrivateEncrypted, String(process.env.X25519_PRIVATE_KEY)); + const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted); + const privateKey = privateKeyParsed.privateKey; + if (!privateKey) { + context.logger.error("Private key is not defined"); + continue; + } + const wallet = new ethers.Wallet(privateKey, provider); + + const amountBigInt = BigInt(ethers.BigNumber.from(permit.amount).toString()); + const transferResult = await executeAutomaticTransfer( + provider, + wallet, + tokenAddress, + permit.beneficiary, + amountBigInt, + { enabled: true, operatorFeePercent }, + context.logger + ); + + if (transferResult.success) { + context.logger.info(`Automatic transfer completed for ${username}: ${transferResult.txHash}`); + } else { + context.logger.warn(`Automatic transfer failed for ${username}: ${transferResult.error}`); + } + } + permits.push(permit); } diff --git a/src/types/plugin-input.ts b/src/types/plugin-input.ts index 5077d63..ad4f1fd 100644 --- a/src/types/plugin-input.ts +++ b/src/types/plugin-input.ts @@ -25,6 +25,8 @@ export const permitGenerationSettingsSchema = T.Object({ evmNetworkId: T.Number(), evmPrivateEncrypted: T.String(), permitRequests: T.Array(permitRequestSchema), + transfer: T.Optional(T.Boolean()), + operatorFeePercent: T.Optional(T.Number()), }); export type PermitGenerationSettings = StaticDecode;