Skip to content

Commit

Permalink
Merge pull request #43 from rndquu/feat/permit-fees
Browse files Browse the repository at this point in the history
Feat/permit fees
  • Loading branch information
rndquu authored Jul 15, 2024
2 parents dad384f + 0206639 commit 97a1a38
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
GITHUB_TOKEN="<token>"
OPENAI_API_KEY="<api_key>"
# treasury fee applied to the final permits, ex: 100 = 100%, 0.1 = 0.1%
PERMIT_FEE_RATE=""
# github account associated with EVM treasury address allowed to claim permit fees, ex: "ubiquibot-treasury"
PERMIT_TREASURY_GITHUB_USERNAME=""
# comma separated list of token addresses which should not incur any fees, ex: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d, 0x4ECaBa5870353805a9F068101A40E0f32ed605C6"
PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST=""
3 changes: 3 additions & 0 deletions .github/workflows/compute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
NFT_MINTER_PRIVATE_KEY: ${{ secrets.NFT_MINTER_PRIVATE_KEY }}
NFT_CONTRACT_ADDRESS: ${{ secrets.NFT_CONTRACT_ADDRESS }}
PERMIT_FEE_RATE: ${{ secrets.PERMIT_FEE_RATE }}
PERMIT_TREASURY_GITHUB_USERNAME: ${{ secrets.PERMIT_TREASURY_GITHUB_USERNAME }}
PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST: ${{ secrets.PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST }}

