diff --git a/src/adapters/supabase/helpers/client.ts b/src/adapters/supabase/helpers/client.ts index 335ba0e95..f9743f034 100644 --- a/src/adapters/supabase/helpers/client.ts +++ b/src/adapters/supabase/helpers/client.ts @@ -2,6 +2,7 @@ import { createClient, SupabaseClient } from "@supabase/supabase-js"; import { getAdapters, getLogger } from "../../../bindings"; import { Issue, UserProfile } from "../../../types"; import { Database } from "../types"; +import { BigNumber, BigNumberish } from "ethers"; /** * @dev Creates a typescript client which will be used to interact with supabase platform @@ -311,3 +312,62 @@ export const getMultiplierReason = async (username: string): Promise => const { data } = await supabase.from("wallets").select("reason").eq("user_name", username).single(); return data?.reason; }; + +export const addPenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string, penalty: BigNumberish): Promise => { + const { supabase } = getAdapters(); + const logger = getLogger(); + + const { error } = await supabase.rpc("add_penalty", { + _username: username, + _repository_name: repoName, + _token_address: tokenAddress, + _network_id: networkId, + _penalty_amount: penalty.toString(), + }); + logger.debug(`Adding penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`); + + if (error) { + throw new Error(`Error adding penalty: ${error.message}`); + } +}; + +export const getPenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string): Promise => { + const { supabase } = getAdapters(); + const logger = getLogger(); + + const { data, error } = await supabase + .from("penalty") + .select("amount") + .eq("username", username) + .eq("repository_name", repoName) + .eq("network_id", networkId) + .eq("token_address", tokenAddress); + logger.debug(`Getting penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`); + + if (error) { + throw new Error(`Error getting penalty: ${error.message}`); + } + + if (data.length === 0) { + return BigNumber.from(0); + } + return BigNumber.from(data[0].amount); +}; + +export const removePenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string, penalty: BigNumberish): Promise => { + const { supabase } = getAdapters(); + const logger = getLogger(); + + const { error } = await supabase.rpc("remove_penalty", { + _username: username, + _repository_name: repoName, + _network_id: networkId, + _token_address: tokenAddress, + _penalty_amount: penalty.toString(), + }); + logger.debug(`Removing penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`); + + if (error) { + throw new Error(`Error removing penalty: ${error.message}`); + } +}; diff --git a/src/adapters/supabase/index.ts b/src/adapters/supabase/index.ts index 872ef39cc..d4e09d7b4 100644 --- a/src/adapters/supabase/index.ts +++ b/src/adapters/supabase/index.ts @@ -1,2 +1 @@ export * from "./helpers"; -export * from "./types"; diff --git a/src/adapters/supabase/types/database.ts b/src/adapters/supabase/types/database.ts index 48a6256bf..d2a8ce03e 100644 --- a/src/adapters/supabase/types/database.ts +++ b/src/adapters/supabase/types/database.ts @@ -240,7 +240,24 @@ export interface Database { [_ in never]: never; }; Functions: { - [_ in never]: never; + add_penalty: { + Args: { + username: string; + repository_name: string; + token_address: string; + penalty_amount: string; + }; + Returns: string; + }; + deduct_penalty: { + Args: { + username: string; + repository_name: string; + token_address: string; + penalty_amount: string; + }; + Returns: string; + }; }; Enums: { issue_status: "READY_TO_START" | "IN_PROGRESS" | "IN_REVIEW" | "DONE"; diff --git a/src/bindings/config.ts b/src/bindings/config.ts index d354c9785..c2a64e6b3 100644 --- a/src/bindings/config.ts +++ b/src/bindings/config.ts @@ -9,9 +9,9 @@ import { getScalarKey, getWideConfig } from "../utils/private"; export const loadConfig = async (context: Context): Promise => { const { - privateKey, baseMultiplier, timeLabels, + privateKey, priorityLabels, commentElementPricing, autoPayMode, diff --git a/src/handlers/comment/handlers/index.ts b/src/handlers/comment/handlers/index.ts index a304d1a10..24d5fa635 100644 --- a/src/handlers/comment/handlers/index.ts +++ b/src/handlers/comment/handlers/index.ts @@ -8,8 +8,20 @@ import { unassign } from "./unassign"; import { registerWallet } from "./wallet"; import { setAccess } from "./allow"; import { multiplier } from "./multiplier"; -import { addCommentToIssue, createLabel, addLabelToIssue, getLabel, upsertCommentToIssue } from "../../../helpers"; -import { getBotConfig, getBotContext } from "../../../bindings"; +import { BigNumber, ethers } from "ethers"; +import { addPenalty } from "../../../adapters/supabase"; +import { + addCommentToIssue, + createLabel, + addLabelToIssue, + getLabel, + upsertCommentToIssue, + getAllIssueComments, + getPayoutConfigByNetworkId, + getTokenSymbol, + getAllIssueAssignEvents, +} from "../../../helpers"; +import { getBotConfig, getBotContext, getLogger } from "../../../bindings"; import { handleIssueClosed } from "../../payout"; import { query } from "./query"; @@ -86,6 +98,83 @@ export const issueCreatedCallback = async (): Promise => { } }; +/** + * Callback for issues reopened - Processor + */ + +export const issueReopenedCallback = async (): Promise => { + const { payload: _payload } = getBotContext(); + const { + payout: { permitBaseUrl }, + } = getBotConfig(); + const logger = getLogger(); + const issue = (_payload as Payload).issue; + const repository = (_payload as Payload).repository; + if (!issue) return; + try { + // find permit comment from the bot + const comments = await getAllIssueComments(issue.number); + const claimUrlRegex = new RegExp(`\\((${permitBaseUrl}\\?claim=\\S+)\\)`); + const permitCommentIdx = comments.findIndex((e) => e.user.type === "Bot" && e.body.match(claimUrlRegex)); + if (permitCommentIdx === -1) { + return; + } + + // extract permit amount and token + const permitComment = comments[permitCommentIdx]; + const permitUrl = permitComment.body.match(claimUrlRegex); + if (!permitUrl || permitUrl.length < 2) { + logger.error(`Permit URL not found`); + return; + } + const url = new URL(permitUrl[1]); + const claimBase64 = url.searchParams.get("claim"); + if (!claimBase64) { + logger.error(`Permit claim search parameter not found`); + return; + } + let networkId = url.searchParams.get("network"); + if (!networkId) { + networkId = "1"; + } + const { rpc } = getPayoutConfigByNetworkId(Number(networkId)); + let claim; + try { + claim = JSON.parse(Buffer.from(claimBase64, "base64").toString("utf-8")); + } catch (err: unknown) { + logger.error(`Error parsing claim: ${err}`); + return; + } + const amount = BigNumber.from(claim.permit.permitted.amount); + const formattedAmount = ethers.utils.formatUnits(amount, 18); + const tokenAddress = claim.permit.permitted.token; + const tokenSymbol = await getTokenSymbol(tokenAddress, rpc); + + // find latest assignment before the permit comment + const events = await getAllIssueAssignEvents(issue.number); + if (events.length === 0) { + logger.error(`No assignment found`); + return; + } + const assignee = events[0].assignee.login; + + // write penalty to db + try { + await addPenalty(assignee, repository.full_name, tokenAddress, networkId.toString(), amount); + } catch (err) { + logger.error(`Error writing penalty to db: ${err}`); + return; + } + + await addCommentToIssue( + `@${assignee} please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **${formattedAmount} ${tokenSymbol}** will be deducted from your next bounty.`, + issue.number + ); + } catch (err: unknown) { + await addCommentToIssue(`Error: ${err}`, issue.number); + } +}; + /** * Default callback for slash commands * diff --git a/src/handlers/payout/action.ts b/src/handlers/payout/action.ts index e998bba5c..ba929eb82 100644 --- a/src/handlers/payout/action.ts +++ b/src/handlers/payout/action.ts @@ -1,6 +1,15 @@ -import { getWalletAddress, getWalletMultiplier } from "../../adapters/supabase"; +import { BigNumber, ethers } from "ethers"; +import { getPenalty, getWalletAddress, getWalletMultiplier, removePenalty } from "../../adapters/supabase"; import { getBotConfig, getBotContext, getLogger } from "../../bindings"; -import { addLabelToIssue, deleteLabel, generatePermit2Signature, getAllIssueComments, getTokenSymbol } from "../../helpers"; +import { + addLabelToIssue, + deleteLabel, + generatePermit2Signature, + getAllIssueAssignEvents, + getAllIssueComments, + getTokenSymbol, + wasIssueReopened, +} from "../../helpers"; import { UserType, Payload, StateReason } from "../../types"; import { shortenEthAddress } from "../../utils"; import { bountyInfo } from "../wildcard"; @@ -8,7 +17,7 @@ import { bountyInfo } from "../wildcard"; export const handleIssueClosed = async () => { const context = getBotContext(); const { - payout: { paymentToken, rpc }, + payout: { paymentToken, rpc, permitBaseUrl, networkId }, mode: { autoPayMode }, } = getBotConfig(); const logger = getLogger(); @@ -16,6 +25,58 @@ export const handleIssueClosed = async () => { const issue = payload.issue; if (!issue) return; + const comments = await getAllIssueComments(issue.number); + + const wasReopened = await wasIssueReopened(issue.number); + const claimUrlRegex = new RegExp(`\\((${permitBaseUrl}\\?claim=\\S+)\\)`); + const permitCommentIdx = comments.findIndex((e) => e.user.type === "Bot" && e.body.match(claimUrlRegex)); + + if (wasReopened && permitCommentIdx !== -1) { + const permitComment = comments[permitCommentIdx]; + const permitUrl = permitComment.body.match(claimUrlRegex); + if (!permitUrl || permitUrl.length < 2) { + logger.error(`Permit URL not found`); + return; + } + const url = new URL(permitUrl[1]); + const claimBase64 = url.searchParams.get("claim"); + if (!claimBase64) { + logger.error(`Permit claim search parameter not found`); + return; + } + let networkId = url.searchParams.get("network"); + if (!networkId) { + networkId = "1"; + } + let claim; + try { + claim = JSON.parse(Buffer.from(claimBase64, "base64").toString("utf-8")); + } catch (err: unknown) { + logger.error(`${err}`); + return; + } + const amount = BigNumber.from(claim.permit.permitted.amount); + const tokenAddress = claim.permit.permitted.token; + + // extract assignee + const events = await getAllIssueAssignEvents(issue.number); + if (events.length === 0) { + logger.error(`No assignment found`); + return; + } + const assignee = events[0].assignee.login; + + try { + await removePenalty(assignee, payload.repository.full_name, tokenAddress, networkId, amount); + } catch (err) { + logger.error(`Failed to remove penalty: ${err}`); + return; + } + + logger.info(`Penalty removed`); + return; + } + if (issue.state_reason !== StateReason.COMPLETED) { logger.info("Permit generation skipped because the issue was not closed as completed"); return "Permit generation skipped because the issue was not closed as completed"; @@ -54,18 +115,32 @@ export const handleIssueClosed = async () => { } // TODO: add multiplier to the priceInEth - const priceInEth = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * multiplier).toString(); + let priceInEth = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * multiplier).toString(); if (!recipient || recipient?.trim() === "") { logger.info(`Recipient address is missing`); return; } + // if bounty hunter has any penalty then deduct it from the bounty + const penaltyAmount = await getPenalty(assignee.login, payload.repository.full_name, paymentToken, networkId.toString()); + if (penaltyAmount.gt(0)) { + logger.info(`Deducting penalty from bounty`); + const bountyAmount = ethers.utils.parseUnits(priceInEth, 18); + const bountyAmountAfterPenalty = bountyAmount.sub(penaltyAmount); + if (bountyAmountAfterPenalty.lte(0)) { + await removePenalty(assignee.login, payload.repository.full_name, paymentToken, networkId.toString(), bountyAmount); + const msg = `Permit generation skipped because bounty amount after penalty is 0`; + logger.info(msg); + return msg; + } + priceInEth = ethers.utils.formatUnits(bountyAmountAfterPenalty, 18); + } + const payoutUrl = await generatePermit2Signature(recipient, priceInEth, issue.node_id); const tokenSymbol = await getTokenSymbol(paymentToken, rpc); const shortenRecipient = shortenEthAddress(recipient, `[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]`.length); logger.info(`Posting a payout url to the issue, url: ${payoutUrl}`); const comment = `### [ **[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n` + "```" + shortenRecipient + "```"; - const comments = await getAllIssueComments(issue.number); const permitComments = comments.filter((content) => content.body.includes("https://pay.ubq.fi?claim=") && content.user.type == UserType.Bot); if (permitComments.length > 0) { logger.info(`Skip to generate a permit url because it has been already posted`); @@ -73,5 +148,8 @@ export const handleIssueClosed = async () => { } await deleteLabel(issueDetailed.priceLabel); await addLabelToIssue("Permitted"); + if (penaltyAmount.gt(0)) { + await removePenalty(assignee.login, payload.repository.full_name, paymentToken, networkId.toString(), penaltyAmount); + } return comment; }; diff --git a/src/handlers/processors.ts b/src/handlers/processors.ts index ecd47fcd5..d64e07eaa 100644 --- a/src/handlers/processors.ts +++ b/src/handlers/processors.ts @@ -3,7 +3,7 @@ import { closePullRequestForAnIssue, commentWithAssignMessage } from "./assign"; import { pricingLabelLogic, validatePriceLabels } from "./pricing"; import { checkBountiesToUnassign, collectAnalytics, checkWeeklyUpdate } from "./wildcard"; import { nullHandler } from "./shared"; -import { handleComment, issueClosedCallback } from "./comment"; +import { handleComment, issueClosedCallback, issueReopenedCallback } from "./comment"; import { checkPullRequests } from "./assign/auto"; import { createDevPoolPR } from "./pull-request"; import { runOnPush } from "./push"; @@ -15,6 +15,11 @@ export const processors: Record = { action: [nullHandler], // SHOULD not set `issueCreatedCallback` until the exploit issue resolved. https://github.com/ubiquity/ubiquibot/issues/535 post: [nullHandler], }, + [GithubEvent.ISSUES_REOPENED]: { + pre: [nullHandler], + action: [issueReopenedCallback], + post: [nullHandler], + }, [GithubEvent.ISSUES_LABELED]: { pre: [validatePriceLabels], action: [pricingLabelLogic], diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index ae9508f00..cb07df7ee 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -1,6 +1,6 @@ import { Context } from "probot"; import { getBotContext, getLogger } from "../bindings"; -import { Comment, IssueType, Payload } from "../types"; +import { AssignEvent, Comment, IssueType, Payload } from "../types"; import { checkRateLimitGit } from "../utils"; import { DEFAULT_TIME_RANGE_FOR_MAX_ISSUE, DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED } from "../configs"; @@ -230,6 +230,73 @@ export const getAllIssueComments = async (issue_number: number): Promise => { + const context = getBotContext(); + const payload = context.payload as Payload; + + const result: AssignEvent[] = []; + let shouldFetch = true; + let page_number = 1; + try { + while (shouldFetch) { + const response = await context.octokit.rest.issues.listEvents({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issue_number, + per_page: 100, + page: page_number, + }); + + await checkRateLimitGit(response?.headers); + + // Fixing infinite loop here, it keeps looping even when its an empty array + if (response?.data?.length > 0) { + response.data.filter((item) => item.event === "assigned").forEach((item) => result?.push(item as AssignEvent)); + page_number++; + } else { + shouldFetch = false; + } + } + } catch (e: unknown) { + shouldFetch = false; + } + + return result.sort((a, b) => (new Date(a.created_at) > new Date(b.created_at) ? -1 : 1)); +}; + +export const wasIssueReopened = async (issue_number: number): Promise => { + const context = getBotContext(); + const payload = context.payload as Payload; + + let shouldFetch = true; + let page_number = 1; + try { + while (shouldFetch) { + const response = await context.octokit.rest.issues.listEvents({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issue_number, + per_page: 100, + page: page_number, + }); + + await checkRateLimitGit(response?.headers); + + // Fixing infinite loop here, it keeps looping even when its an empty array + if (response?.data?.length > 0) { + if (response.data.filter((item) => item.event === "reopened").length > 0) return true; + page_number++; + } else { + shouldFetch = false; + } + } + } catch (e: unknown) { + shouldFetch = false; + } + + return false; +}; + export const removeAssignees = async (issue_number: number, assignees: string[]): Promise => { const context = getBotContext(); const logger = getLogger(); diff --git a/src/types/payload.ts b/src/types/payload.ts index 1fadd187b..aa80cf454 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -11,6 +11,7 @@ export enum GithubEvent { ISSUES_UNASSIGNED = "issues.unassigned", ISSUES_CLOSED = "issues.closed", ISSUES_OPENED = "issues.opened", + ISSUES_REOPENED = "issues.reopened", // issue_comment ISSUE_COMMENT_CREATED = "issue_comment.created", @@ -238,6 +239,21 @@ export const CommentSchema = Type.Object({ export type Comment = Static; +export const AssignEventSchema = Type.Object({ + url: Type.String(), + id: Type.Number(), + node_id: Type.String(), + event: Type.String(), + commit_id: Type.String(), + commit_url: Type.String(), + created_at: Type.String({ format: "date-time" }), + actor: UserSchema, + assignee: UserSchema, + assigner: UserSchema, +}); + +export type AssignEvent = Static; + export const PayloadSchema = Type.Object({ action: Type.String(), issue: Type.Optional(IssueSchema), diff --git a/supabase/migrations/20230710160900_bounty_hunter_penalty.sql b/supabase/migrations/20230710160900_bounty_hunter_penalty.sql new file mode 100644 index 000000000..0e0ef9260 --- /dev/null +++ b/supabase/migrations/20230710160900_bounty_hunter_penalty.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS penalty ( + username text not null, + repository_name text not null, + network_id text not null, + token_address text not null, + amount text not null default '0', + PRIMARY KEY (username, repository_name, network_id, token_address) +); + +-- Insert penalty or add penalty amount and return the new penalty amount +create or replace function add_penalty(_username text, _repository_name text, _network_id text, _token_address text, _penalty_amount text) +returns text as +$$ + declare updated_penalty_amount text; + begin + insert into penalty (username, repository_name, network_id, token_address, amount) VALUES (_username, _repository_name, _network_id, _token_address, _penalty_amount) + on conflict (username, repository_name, network_id, token_address) do update + set amount = (penalty.amount::DECIMAL + EXCLUDED.amount::DECIMAL)::TEXT + returning amount into updated_penalty_amount; + return updated_penalty_amount; + end +$$ +language plpgsql; + +-- Remove penalty amount and return the new penalty amount +create or replace function remove_penalty(_username text, _repository_name text, _network_id text, _token_address text, _penalty_amount text) +returns text as +$$ + declare updated_penalty_amount text; + begin + update penalty + set amount = (amount::DECIMAL - _penalty_amount::DECIMAL)::TEXT + where username = _username and repository_name = _repository_name and network_id = _network_id and token_address = _token_address + returning amount into updated_penalty_amount; + return updated_penalty_amount; + end +$$ +language plpgsql; \ No newline at end of file