diff --git a/src/helpers/web3.ts b/src/helpers/web3.ts index 0112f445..4368e8d9 100644 --- a/src/helpers/web3.ts +++ b/src/helpers/web3.ts @@ -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 = [ @@ -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 { 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 { diff --git a/src/parser/payment-module.ts b/src/parser/payment-module.ts index a0ac386f..48da1d88 100644 --- a/src/parser/payment-module.ts +++ b/src/parser/payment-module.ts @@ -38,7 +38,7 @@ interface Payload { } interface Payable { - [username: string]: string; + [username: string]: { address: string; reward: number }; } export class PaymentModule extends BaseModule { @@ -49,7 +49,6 @@ export class PaymentModule extends BaseModule { readonly _erc20RewardToken: string = this.context.config.erc20RewardToken; readonly _supabase = createClient(this.context.env.SUPABASE_URL, this.context.env.SUPABASE_KEY); - // eslint-disable-next-line sonarjs/cognitive-complexity async transform(data: Readonly, result: Result): Promise { const networkExplorer = await this._getNetworkExplorer(this._evmNetworkId); const canMakePayment = await this._canMakePayment(data); @@ -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, @@ -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." ); } @@ -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; @@ -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 @@ -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); @@ -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 { + async _getPayables(result: Result): Promise { 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; } @@ -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. * diff --git a/tests/fees.test.ts b/tests/fees.test.ts index 7d7e6c6e..350240fd 100644 --- a/tests/fees.test.ts +++ b/tests/fees.test.ts @@ -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: {}, diff --git a/tests/helpers/web3.test.ts b/tests/helpers/web3.test.ts index 6e4e5e31..6fbc7740 100644 --- a/tests/helpers/web3.test.ts +++ b/tests/helpers/web3.test.ts @@ -8,6 +8,9 @@ const mockContract = { symbol: jest.fn().mockReturnValue("WXDAI"), decimals: jest.fn().mockReturnValue(18), transfer: jest.fn().mockReturnValue("0xTransactionData"), + estimateGas: { + transfer: jest.fn().mockReturnValue(parseUnits("0.004", 18)), + }, }; const mockWallet = { @@ -48,13 +51,14 @@ describe("web3.ts", () => { }, 120000); it("Should return a valid tx", async () => { - const tx = await erc20Wrapper.sendTransferTransaction( - mockWallet as unknown as ethers.Wallet, - "0xRecipient", - "1000" - ); + const tx = await erc20Wrapper.sendTransferTransaction(mockWallet as unknown as ethers.Wallet, "0xRecipient", 1000); expect(mockContract.transfer).toHaveBeenCalledWith("0xRecipient", parseUnits("1000", 18)); expect(mockWallet.sendTransaction).toHaveBeenCalledWith("0xTransactionData"); expect(tx.hash).toEqual("0xTransactionHash"); }, 120000); + + it("Should estimates transfer fee correctly", async () => { + const estimate = await erc20Wrapper.estimateTransferGas("0xfrom", "oxto", 100); + expect(estimate).toEqual(parseUnits("0.004", 18)); + }, 120000); }); diff --git a/tests/parser/payment-module.test.ts b/tests/parser/payment-module.test.ts index 4d14210a..4d97680a 100644 --- a/tests/parser/payment-module.test.ts +++ b/tests/parser/payment-module.test.ts @@ -10,6 +10,7 @@ import dbSeed from "../__mocks__/db-seed.json"; import { server } from "../__mocks__/node"; import cfg from "../__mocks__/results/valid-configuration.json"; import { parseUnits } from "ethers/lib/utils"; +import { BigNumber } from "ethers"; const DOLLAR_ADDRESS = "0xb6919Ef2ee4aFC163BC954C5678e2BB570c2D103"; const WXDAI_ADDRESS = "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"; @@ -53,33 +54,28 @@ jest.unstable_mockModule("@supabase/supabase-js", () => { }; }); -class MockErc20Wrapper { - getSymbol = () => { - return "WXDAI"; - }; - getBalance = () => { - return parseUnits("100", 18); - }; - getDecimals = () => { - return 18; - }; - sendTransferTransaction = () => { - return { hash: "0xTransactionHash" }; +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)), + })), }; -} -jest.unstable_mockModule("../../src/helpers/web3", () => ({ - Erc20Wrapper: MockErc20Wrapper, - getErc20TokenContract() { - return { provider: "dummy" }; - }, - getEvmWallet() { - return { address: "0xAddress" }; - }, -})); +}); // original rewards object before fees are applied const resultOriginal: Result = { - user1: { + molecula451: { total: 100, task: { reward: 90, @@ -99,7 +95,7 @@ const resultOriginal: Result = { }, ], }, - user2: { + "0x4007": { total: 11.11, task: { reward: 9.99, @@ -127,11 +123,11 @@ jest.unstable_mockModule("@supabase/supabase-js", () => { from: jest.fn(() => ({ select: jest.fn(() => ({ // eslint-disable-next-line sonarjs/no-nested-functions - eq: jest.fn(() => ({ + eq: jest.fn((id, value) => ({ single: jest.fn(() => ({ data: { - id: 1, - address: "0x1", + id: value === "molecula451" ? 1 : 2, + address: "0xAddress", }, })), })), @@ -214,19 +210,19 @@ describe("payment-module.ts", () => { const resultAfterFees = await paymentModule._applyFees(resultOriginal, WXDAI_ADDRESS); // check that 10% fee is subtracted from rewards - expect(resultAfterFees["user1"].total).toEqual(90); - expect(resultAfterFees["user1"].task?.reward).toEqual(81); - expect(resultAfterFees["user1"].comments?.[0].score?.reward).toEqual(9); - expect(resultAfterFees["user2"].total).toEqual(10); - expect(resultAfterFees["user2"].task?.reward).toEqual(8.99); - expect(resultAfterFees["user2"].comments?.[0].score?.reward).toEqual(1.01); + expect(resultAfterFees["molecula451"].total).toEqual(90); + expect(resultAfterFees["molecula451"].task?.reward).toEqual(81); + expect(resultAfterFees["molecula451"].comments?.[0].score?.reward).toEqual(9); + expect(resultAfterFees["0x4007"].total).toEqual(10); + expect(resultAfterFees["0x4007"].task?.reward).toEqual(8.99); + expect(resultAfterFees["0x4007"].comments?.[0].score?.reward).toEqual(1.01); // check that treasury item is added expect(resultAfterFees["ubiquity-os-treasury"].total).toEqual(11.11); }); }); - describe("Auto transfer mode tests", () => { + describe("_getNetworkExplorer()", () => { beforeEach(() => { ctx.env.PERMIT_FEE_RATE = ""; drop(db); @@ -239,7 +235,6 @@ describe("payment-module.ts", () => { }); afterEach(() => { - // restore the spy created with spyOn jest.restoreAllMocks(); }); @@ -248,19 +243,187 @@ describe("payment-module.ts", () => { const url = await paymentModule._getNetworkExplorer(100); expect(url).toMatch(/http.*/); }); + }); + + describe("_getPayables()", () => { + beforeEach(() => { + ctx.env.PERMIT_FEE_RATE = ""; + drop(db); + for (const table of Object.keys(dbSeed)) { + const tableName = table as keyof typeof dbSeed; + for (const row of dbSeed[tableName]) { + db[tableName].create(row); + } + } + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); it("Should return the correct total payable amount", async () => { const paymentModule = new PaymentModule(ctx); - const amount = await paymentModule._getTotalPayable(resultOriginal); - expect(amount).toEqual(111.11); + const payable = await paymentModule._getPayables(resultOriginal); + expect(payable == null).toEqual(false); + let totalPayable = 0; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const data of Object.values(payable!)) { + totalPayable += data.reward; + } + expect(totalPayable).toEqual(111.11); + }); + }); + + describe("_canTransferDirectly()", () => { + beforeEach(() => { + ctx.env.PERMIT_FEE_RATE = ""; + drop(db); + for (const table of Object.keys(dbSeed)) { + const tableName = table as keyof typeof dbSeed; + for (const row of dbSeed[tableName]) { + db[tableName].create(row); + } + } + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Should return true when the funding wallet has enough reward tokens and gas", async () => { + const paymentModule = new PaymentModule(ctx); + const spyConsoleLog = jest.spyOn(ctx.logger, "info"); + + ctx.env.X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + const [canTransferDirectly, erc20Wrapper, fundingWallet, payables] = await paymentModule._canTransferDirectly( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + resultOriginal + ); + expect(canTransferDirectly).toEqual(true); + expect(erc20Wrapper).not.toBeNull(); + expect(fundingWallet).not.toBeNull(); + expect(payables).not.toBeNull(); + + const logCallArgs = spyConsoleLog.mock.calls.map((call) => call[0]); + expect(logCallArgs[0]).toMatch( + /.*The funding wallet has sufficient gas and reward tokens to perform direct transfers/ + ); + const logCallMetadata = spyConsoleLog.mock.calls.map((call) => call[1])[0] as { + [key: string]: { [key: string]: string }; + }; + expect(logCallMetadata).not.toBeUndefined(); + + expect(logCallMetadata.gas.has).toEqual(parseUnits("1", 18).toString()); + expect(logCallMetadata.gas.required).toEqual(parseUnits("0.012", 18).toString()); + expect(logCallMetadata.rewardToken.has).toEqual(parseUnits("200", 18).toString()); + expect(logCallMetadata.rewardToken.required).toEqual(parseUnits("111.11", 18).toString()); + + spyConsoleLog.mockReset(); + }); + + it("Should return false if the funding wallet has enough reward tokens but insufficient gas", async () => { + const { getEvmWallet } = await import("../../src/helpers/web3"); + + const mockedGetEvmWallet = getEvmWallet as jest.Mock; + mockedGetEvmWallet.mockImplementationOnce(() => ({ + address: "0xOverriddenAddress", + getBalance: jest.fn().mockReturnValue(parseUnits("0.004", 18)), + })); + const paymentModule = new PaymentModule(ctx); + const spyConsoleLog = jest.spyOn(ctx.logger, "error"); + + ctx.env.X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + const [canTransferDirectly, , ,] = await paymentModule._canTransferDirectly( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + resultOriginal + ); + expect(canTransferDirectly).toEqual(false); + + const logCallArgs = spyConsoleLog.mock.calls.map((call) => call[0]); + expect(logCallArgs[0]).toMatch( + /.*The funding wallet lacks sufficient gas and\/or reward tokens to perform direct transfers/ + ); + const logCallMetadata = spyConsoleLog.mock.calls.map((call) => call[1])[0] as { + [key: string]: { [key: string]: string }; + }; + expect(logCallMetadata).not.toBeUndefined(); + + expect(logCallMetadata.gas.has).toEqual(parseUnits("0.004", 18).toString()); + expect(logCallMetadata.gas.required).toEqual(parseUnits("0.012", 18).toString()); + expect(logCallMetadata.rewardToken.has).toEqual(parseUnits("200", 18).toString()); + expect(logCallMetadata.rewardToken.required).toEqual(parseUnits("111.11", 18).toString()); + + spyConsoleLog.mockReset(); + }); + + it("Should return false if the funding wallet has insufficient gas and reward tokens", async () => { + const { getEvmWallet } = await import("../../src/helpers/web3"); + + const mockedGetEvmWallet = getEvmWallet as jest.Mock; + mockedGetEvmWallet.mockImplementationOnce(() => ({ + address: "0xOverriddenAddress", + getBalance: jest.fn().mockReturnValue(parseUnits("0.004", 18)), + })); + mockRewardTokenBalance.mockReturnValueOnce(parseUnits("50", 18)); + + const paymentModule = new PaymentModule(ctx); + const spyConsoleLog = jest.spyOn(ctx.logger, "error"); + + ctx.env.X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + const [canTransferDirectly, , ,] = await paymentModule._canTransferDirectly( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + resultOriginal + ); + expect(canTransferDirectly).toEqual(false); + + const logCallArgs = spyConsoleLog.mock.calls.map((call) => call[0]); + expect(logCallArgs[0]).toMatch( + /.*The funding wallet lacks sufficient gas and\/or reward tokens to perform direct transfers/ + ); + const logCallMetadata = spyConsoleLog.mock.calls.map((call) => call[1])[0] as { + [key: string]: { [key: string]: string }; + }; + expect(logCallMetadata).not.toBeUndefined(); + + expect(logCallMetadata.gas.has).toEqual(parseUnits("0.004", 18).toString()); + expect(logCallMetadata.gas.required).toEqual(parseUnits("0.012", 18).toString()); + expect(logCallMetadata.rewardToken.has).toEqual(parseUnits("50", 18).toString()); + expect(logCallMetadata.rewardToken.required).toEqual(parseUnits("111.11", 18).toString()); + + spyConsoleLog.mockReset(); }); - it("Should return the correct beneficiary wallet address", async () => { + it("Should return false if the funding wallet has enough gas but insufficient reward token", async () => { + mockRewardTokenBalance.mockReturnValueOnce(parseUnits("50", 18)); + const paymentModule = new PaymentModule(ctx); - const address = await paymentModule._getBeneficiaryWalletAddress("molecula451"); - expect(address).toEqual("0x1"); + const spyConsoleLog = jest.spyOn(ctx.logger, "error"); + + ctx.env.X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + const [canTransferDirectly, , ,] = await paymentModule._canTransferDirectly( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + resultOriginal + ); + expect(canTransferDirectly).toEqual(false); + + const logCallArgs = spyConsoleLog.mock.calls.map((call) => call[0]); + expect(logCallArgs[0]).toMatch( + /.*The funding wallet lacks sufficient gas and\/or reward tokens to perform direct transfers/ + ); + const logCallMetadata = spyConsoleLog.mock.calls.map((call) => call[1])[0] as { + [key: string]: { [key: string]: string }; + }; + expect(logCallMetadata).not.toBeUndefined(); + + expect(logCallMetadata.gas.has).toEqual(parseUnits("1", 18).toString()); + expect(logCallMetadata.gas.required).toEqual(parseUnits("0.012", 18).toString()); + expect(logCallMetadata.rewardToken.has).toEqual(parseUnits("50", 18).toString()); + expect(logCallMetadata.rewardToken.required).toEqual(parseUnits("111.11", 18).toString()); + + spyConsoleLog.mockReset(); }); }); + describe("_isPrivateKeyAllowed()", () => { beforeEach(() => { // set dummy X25519_PRIVATE_KEY diff --git a/tests/payment-generatable.test.ts b/tests/payment-generatable.test.ts index ffc04989..182ce0b8 100644 --- a/tests/payment-generatable.test.ts +++ b/tests/payment-generatable.test.ts @@ -13,32 +13,28 @@ import { server } from "./__mocks__/node"; import permitGenerationResults from "./__mocks__/results/permit-generation-results.json"; import cfg from "./__mocks__/results/valid-configuration.json"; import { parseUnits } from "ethers/lib/utils"; +import { BigNumber } from "ethers"; const issueUrl = process.env.TEST_ISSUE_URL ?? "https://github.com/ubiquity-os/conversation-rewards/issues/5"; -class MockErc20Wrapper { - getSymbol = () => { - return "WXDAI"; - }; - getBalance = () => { - return parseUnits("100", 18); - }; - getDecimals = () => { - return 18; - }; - sendTransferTransaction = () => { - return { hash: "0xTransactionHash" }; +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)), + })), }; -} -jest.unstable_mockModule("../src/helpers/web3", () => ({ - Erc20Wrapper: MockErc20Wrapper, - getErc20TokenContract() { - return { provider: "dummy" }; - }, - getEvmWallet() { - return { address: "0xAddress" }; - }, -})); +}); jest.unstable_mockModule("@actions/github", () => ({ default: {}, diff --git a/tests/process.issue.test.ts b/tests/process.issue.test.ts index 9aef6cf1..b1d68bd0 100644 --- a/tests/process.issue.test.ts +++ b/tests/process.issue.test.ts @@ -22,32 +22,28 @@ import { customOctokit as Octokit } from "@ubiquity-os/plugin-sdk/octokit"; import { CommentAssociation } from "../src/configuration/comment-types"; import { GitHubIssue } from "../src/github-types"; import { parseUnits } from "ethers/lib/utils"; +import { BigNumber } from "ethers"; const issueUrl = process.env.TEST_ISSUE_URL ?? "https://github.com/ubiquity-os/conversation-rewards/issues/5"; -class MockErc20Wrapper { - getSymbol = () => { - return "WXDAI"; - }; - getBalance = () => { - return parseUnits("100", 18); - }; - getDecimals = () => { - return 18; - }; - sendTransferTransaction = () => { - return { hash: "0xTransactionHash" }; +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)), + })), }; -} -jest.unstable_mockModule("../src/helpers/web3", () => ({ - Erc20Wrapper: MockErc20Wrapper, - getErc20TokenContract() { - return { provider: "dummy" }; - }, - getEvmWallet() { - return { address: "0xAddress" }; - }, -})); +}); jest.unstable_mockModule("@actions/github", () => ({ default: {}, diff --git a/tests/rewards.test.ts b/tests/rewards.test.ts index 33fa6c8b..b65d5d24 100644 --- a/tests/rewards.test.ts +++ b/tests/rewards.test.ts @@ -14,6 +14,7 @@ import { server } from "./__mocks__/node"; import rewardSplitResult from "./__mocks__/results/reward-split.json"; 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"; @@ -86,29 +87,24 @@ jest.unstable_mockModule("@supabase/supabase-js", () => { }; }); -class MockErc20Wrapper { - getSymbol = () => { - return "WXDAI"; - }; - getBalance = () => { - return parseUnits("100", 18); - }; - getDecimals = () => { - return 18; - }; - sendTransferTransaction = () => { - return { hash: "0xTransactionHash" }; +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)), + })), }; -} -jest.unstable_mockModule("../src/helpers/web3", () => ({ - Erc20Wrapper: MockErc20Wrapper, - getErc20TokenContract() { - return { provider: "dummy" }; - }, - getEvmWallet() { - return { address: "0xAddress" }; - }, -})); +}); jest.unstable_mockModule("../src/helpers/get-comment-details", () => ({ getMinimizedCommentStatus: jest.fn(),