steps:
- name: Post starting comment to issue
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/jest-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
EVM_PRIVATE_ENCRYPTED: "kmpTKq5Wh9r9x5j3U9GqZr3NYnjK2g0HtbzeUBOuLC2y3x8ja_SKBNlB2AZ6LigXHP_HeMitftVUtzmoj8CFfVP9SqjWoL6IPku1hVTWkdTn97g1IxzmjydFxjdcf0wuDW1hvVtoq3Uw5yALABqxcQ"
NFT_MINTER_PRIVATE_KEY: ${{ secrets.NFT_MINTER_PRIVATE_KEY }}
NFT_CONTRACT_ADDRESS: ${{ secrets.NFT_CONTRACT_ADDRESS }}
PERMIT_FEE_RATE: ""
PERMIT_TREASURY_GITHUB_USERNAME: ""
PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST: ""
steps:
- uses: actions/setup-node@v4
with:
Expand Down
83 changes: 83 additions & 0 deletions src/parser/permit-generation-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SupportedEvents,
TokenType,
} from "@ubiquibot/permit-generation/core";
import Decimal from "decimal.js";
import configuration from "../configuration/config-reader";
import {
PermitGenerationConfiguration,
Expand Down Expand Up @@ -69,6 +70,9 @@ export class PermitGenerationModule implements Module {
};
const adapters = {} as ReturnType<typeof createAdapters>;

// apply fees
result = await this._applyFees(result, payload.erc20RewardToken);

for (const [key, value] of Object.entries(result)) {
try {
const config: Context["config"] = {
Expand Down Expand Up @@ -110,6 +114,85 @@ export class PermitGenerationModule implements Module {
console.error(e);
}
}

// remove treasury item from final result in order not to display permit fee in github comments
if (process.env.PERMIT_TREASURY_GITHUB_USERNAME) delete result[process.env.PERMIT_TREASURY_GITHUB_USERNAME];

return result;
}

/**
* Applies fees to the final result.
* How it works:
* 1. Fee (read from ENV variable) is subtracted from all of the final result items (user.total, user.task.reward, user.comments[].reward)
* 2. Total fee is calculated
* 3. A new item is added to the final result object, example:
* ```
* {
* ...other items
* "ubiquibot-treasury": {
* total: 10.00,
* userId: 1
* }
* }
* ```
* This method is meant to be called before the final permit generation.
* @param result Result object
* @param erc20RewardToken ERC20 address of the reward token
* @returns Result object
*/
async _applyFees(result: Result, erc20RewardToken: string): Promise<Result> {
// validate fee related env variables
if (!process.env.PERMIT_FEE_RATE || +process.env.PERMIT_FEE_RATE === 0) {
console.log("PERMIT_FEE_RATE is not set, skipping permit fee generation");
return result;
}
if (!process.env.PERMIT_TREASURY_GITHUB_USERNAME) {
console.log("PERMIT_TREASURY_GITHUB_USERNAME is not set, skipping permit fee generation");
return result;
}
if (process.env.PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST) {
const erc20TokensNoFee = process.env.PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST.split(",");
if (erc20TokensNoFee.includes(erc20RewardToken)) {
console.log(`Token address ${erc20RewardToken} is whitelisted to be fee free, skipping permit fee generation`);
return result;
}
}

// Get treasury github user id
const octokit = getOctokitInstance();
const { data: treasuryGithubData } = await octokit.users.getByUsername({ username: process.env.PERMIT_TREASURY_GITHUB_USERNAME });
if (!treasuryGithubData) {
console.log(`GitHub user was not found for username ${process.env.PERMIT_TREASURY_GITHUB_USERNAME}, skipping permit fee generation`);
return result;
}

// Subtract fees from the final result:
// - user.total
// - user.task.reward
// - user.comments[].reward
const feeRateDecimal = new Decimal(100).minus(process.env.PERMIT_FEE_RATE).div(100);
let permitFeeAmountDecimal = new Decimal(0);
for (const [_, rewardResult] of Object.entries(result)) {
// accumulate total permit fee amount
const totalAfterFee = +(new Decimal(rewardResult.total).mul(feeRateDecimal));
permitFeeAmountDecimal = permitFeeAmountDecimal.add(new Decimal(rewardResult.total).minus(totalAfterFee));
// subtract fees
rewardResult.total = +totalAfterFee.toFixed(2);
if (rewardResult.task) rewardResult.task.reward = +(new Decimal(rewardResult.task.reward).mul(feeRateDecimal).toFixed(2));
if (rewardResult.comments) {
for (let comment of rewardResult.comments) {
if (comment.score) comment.score.reward = +(new Decimal(comment.score.reward).mul(feeRateDecimal).toFixed(2));
}
}
}

// Add a new result item for treasury
result[process.env.PERMIT_TREASURY_GITHUB_USERNAME] = {
total: +permitFeeAmountDecimal.toFixed(2),
userId: treasuryGithubData.id,
};

return result;
}

Expand Down
3 changes: 3 additions & 0 deletions src/types/env-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const envConfigSchema = Type.Object({
OPENAI_API_KEY: Type.String(),
NFT_MINTER_PRIVATE_KEY: Type.String({ default: "" }),
NFT_CONTRACT_ADDRESS: Type.String({ default: "" }),
PERMIT_FEE_RATE: Type.String(),
PERMIT_TREASURY_GITHUB_USERNAME: Type.String(),
PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST: Type.String(),
});

export type EnvConfigType = Static<typeof envConfigSchema>;
Expand Down
3 changes: 3 additions & 0 deletions src/types/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ declare global {
SUPABASE_URL: string;
NFT_CONTRACT_ADDRESS: string;
NFT_MINTER_PRIVATE_KEY: string;
PERMIT_FEE_RATE: string,
PERMIT_TREASURY_GITHUB_USERNAME: string,
PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST: string,
COMMENT_ID: string | undefined;
}
}
Expand Down
156 changes: 156 additions & 0 deletions tests/parser/permit-generation-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { CommentType } from "../../src/configuration/comment-types";
import { PermitGenerationModule } from "../../src/parser/permit-generation-module";
import { Result } from "../../src/parser/processor";

const DOLLAR_ADDRESS = '0xb6919Ef2ee4aFC163BC954C5678e2BB570c2D103';
const WXDAI_ADDRESS = '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d';

jest.mock("../../src/parser/command-line", () => {
const cfg = require("../__mocks__/results/valid-configuration.json");
const dotenv = require("dotenv");
dotenv.config();
return {
stateId: 1,
eventName: "issues.closed",
authToken: process.env.GITHUB_TOKEN,
ref: "",
eventPayload: {
issue: {
html_url: "https://github.com/ubiquibot/comment-incentives/issues/22",
number: 1,
state_reason: "not_planned",
},
repository: {
name: "conversation-rewards",
owner: {
login: "ubiquibot",
},
},
},
settings: JSON.stringify(cfg),
};
});

jest.mock("../../src/get-authentication-token", () => ({
getOctokitInstance: () => ({
users: {
getByUsername: () => ({
data: {
id: 3
}
})
}
})
}));

jest.mock("@supabase/supabase-js", () => {
return {
createClient: jest.fn(),
};
});

// original rewards object before fees are applied
const resultOriginal: Result = {
"user1": {
"total": 100,
"task": {
"reward": 90,
"multiplier": 1,
},
"userId": 1,
"comments": [
{
"content": "comment 3",
"url": "https://github.com/user-org/test-repo/issues/57#issuecomment-2172704421",
"type": CommentType.COMMENTED,
"score": {
"reward": 10
}
}
]
},
"user2": {
"total": 11.11,
"task": {
"reward": 9.99,
"multiplier": 1,
},
"userId": 1,
"comments": [
{
"content": "comment 3",
"url": "https://github.com/user-org/test-repo/issues/57#issuecomment-2172704421",
"type": CommentType.COMMENTED,
"score": {
"reward": 1.12
}
}
]
},
};

describe("permit-generation-module.ts", () => {
describe("applyFees()", () => {
beforeEach(() => {
// set fee related env variables
// treasury fee applied to the final permits, ex: 100 = 100%, 0.1 = 0.1%
process.env.PERMIT_FEE_RATE='10';
// github account associated with EVM treasury address allowed to claim permit fees, ex: "ubiquibot-treasury"
process.env.PERMIT_TREASURY_GITHUB_USERNAME="ubiquibot-treasury"
// comma separated list of token addresses which should not incur any fees, ex: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d, 0x4ECaBa5870353805a9F068101A40E0f32ed605C6"
process.env.PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST=`${DOLLAR_ADDRESS}`
});

afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});

it("Should not apply fees if PERMIT_FEE_RATE is empty", async () => {
process.env.PERMIT_FEE_RATE = '';
const permitGenerationModule = new PermitGenerationModule();
const spyConsoleLog = jest.spyOn(console, 'log');
await permitGenerationModule._applyFees(resultOriginal, WXDAI_ADDRESS);
expect(spyConsoleLog).toHaveBeenCalledWith('PERMIT_FEE_RATE is not set, skipping permit fee generation');
});

it("Should not apply fees if PERMIT_FEE_RATE is 0", async () => {
process.env.PERMIT_FEE_RATE = '0';
const permitGenerationModule = new PermitGenerationModule();
const spyConsoleLog = jest.spyOn(console, 'log');
await permitGenerationModule._applyFees(resultOriginal, WXDAI_ADDRESS);
expect(spyConsoleLog).toHaveBeenCalledWith('PERMIT_FEE_RATE is not set, skipping permit fee generation');
});

it("Should not apply fees if PERMIT_TREASURY_GITHUB_USERNAME is empty", async () => {
process.env.PERMIT_TREASURY_GITHUB_USERNAME = '';
const permitGenerationModule = new PermitGenerationModule();
const spyConsoleLog = jest.spyOn(console, 'log');
await permitGenerationModule._applyFees(resultOriginal, WXDAI_ADDRESS);
expect(spyConsoleLog).toHaveBeenCalledWith('PERMIT_TREASURY_GITHUB_USERNAME is not set, skipping permit fee generation');
});

it("Should not apply fees if ERC20 reward token is included in PERMIT_ERC20_TOKENS_NO_FEE_WHITELIST", async () => {
const permitGenerationModule = new PermitGenerationModule();
const spyConsoleLog = jest.spyOn(console, 'log');
await permitGenerationModule._applyFees(resultOriginal, DOLLAR_ADDRESS);
expect(spyConsoleLog).toHaveBeenCalledWith(`Token address ${DOLLAR_ADDRESS} is whitelisted to be fee free, skipping permit fee generation`);
});

it("Should apply fees", async () => {
const permitGenerationModule = new PermitGenerationModule();
const resultAfterFees = await permitGenerationModule._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);

// check that treasury item is added
expect(resultAfterFees['ubiquibot-treasury'].total).toEqual(11.11);
});
});
});

0 comments on commit 97a1a38

Please sign in to comment.