From a813c92243e953e365bd7f7f9237f8e033d77095 Mon Sep 17 00:00:00 2001 From: Brain Agent Date: Mon, 18 May 2026 15:45:41 +0800 Subject: [PATCH 1/3] feat: add automatic transfer with dynamic gas estimation and operator fee - Added transfer and operatorFeePercent to PermitGenerationSettings schema - Created automatic-transfer.ts with gas estimation and executeAutomaticTransfer - Updated generatePayoutPermit to trigger automatic transfer after ERC20 permit - Operator fee configurable (default 10%), goes to ubq.eth - Dynamic gas estimation via ethers.Contract.estimateGas Closes #6 --- src/handlers/automatic-transfer.ts | 146 +++++++++++++++++++++++++ src/handlers/generate-payout-permit.ts | 37 +++++++ src/types/plugin-input.ts | 2 + 3 files changed, 185 insertions(+) create mode 100644 src/handlers/automatic-transfer.ts diff --git a/src/handlers/automatic-transfer.ts b/src/handlers/automatic-transfer.ts new file mode 100644 index 0000000..32eeb0e --- /dev/null +++ b/src/handlers/automatic-transfer.ts @@ -0,0 +1,146 @@ +import { ethers } from "ethers"; +import { PERMIT2_ADDRESS } from "../types"; +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; + } 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 = gasEstimate * gasPrice; + + // Total needed: amount + gas cost + const totalNeeded = amountAfterFee + gasCost; + + // 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 < amountAfterFee) { + return { success: false, error: `Insufficient token balance: ${tokenBalance} < ${amountAfterFee}` }; + } + + if (balance < gasCost) { + return { success: false, error: `Insufficient ETH for gas: ${balance} < ${gasCost}` }; + } + + // 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, amountAfterFee); + const receipt = await tx.wait(); + + logger.info(`Transfer successful: ${receipt.transactionHash}, amount: ${amountAfterFee}, fee: ${fee}`); + + // 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 + ); + // Resolve ubq.eth - use raw address if ENS fails + let operatorAddress = UBQ_ETH; + try { + const resolver = await provider.getResolver("ubq.eth"); + if (resolver) { + operatorAddress = await resolver.address; + } + } catch { + logger.debug("Could not resolve ubq.eth, using ENS name directly"); + } + const feeTx = await feeTokenWithSigner.transfer(operatorAddress, fee); + const feeReceipt = await feeTx.wait(); + feeTxHash = feeReceipt.transactionHash; + logger.info(`Operator fee transferred: ${feeReceipt.transactionHash}, amount: ${fee}`); + } 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 }; + } +} + +export async function getDynamicGasEstimate( + provider: ethers.providers.Provider, + networkId: number +): Promise<{ gasPrice: bigint; maxFeePerGas?: bigint; maxPriorityFeePerGas?: bigint }> { + const feeData = await provider.getFeeData(); + + return { + gasPrice: feeData.gasPrice ?? ethers.BigNumber.from(0), + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + }; +} diff --git a/src/handlers/generate-payout-permit.ts b/src/handlers/generate-payout-permit.ts index 8a72a57..aa06daf 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(permit.amount); + 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; From 40fa4c13c02d6eef26950a2e40eef9cb80d15f1a Mon Sep 17 00:00:00 2001 From: Brain Agent Date: Mon, 18 May 2026 15:58:41 +0800 Subject: [PATCH 2/3] fix: BigNumberish type conversion and ethers type compatibility --- src/handlers/automatic-transfer.ts | 48 ++++++++++---------------- src/handlers/generate-payout-permit.ts | 2 +- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/handlers/automatic-transfer.ts b/src/handlers/automatic-transfer.ts index 32eeb0e..bd8b1fe 100644 --- a/src/handlers/automatic-transfer.ts +++ b/src/handlers/automatic-transfer.ts @@ -1,5 +1,4 @@ import { ethers } from "ethers"; -import { PERMIT2_ADDRESS } from "../types"; import { Logger } from "../types/context"; const UBQ_ETH = "0xobq.eth"; // ENS resolution for ubq.eth @@ -29,7 +28,7 @@ export async function estimateGasForTransfer( try { // Estimate gas for transfer const gasEstimate = await tokenContract.estimateGas.transfer(to, amount, { from }); - return gasEstimate; + return gasEstimate.toBigInt(); } catch { // Fallback to rough estimate if estimation fails return BigInt(65000); // standard gas for ERC20 transfer @@ -66,10 +65,7 @@ export async function executeAutomaticTransfer( // Get gas price const feeData = await provider.getFeeData(); const gasPrice = feeData.gasPrice ?? feeData.maxFeePerGas ?? ethers.BigNumber.from(0); - const gasCost = gasEstimate * gasPrice; - - // Total needed: amount + gas cost - const totalNeeded = amountAfterFee + gasCost; + const gasCost = ethers.BigNumber.from(gasEstimate).mul(gasPrice); // Check balance const balance = await provider.getBalance(wallet.address); @@ -80,20 +76,24 @@ export async function executeAutomaticTransfer( ); const tokenBalance = await tokenContract.balanceOf(wallet.address); - if (tokenBalance < amountAfterFee) { - return { success: false, error: `Insufficient token balance: ${tokenBalance} < ${amountAfterFee}` }; + if (tokenBalance.lt(ethers.BigNumber.from(amountAfterFee))) { + return { success: false, error: `Insufficient token balance: ${tokenBalance.toString()} < ${amountAfterFee.toString()}` }; } - if (balance < gasCost) { - return { success: false, error: `Insufficient ETH for gas: ${balance} < ${gasCost}` }; + 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, amountAfterFee); + 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}, fee: ${fee}`); + 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; @@ -104,20 +104,10 @@ export async function executeAutomaticTransfer( ["function transfer(address to, uint256 amount) returns (bool)"], wallet ); - // Resolve ubq.eth - use raw address if ENS fails - let operatorAddress = UBQ_ETH; - try { - const resolver = await provider.getResolver("ubq.eth"); - if (resolver) { - operatorAddress = await resolver.address; - } - } catch { - logger.debug("Could not resolve ubq.eth, using ENS name directly"); - } - const feeTx = await feeTokenWithSigner.transfer(operatorAddress, fee); + 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}`); + 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 @@ -134,13 +124,13 @@ export async function executeAutomaticTransfer( export async function getDynamicGasEstimate( provider: ethers.providers.Provider, - networkId: number + _networkId: number ): Promise<{ gasPrice: bigint; maxFeePerGas?: bigint; maxPriorityFeePerGas?: bigint }> { const feeData = await provider.getFeeData(); return { - gasPrice: feeData.gasPrice ?? ethers.BigNumber.from(0), - maxFeePerGas: feeData.maxFeePerGas ?? undefined, - maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + gasPrice: (feeData.gasPrice ?? ethers.BigNumber.from(0)).toBigInt(), + maxFeePerGas: feeData.maxFeePerGas?.toBigInt(), + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.toBigInt(), }; } diff --git a/src/handlers/generate-payout-permit.ts b/src/handlers/generate-payout-permit.ts index aa06daf..46537ba 100644 --- a/src/handlers/generate-payout-permit.ts +++ b/src/handlers/generate-payout-permit.ts @@ -48,7 +48,7 @@ export async function generatePayoutPermit(context: Context, permitRequests: Per } const wallet = new ethers.Wallet(privateKey, provider); - const amountBigInt = BigInt(permit.amount); + const amountBigInt = BigInt(ethers.BigNumber.from(permit.amount).toString()); const transferResult = await executeAutomaticTransfer( provider, wallet, From 6e220fa8b3fc55506a46b99288c71b6e664f6669 Mon Sep 17 00:00:00 2001 From: Brain Agent Date: Mon, 18 May 2026 16:09:40 +0800 Subject: [PATCH 3/3] fix: remove unused getDynamicGasEstimate export to pass knip --- src/handlers/automatic-transfer.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/handlers/automatic-transfer.ts b/src/handlers/automatic-transfer.ts index bd8b1fe..58a02bf 100644 --- a/src/handlers/automatic-transfer.ts +++ b/src/handlers/automatic-transfer.ts @@ -122,15 +122,4 @@ export async function executeAutomaticTransfer( } } -export async function getDynamicGasEstimate( - provider: ethers.providers.Provider, - _networkId: number -): Promise<{ gasPrice: bigint; maxFeePerGas?: bigint; maxPriorityFeePerGas?: bigint }> { - const feeData = await provider.getFeeData(); - - return { - gasPrice: (feeData.gasPrice ?? ethers.BigNumber.from(0)).toBigInt(), - maxFeePerGas: feeData.maxFeePerGas?.toBigInt(), - maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.toBigInt(), - }; -} +