Skip to content

Commit

Permalink
test: add for _canTransferDirectly
Browse files Browse the repository at this point in the history
  • Loading branch information
hhio618 committed Jan 16, 2025
1 parent 1715f26 commit 4a0eff4
Show file tree
Hide file tree
Showing 8 changed files with 398 additions and 234 deletions.
22 changes: 17 additions & 5 deletions src/helpers/web3.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RPCHandler, HandlerConstructorConfig, NetworkId } from "@ubiquity-dao/rpc-handler";
import { ethers, utils, Contract, Wallet } from "ethers";
import { ethers, utils, Contract, Wallet, BigNumber } from "ethers";

// Required ERC20 ABI functions
export const ERC20_ABI = [
Expand Down Expand Up @@ -79,20 +79,32 @@ export class Erc20Wrapper {
* @param address input address
* @returns ERC20 token balance of the input address
*/
async getBalance(address: string) {
async getBalance(address: string): Promise<BigNumber> {
return await this._contract.balanceOf(address);
}

/**
* Returns Fee estimation of erc20 transfer request
* @param address input address
* @param normalizedAmount Human readable amount of ERC20 token to be transferred
* @returns Fee estimation of erc20 transfer request
*/
async estimateTransferGas(from: string, to: string, normalizedAmount: number) {
const tokenDecimals = await this.getDecimals();
const _amount = utils.parseUnits(normalizedAmount.toString(), tokenDecimals);
return await this._contract.estimateGas.transfer(to, _amount, { from });
}

/**
* Returns Transaction data of the ERC20 token transfer
* @param evmWallet Wallet to transfer ERC20 token from
* @param address Address to send ERC20 token
* @param amount Amount of ERC20 token to be transferred
* @param normalizedAmount Human readable amount of ERC20 token to be transferred
* @returns Transaction data of the ERC20 token transfer
*/
async sendTransferTransaction(evmWallet: Wallet, address: string, amount: string) {
async sendTransferTransaction(evmWallet: Wallet, address: string, normalizedAmount: number) {
const tokenDecimals = await this.getDecimals();
const _amount = utils.parseUnits(amount, tokenDecimals);
const _amount = utils.parseUnits(normalizedAmount.toString(), tokenDecimals);

// Create the signed transaction
try {
Expand Down
193 changes: 97 additions & 96 deletions src/parser/payment-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface Payload {
}

interface Payable {
[username: string]: string;
[username: string]: { address: string; reward: number };
}

export class PaymentModule extends BaseModule {
Expand All @@ -49,7 +49,6 @@ export class PaymentModule extends BaseModule {
readonly _erc20RewardToken: string = this.context.config.erc20RewardToken;
readonly _supabase = createClient<Database>(this.context.env.SUPABASE_URL, this.context.env.SUPABASE_KEY);

// eslint-disable-next-line sonarjs/cognitive-complexity
async transform(data: Readonly<IssueActivity>, result: Result): Promise<Result> {
const networkExplorer = await this._getNetworkExplorer(this._evmNetworkId);
const canMakePayment = await this._canMakePayment(data);
Expand All @@ -72,14 +71,7 @@ export class PaymentModule extends BaseModule {
const env = this.context.env;

// Decrypt the private key object
let privateKeyParsed;
try {
privateKeyParsed = await this._parsePrivateKey(this._evmPrivateEncrypted);
} catch (e) {
this.context.logger.error("[PaymentModule] Private key could not be parsed.", { e });
return Promise.resolve(result);
}

const privateKeyParsed = await this._parsePrivateKey(this._evmPrivateEncrypted);
const isPrivateKeyAllowed = await this._isPrivateKeyAllowed(
privateKeyParsed,
this.context.payload.repository.owner.id,
Expand Down Expand Up @@ -117,15 +109,26 @@ export class PaymentModule extends BaseModule {
// apply fees
result = await this._applyFees(result, payload.erc20RewardToken);

// Check if funding wallet has enough reward token and gas to transfer rewards directly
const [canTransferDirectly, erc20Wrapper, fundingWallet, payables] = await this._canTransferDirectly(
privateKeyParsed.privateKey,
result
);

if (this._autoTransferMode && canTransferDirectly) {
this.context.logger.debug(
"[PaymentModule] AutoTransformMode is enabled, and the funding wallet has sufficient funds available."
if (this._autoTransferMode) {
// Check if funding wallet has enough reward token and gas to transfer rewards directly
const [canTransferDirectly, erc20Wrapper, fundingWallet, payables] = await this._canTransferDirectly(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
privateKeyParsed.privateKey!,
result
);
if (canTransferDirectly) {
this.context.logger.info(
"[PaymentModule] AutoTransformMode is enabled, and the funding wallet has sufficient funds available."
);
for (const [username, reward] of Object.entries(result)) {
const beneficiaryWalletAddress = payables[username].address;
const tx = await erc20Wrapper.sendTransferTransaction(fundingWallet, beneficiaryWalletAddress, reward.total);
result[username].explorerUrl = `${networkExplorer}/tx/${tx?.hash}`;
}
return result;
}
this.context.logger.info(
"[PaymentModule] AutoTransformMode is enabled, but the funding wallet lacks sufficient funds. Skipping."
);
}

Expand All @@ -144,44 +147,33 @@ export class PaymentModule extends BaseModule {
},
],
};

if (this._autoTransferMode && canTransferDirectly) {
const beneficiaryWalletAddress = payables[username];
const tx = await erc20Wrapper?.sendTransferTransaction(
fundingWallet,
beneficiaryWalletAddress,
reward.total.toString()
);
result[username].explorerUrl = `${networkExplorer}/tx/${tx?.hash}`;
} else {
try {
const permits = await generatePayoutPermit(
{
try {
const permits = await generatePayoutPermit(
{
env,
eventName,
logger: permitLogger,
payload,
adapters: createAdapters(this._supabase, {
env,
eventName,
logger: permitLogger,
payload,
adapters: createAdapters(this._supabase, {
env,
eventName,
octokit,
config,
logger: permitLogger,
payload,
adapters,
}),
octokit,
config,
},
config.permitRequests
);
result[username].permitUrl = `https://pay.ubq.fi?claim=${encodePermits(permits)}`;
await this._savePermitsToDatabase(result[username].userId, { issueUrl: payload.issueUrl, issueId }, permits);
// remove treasury item from final result in order not to display permit fee in GitHub comments
if (env.PERMIT_TREASURY_GITHUB_USERNAME) delete result[env.PERMIT_TREASURY_GITHUB_USERNAME];
} catch (e) {
this.context.logger.error(`[PaymentModule] Failed to generate permits for user ${username}`, { e });
}
logger: permitLogger,
payload,
adapters,
}),
octokit,
config,
},
config.permitRequests
);
result[username].permitUrl = `https://pay.ubq.fi?claim=${encodePermits(permits)}`;
await this._savePermitsToDatabase(result[username].userId, { issueUrl: payload.issueUrl, issueId }, permits);
// remove treasury item from final result in order not to display permit fee in GitHub comments
if (env.PERMIT_TREASURY_GITHUB_USERNAME) delete result[env.PERMIT_TREASURY_GITHUB_USERNAME];
} catch (e) {
this.context.logger.error(`[PaymentModule] Failed to generate permits for user ${username}`, { e });
}
}
return result;
Expand All @@ -203,23 +195,6 @@ export class PaymentModule extends BaseModule {
return isCollaborative(data);
}

async _parsePrivateKey(evmPrivateEncrypted: string) {
try {
const privateKeyDecrypted = await decrypt(evmPrivateEncrypted, String(process.env.X25519_PRIVATE_KEY));
const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted);
if (!privateKeyParsed.privateKey) {
throw new Error("Private key is null or empty");
}
return {
privateKey: privateKeyParsed.privateKey,
allowedOrganizationId: privateKeyParsed.allowedOrganizationId,
allowedRepositoryId: privateKeyParsed.allowedRepositoryId,
};
} catch (error) {
throw new Error(this.context.logger.error(`Failed to decrypt a private key: ${error}`).logMessage.raw);
}
}

/*
* This method checks that the funding wallet has enough reward tokens for a direct transfer and sufficient funds to cover gas fees.
* @param private key of the funding wallet
Expand All @@ -229,7 +204,7 @@ export class PaymentModule extends BaseModule {
async _canTransferDirectly(
privateKey: string,
result: Result
): Promise<[boolean, Erc20Wrapper, ethers.Wallet, Payable] | [false, null, null, null]> {
): Promise<[true, Erc20Wrapper, ethers.Wallet, Payable] | [false, null, null, null]> {
try {
const tokenContract = await getErc20TokenContract(this._evmNetworkId, this._erc20RewardToken);
const fundingWallet = await getEvmWallet(privateKey, tokenContract.provider);
Expand All @@ -242,47 +217,63 @@ export class PaymentModule extends BaseModule {
const fundingWalletNativeTokenBalance = await fundingWallet.getBalance();

const payables = await this._getPayables(result);
const totalFee = BigNumber.from("0");
const totalReward = BigNumber.from("0");
for (const [address, reward] of Object.entries(payables)) {
const rewardAmount = parseUnits(reward, decimals);
const fee = await tokenContract.estimateGas.transfer(address, rewardAmount);
totalFee.add(fee);
totalReward.add(rewardAmount);
if (payables == null) {
return [false, null, null, null];
}
let totalFee = BigNumber.from("0");
let totalReward = BigNumber.from("0");
for (const data of Object.values(payables)) {
const fee = await erc20Wrapper.estimateTransferGas(fundingWallet.address, data.address, data.reward);
totalFee = totalFee.add(fee);
totalReward = totalReward.add(parseUnits(data.reward.toString(), decimals));
}

this.context.logger.info(`[PaymentModule] Funding Wallet Balances`, {
rewardTokenBalance: fundingWalletRewardTokenBalance.toString(),
nativeTokenBalance: fundingWalletNativeTokenBalance.toString(),
});

return [
fundingWalletNativeTokenBalance.gt(totalFee) && fundingWalletRewardTokenBalance.gt(totalReward),
erc20Wrapper,
fundingWallet,
payables,
];
const hasEnoughRewardToken = fundingWalletNativeTokenBalance.gt(totalFee);
const hasEnoughGas = fundingWalletRewardTokenBalance.gt(totalReward);
const gasAndRewardInfo = {
gas: {
has: fundingWalletNativeTokenBalance.toString(),
required: totalFee.toString(),
},
rewardToken: {
has: fundingWalletRewardTokenBalance.toString(),
required: totalReward.toString(),
},
};
if (!hasEnoughGas || !hasEnoughRewardToken) {
this.context.logger.error(
`[PaymentModule] The funding wallet lacks sufficient gas and/or reward tokens to perform direct transfers`,
gasAndRewardInfo
);
return [false, null, null, null];
}
this.context.logger.info(
`[PaymentModule] The funding wallet has sufficient gas and reward tokens to perform direct transfers`,
gasAndRewardInfo
);
return [true, erc20Wrapper, fundingWallet, payables];
} catch (e) {
this.context.logger.error("[PaymentModule] Failed to fetch the funding wallet data", { e });

this.context.logger.error(`[PaymentModule] Failed to fetch the funding wallet data: ${e}`, { e });
return [false, null, null, null];
}
}

async _getPayables(result: Result): Promise<Payable> {
async _getPayables(result: Result): Promise<Payable | null> {
const addresses: Payable = {};
for (const username of Object.keys(result)) {
for (const [username, reward] of Object.entries(result)) {
// Obtain the beneficiary wallet address from the github user name
const { data: userData } = await this.context.octokit.rest.users.getByUsername({ username });
if (!userData) {
throw new Error(this.context.logger.error(`GitHub user was not found for id ${username}`).logMessage.raw);
this.context.logger.error(`GitHub user was not found for id ${username}`);
return null;
}
const userId = userData.id;
const { data: walletData } = await this._supabase.from("wallets").select("address").eq("id", userId).single();
if (!walletData?.address) {
throw new Error(this.context.logger.error("Beneficiary wallet not found").logMessage.raw);
this.context.logger.error("Beneficiary wallet not found");
return null;
}
addresses[username] = walletData.address;
addresses[username] = { address: walletData.address, reward: reward.total };
}
return addresses;
}
Expand Down Expand Up @@ -447,6 +438,16 @@ export class PaymentModule extends BaseModule {
}
}

async _parsePrivateKey(evmPrivateEncrypted: string) {
const privateKeyDecrypted = await decrypt(evmPrivateEncrypted, String(process.env.X25519_PRIVATE_KEY));
const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted);
return {
privateKey: privateKeyParsed.privateKey,
allowedOrganizationId: privateKeyParsed.allowedOrganizationId,
allowedRepositoryId: privateKeyParsed.allowedRepositoryId,
};
}

/**
* Checks whether partner's private key is allowed to be used in current repository.
*
Expand Down
40 changes: 18 additions & 22 deletions tests/fees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,28 @@ import { ContextPlugin } from "../src/types/plugin-input";
import { Result } from "../src/types/results";
import cfg from "./__mocks__/results/valid-configuration.json";
import { parseUnits } from "ethers/lib/utils";
import { BigNumber } from "ethers";

const issueUrl = "https://github.com/ubiquity/work.ubq.fi/issues/69";

class MockErc20Wrapper {
getSymbol = () => {
return "WXDAI";
const mockRewardTokenBalance = jest.fn().mockReturnValue(parseUnits("200", 18) as BigNumber);
jest.unstable_mockModule("../src/helpers/web3", () => {
class MockErc20Wrapper {
getBalance = mockRewardTokenBalance;
getSymbol = jest.fn().mockReturnValue("WXDAI");
getDecimals = jest.fn().mockReturnValue(18);
sendTransferTransaction = jest.fn().mockReturnValue("0xTransactionHash");
estimateTransferGas = jest.fn().mockReturnValue(parseUnits("0.004", 18));
}
return {
Erc20Wrapper: MockErc20Wrapper,
getErc20TokenContract: jest.fn().mockReturnValue({ provider: "dummy" }),
getEvmWallet: jest.fn(() => ({
address: "0xAddress",
getBalance: jest.fn().mockReturnValue(parseUnits("1", 18)),
})),
};
getBalance = () => {
return parseUnits("100", 18);
};
getDecimals = () => {
return 18;
};
sendTransferTransaction = () => {
return { hash: "0xTransactionHash" };
};
}
jest.unstable_mockModule("../src/helpers/web3", () => ({
Erc20Wrapper: MockErc20Wrapper,
getErc20TokenContract() {
return { provider: "dummy" };
},
getEvmWallet() {
return { address: "0xAddress" };
},
}));
});

jest.unstable_mockModule("@actions/github", () => ({
default: {},
Expand Down
Loading

0 comments on commit 4a0eff4

Please sign in to comment.