@kaiachain/ethers-ext
Version:
ethers.js extension for kaia blockchain
525 lines (524 loc) • 21.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isGaslessSwap = exports.validateWithoutApprove = exports.validateWithApprove = exports.validateAmountRepayWithoutApprove = exports.validateNonceWithoutApprove = exports.validateAmountRepayWithApprove = exports.validateNonceWithApprove = exports.validateApproveAmount = exports.validateApproveToken = exports.validateAndDecodeSwapFunction = exports.isValidRouterAddress = exports.isValidSwapTxFormat = exports.isGaslessApprove = exports.isGaslessSupportedToken = exports.sendGaslessTx = exports.getSwapTx = exports.getApproveTx = exports.getAmountIn = exports.getMinAmountOut = exports.getCommissionRate = exports.getGaslessSwapRouter = exports.getAmountRepay = void 0;
const ethers_1 = require("ethers");
const GaslessSwapRouter_js_1 = require("./abi/GaslessSwapRouter.js");
const Registry_js_1 = require("./abi/Registry.js");
const txutil_js_1 = require("./txutil.js");
const SUPPORTED_CHAIN_IDS = {
8217: "mainnet",
1001: "testnet",
1000: "local"
};
// GaslessSwapRouterAddress registry key
// https://github.com/kaiachain/kaia/blob/v2.0.0/contracts/contracts/system_contracts/multicall/MultiCallContract.sol#L140
const GASLESS_SWAP_ROUTER_NAME = "GaslessSwapRouter";
const REGISTRY_ADDRESS = "0x0000000000000000000000000000000000000401";
function validateChainId(chainId) {
const networkName = SUPPORTED_CHAIN_IDS[chainId];
if (!networkName) {
throw new Error(`Chain ID ${chainId} is not supported by this SDK. This SDK only supports Kaia networks.`);
}
return networkName;
}
/**
* Calculate the amount to repay based on whether approval is required and gas price
* @param approveRequired Whether approval transaction is required
* @param gasPrice Gas price in gkei (default: 25gkei)
* @returns The amount to repay
*/
function getAmountRepay(approveRequired, gasPrice = 25) {
const gasPriceBN = BigInt(Math.floor(gasPrice * 1e9));
const lendTxGas = BigInt(21000);
const approveTxGas = approveRequired ? BigInt(100000) : BigInt(0);
const swapTxGas = BigInt(500000);
const R1 = gasPriceBN * lendTxGas;
const R2 = gasPriceBN * approveTxGas;
const R3 = gasPriceBN * swapTxGas;
const amountRepay = R1 + R2 + R3;
return amountRepay.toString();
}
exports.getAmountRepay = getAmountRepay;
/**
* Get the gasless swap router for the specified chain
* @param provider The ethers provider
* @param chainId The chain ID
* @param address Override the address of the gasless swap router (optional)
* @returns The gasless swap router contract
*/
async function getGaslessSwapRouter(provider, chainId, address) {
let routerAddr;
if (!address) {
// Attempt to get the address from the KIP-149 registry
validateChainId(chainId);
const registry = new ethers_1.ethers.Contract(REGISTRY_ADDRESS, Registry_js_1.RegistryAbi, provider);
const addr = await registry.getActiveAddr(GASLESS_SWAP_ROUTER_NAME);
if (addr === undefined || addr === null || addr === ethers_1.ethers.ZeroHash) {
throw new Error(`There is no GaslessSwapRouter registered in the target chain (chainId: ${chainId})`);
}
routerAddr = addr;
}
else {
// Otherwise use specified address
routerAddr = address;
}
const contract = new ethers_1.ethers.Contract(routerAddr, GaslessSwapRouter_js_1.GaslessSwapRouterAbi.abi, provider);
const contractWithAddress = Object.assign(contract, { address: routerAddr });
return contractWithAddress;
}
exports.getGaslessSwapRouter = getGaslessSwapRouter;
/**
* Get the commission rate for the specified gasless swap router
* @param gsr The gasless swap router contract
* @returns The commission rate
*/
async function getCommissionRate(gsr) {
const rate = await gsr.commissionRate();
return Number(rate);
}
exports.getCommissionRate = getCommissionRate;
/**
* Calculate the minimum amount out based on amount to repay, app transaction fee, and commission rate
* @param amountRepay The amount to repay
* @param appTxFee The application transaction fee
* @param commissionRateBasisPoints The commission rate in basis points (e.g., 1000 = 10%)
* @returns The minimum amount out
*/
function getMinAmountOut(amountRepay, appTxFee, commissionRateBasisPoints) {
if (!Number.isInteger(commissionRateBasisPoints)) {
throw new Error("Commission rate must be an integer value in basis points");
}
if (commissionRateBasisPoints < 0 || commissionRateBasisPoints >= 10000) {
throw new Error("Commission rate must be between 0 and 9999 basis points");
}
// Calculate minimum amount out: appTxFee/(1 - commissionRate) + amountRepay
const appTxFeeBN = BigInt(appTxFee);
const amountRepayBN = BigInt(amountRepay);
const commissionRateBN = BigInt(commissionRateBasisPoints);
const denominator = BigInt(10000);
const adjustedFee = appTxFeeBN * denominator / (denominator - commissionRateBN);
const minAmountOut = adjustedFee + amountRepayBN;
return minAmountOut.toString();
}
exports.getMinAmountOut = getMinAmountOut;
/**
* Calculate the amount in based on minimum amount out and slippage
* @param gsr The gasless swap router contract
* @param token The token address
* @param minAmountOut The minimum amount out
* @param slippageBasisPoints The slippage basis point (e.g., 50 basis points = 0.5%)
* @returns The amount in
*/
async function getAmountIn(gsr, token, minAmountOut, slippageBasisPoints) {
const minAmountOutBN = BigInt(minAmountOut);
const slippageBN = BigInt(slippageBasisPoints);
const denominator = BigInt(10000);
const adjustedMinAmountOut = minAmountOutBN * (denominator + slippageBN) / denominator;
const amountIn = await gsr.getAmountIn(token, adjustedMinAmountOut.toString());
return amountIn.toString();
}
exports.getAmountIn = getAmountIn;
/**
* Generate a approve transaction
* @param provider The ethers provider
* @param fromAddress The sender address
* @param tokenAddr The token address
* @param routerAddress The router address
* @param amount The amount to approve
* @returns The approve transaction
*/
async function getApproveTx(provider, fromAddress, tokenAddr, routerAddress) {
try {
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
validateChainId(chainId);
const tokenAbi = [
"function approve(address spender, uint256 amount) external returns (bool)"
];
const tokenInterface = new ethers_1.ethers.Interface(tokenAbi);
// GaslessApproveTx's allowance is only set to MaxUint256.
// ref: https://github.com/kaiachain/kips/pull/64
const approveData = tokenInterface.encodeFunctionData("approve", [
routerAddress,
ethers_1.MaxUint256.toString()
]);
const nonce = await provider.getTransactionCount(fromAddress);
const feeData = await provider.getFeeData();
const gasPriceBN = feeData.gasPrice?.toString() || "25000000000";
return {
type: 0,
to: tokenAddr,
from: fromAddress,
nonce: nonce,
gasLimit: 100000,
gasPrice: gasPriceBN,
data: approveData,
value: 0n,
chainId: chainId,
};
}
catch (error) {
console.error("Error in getApproveTx:", error);
throw error;
}
}
exports.getApproveTx = getApproveTx;
/**
* Generate an swap transaction
* @param provider The ethers provider
* @param fromAddress The sender address
* @param tokenAddr The token address to swap
* @param amountIn The amount to swap
* @param minAmountOut The minimum amount out
* @param amountRepay The amount to repay
* @param isSingle Whether this is a single transaction (default: true)
* @param deadline The deadline in seconds (default: 1800)
* @returns The swap transaction
*/
async function getSwapTx(provider, fromAddress, tokenAddr, amountIn, minAmountOut, amountRepay, isSingle = true, deadline = 1800) {
try {
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
validateChainId(chainId);
const routerInfo = await getGaslessSwapRouter(provider, chainId);
const routerAddress = routerInfo.address;
const currentBlock = await provider.getBlock("latest");
if (!currentBlock) {
throw new Error("Failed to get latest block");
}
const deadlineTimestamp = currentBlock.timestamp + deadline;
const routerInterface = new ethers_1.ethers.Interface(GaslessSwapRouter_js_1.GaslessSwapRouterAbi.abi);
const swapData = routerInterface.encodeFunctionData("swapForGas", [
tokenAddr,
amountIn,
minAmountOut,
amountRepay,
deadlineTimestamp
]);
const baseNonce = await provider.getTransactionCount(fromAddress);
const nonceIncrement = isSingle ? 0 : 1;
const nonce = baseNonce + nonceIncrement;
const feeData = await provider.getFeeData();
const gasPriceBN = feeData.gasPrice?.toString() || "25000000000";
// Construct the transaction object
const tx = {
type: 0,
to: routerAddress,
from: fromAddress,
nonce: nonce,
gasLimit: 500000,
gasPrice: gasPriceBN,
data: swapData,
value: 0n,
chainId: chainId,
};
return tx;
}
catch (error) {
console.error("Error in getSwapTx:", error);
throw error;
}
}
exports.getSwapTx = getSwapTx;
/**
* Send gasless transactions
* @param approveTxOrNull The approve transaction or null if not needed
* @param swapTx The swap transaction
* @param provider Optional provider to use for sending transactions
* @returns Array of transaction hashes
*/
async function sendGaslessTx(approveTxOrNull, swapTx, provider) {
try {
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
validateChainId(chainId);
// Assert that provider is JsonRpcApiProvider
(0, ethers_1.assert)(provider instanceof ethers_1.JsonRpcApiProvider, "Provider is not JsonRpcApiProvider: cannot send kaia_sendRawTransactions", "UNSUPPORTED_OPERATION", {
operation: "sendGaslessTx",
});
if (approveTxOrNull) {
console.log("Sending both approve and swap transactions via RPC...");
return await provider.send("kaia_sendRawTransactions", [[approveTxOrNull, swapTx]]);
}
else {
return await provider.send("kaia_sendRawTransactions", [[swapTx]]);
}
}
catch (error) {
console.error("Error in sendGaslessTx:", error);
throw error;
}
}
exports.sendGaslessTx = sendGaslessTx;
/**
* Check if a token is supported for gasless transactions
* @param signer The ethers signer
* @param token The token address
* @param chainId The chain ID
* @returns True if the token is supported, false otherwise
*/
async function isGaslessSupportedToken(provider, token, chainId) {
try {
const gsr = await getGaslessSwapRouter(provider, chainId);
return await gsr.isTokenSupported(token);
}
catch (error) {
console.error("Error in isGaslessSupportedToken:", error);
return false;
}
}
exports.isGaslessSupportedToken = isGaslessSupportedToken;
/**
* Check if a transaction is a gasless approve transaction
* @param provider The ethers provider
* @param tx The transaction
* @param chainId The chain ID
* @returns True if the transaction is a gasless approve transaction, false otherwise
*/
async function isGaslessApprove(provider, tx, chainId) {
try {
const txRequest = await (0, txutil_js_1.getTransactionRequest)(tx);
if (!txRequest.data || !txRequest.to) {
return false;
}
// A1: GaslessApproveTx.to is a whitelisted ERC-20 token.
const isTokenSupported = await isGaslessSupportedToken(provider, txRequest.to.toString(), chainId);
if (!isTokenSupported) {
return false;
}
// A2: GaslessApproveTx.data is approve(spender, amount).
const dataPrefix = txRequest.data.toString().slice(0, 10);
const approveMethodId = "0x095ea7b3";
if (dataPrefix !== approveMethodId) {
return false;
}
const data = txRequest.data.toString();
const spenderData = "0x" + data.slice(34, 74);
const amountData = "0x" + data.slice(74);
// A3: spender is a whitelisted GaslessSwapRouter.
const router = await getGaslessSwapRouter(provider, chainId);
if (spenderData.toLowerCase() !== router.address.toLowerCase()) {
return false;
}
// A4: amount is MaxUint256.
const amount = BigInt(amountData);
if (amount !== ethers_1.MaxUint256) {
return false;
}
// A5: nonce is getNonce(tx.from).
if (txRequest.nonce !== undefined && txRequest.nonce !== null && txRequest.from) {
const expectedNonce = await provider.getTransactionCount(txRequest.from);
if (BigInt(txRequest.nonce.toString()) !== BigInt(expectedNonce)) {
return false;
}
}
return true;
}
catch (error) {
console.error("Error in isGaslessApprove:", error);
return false;
}
}
exports.isGaslessApprove = isGaslessApprove;
// Add this helper function near the top of the file after imports
function getFunctionSelector(func) {
return ethers_1.ethers.id(func).slice(0, 10);
}
// Helper functions for isGaslessSwap
function isValidSwapTxFormat(txRequest) {
return !!(txRequest.data && txRequest.to);
}
exports.isValidSwapTxFormat = isValidSwapTxFormat;
async function isValidRouterAddress(provider, txRequest, chainId) {
if (!txRequest.to) {
return false;
}
const router = await getGaslessSwapRouter(provider, chainId);
return txRequest.to.toLowerCase() === router.address.toLowerCase();
}
exports.isValidRouterAddress = isValidRouterAddress;
function validateAndDecodeSwapFunction(data) {
try {
const functionSelector = data.slice(0, 10);
const expectedSelector = getFunctionSelector("swapForGas(address,uint256,uint256,uint256,uint256)");
if (functionSelector !== expectedSelector) {
return { isValid: false };
}
const inputData = "0x" + data.slice(10);
const abiCoder = ethers_1.ethers.AbiCoder.defaultAbiCoder();
const paramTypes = ["address", "uint256", "uint256", "uint256", "uint256"];
const decodedParams = abiCoder.decode(paramTypes, inputData);
return {
isValid: true,
decodedParams: {
tokenData: decodedParams[0],
amountInData: decodedParams[1].toString(),
amountRepayData: decodedParams[3].toString()
}
};
}
catch (error) {
console.error("Error decoding swap parameters:", error);
return { isValid: false };
}
}
exports.validateAndDecodeSwapFunction = validateAndDecodeSwapFunction;
function validateApproveToken(approveTxRequest, tokenData) {
return approveTxRequest.to?.toLowerCase() === tokenData.toLowerCase();
}
exports.validateApproveToken = validateApproveToken;
function validateApproveAmount(approveTxRequest, amountInData) {
const approveData = approveTxRequest.data?.toString() || "";
const approveAmountData = "0x" + approveData.slice(74);
const approveAmount = BigInt(approveAmountData);
const amountIn = BigInt(amountInData);
return approveAmount >= amountIn;
}
exports.validateApproveAmount = validateApproveAmount;
async function validateNonceWithApprove(provider, approveTxRequest, swapTxRequest) {
if (swapTxRequest.nonce === undefined || swapTxRequest.nonce === null ||
approveTxRequest.nonce === undefined || approveTxRequest.nonce === null) {
return false;
}
// Approve transaction nonce + 1 = Swap transaction nonce
if (BigInt(approveTxRequest.nonce.toString()) + BigInt(1) !== BigInt(swapTxRequest.nonce.toString())) {
return false;
}
// Approve transaction nonce = Current nonce
if (swapTxRequest.from) {
try {
const currentNonce = await provider.getTransactionCount(swapTxRequest.from);
if (BigInt(approveTxRequest.nonce.toString()) !== BigInt(currentNonce)) {
return false;
}
}
catch (error) {
return false;
}
}
return true;
}
exports.validateNonceWithApprove = validateNonceWithApprove;
function validateAmountRepayWithApprove(swapTxRequest, amountRepayData) {
const gasPrice = swapTxRequest.gasPrice?.toString() || "25000000000";
const expectedAmountRepay = getAmountRepay(true, Number(gasPrice) / 1000000000);
if (BigInt(amountRepayData) !== BigInt(expectedAmountRepay)) {
return false;
}
return true;
}
exports.validateAmountRepayWithApprove = validateAmountRepayWithApprove;
async function validateNonceWithoutApprove(provider, swapTxRequest) {
if (swapTxRequest.nonce === undefined || swapTxRequest.nonce === null) {
return false;
}
if (swapTxRequest.from) {
try {
const currentNonce = await provider.getTransactionCount(swapTxRequest.from);
if (BigInt(swapTxRequest.nonce.toString()) !== BigInt(currentNonce)) {
return false;
}
}
catch (error) {
console.error("Error checking nonce:", error);
return false;
}
}
return true;
}
exports.validateNonceWithoutApprove = validateNonceWithoutApprove;
function validateAmountRepayWithoutApprove(swapTxRequest, amountRepayData) {
const gasPrice = swapTxRequest.gasPrice?.toString() || "25000000000";
const expectedAmountRepay = getAmountRepay(false, Number(gasPrice) / 1000000000);
if (BigInt(amountRepayData) !== BigInt(expectedAmountRepay)) {
return false;
}
return true;
}
exports.validateAmountRepayWithoutApprove = validateAmountRepayWithoutApprove;
async function validateWithApprove(provider, approveTx, swapTxRequest, tokenData, amountInData, amountRepayData, chainId) {
const isApprove = await isGaslessApprove(provider, approveTx, chainId);
if (!isApprove) {
return false;
}
const approveTxRequest = await (0, txutil_js_1.getTransactionRequest)(approveTx);
// SP1: GaslessApproveTx.to=token
if (!validateApproveToken(approveTxRequest, tokenData)) {
return false;
}
// SP2: GaslessApproveTx.data.amount>=amountIn
if (!validateApproveAmount(approveTxRequest, amountInData)) {
return false;
}
// SP3: Nonce is the correct value
if (!await validateNonceWithApprove(provider, approveTxRequest, swapTxRequest)) {
return false;
}
// SP4: amountRepay is the correct value
if (!validateAmountRepayWithApprove(swapTxRequest, amountRepayData)) {
return false;
}
return true;
}
exports.validateWithApprove = validateWithApprove;
async function validateWithoutApprove(provider, swapTxRequest, amountRepayData) {
// SP3: Nonce is the correct value
if (!await validateNonceWithoutApprove(provider, swapTxRequest)) {
return false;
}
// SP4: amountRepay is the correct value
if (!validateAmountRepayWithoutApprove(swapTxRequest, amountRepayData)) {
return false;
}
return true;
}
exports.validateWithoutApprove = validateWithoutApprove;
/**
* Check if transactions form a valid gasless swap
* @param approveTxOrNull The approve transaction or null if not needed
* @param swapTx The swap transaction
* @param chainId The chain ID
* @param provider The ethers provider
* @returns True if the transactions form a valid gasless swap, false otherwise
*/
async function isGaslessSwap(provider, approveTxOrNull, swapTx, chainId) {
try {
const swapTxRequest = await (0, txutil_js_1.getTransactionRequest)(swapTx);
// Basic validation
if (!isValidSwapTxFormat(swapTxRequest)) {
return false;
}
// S1: Router address validation
if (!await isValidRouterAddress(provider, swapTxRequest, chainId)) {
return false;
}
// S2: Function selector validation and parameter decoding
const { isValid, decodedParams } = validateAndDecodeSwapFunction(swapTxRequest.data?.toString() || "");
if (!isValid || !decodedParams) {
return false;
}
const { tokenData, amountInData, amountRepayData } = decodedParams;
// S3: Token support validation
const isTokenSupported = await isGaslessSupportedToken(provider, tokenData, chainId);
if (!isTokenSupported) {
return false;
}
// Validation with or without approve transaction
if (approveTxOrNull) {
if (!await validateWithApprove(provider, approveTxOrNull, swapTxRequest, tokenData, amountInData, amountRepayData, chainId)) {
return false;
}
}
else {
if (!await validateWithoutApprove(provider, swapTxRequest, amountRepayData)) {
return false;
}
}
return true;
}
catch (error) {
console.error("Error in isGaslessSwap:", error);
return false;
}
}
exports.isGaslessSwap = isGaslessSwap;