Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add auto transfer mode #235

Open
wants to merge 4 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 99 additions & 8 deletions src/helpers/web3.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import { RPCHandler, HandlerConstructorConfig, NetworkId } from "@ubiquity-dao/rpc-handler";
import { ethers } from "ethers";
import { Context } from "@ubiquity-os/permit-generation";
import { ethers, utils } from "ethers";

/**
* Returns ERC20 token symbol
* Returns the funding wallet
* @param privateKey of the funding wallet
* @param provider ethers.Provider
* @returns the funding wallet
*/
async function getFundingWallet(privateKey: string, provider: ethers.providers.Provider) {
try {
return new ethers.Wallet(privateKey, provider);
} catch (error) {
const errorMessage = `Failed to instantiate wallet: ${error}`;
throw new Error(errorMessage);
}
}

/**
* Returns ERC20 token contract
* @param networkId Network id
* @param tokenAddress ERC20 token address
* @returns ERC20 token symbol
* @returns ERC20 token contract
*/
export async function getErc20TokenSymbol(networkId: number, tokenAddress: string) {
const abi = ["function symbol() view returns (string)"];

async function getErc20TokenContract(networkId: number, tokenAddress: string) {
const abi = [
"function symbol() view returns (string)",
"function decimals() public view returns (uint8)",
"function balanceOf(address) view returns (uint256)",
"function transfer(address,uint256) public returns (bool)",
];

// get fastest RPC
const config: HandlerConstructorConfig = {
Expand All @@ -30,7 +52,76 @@ export async function getErc20TokenSymbol(networkId: number, tokenAddress: strin
const handler = new RPCHandler(config);
const provider = await handler.getFastestRpcProvider();

// fetch token symbol
const contract = new ethers.Contract(tokenAddress, abi, provider);
return await contract.symbol();
return new ethers.Contract(tokenAddress, abi, provider);
}
/**
* Returns ERC20 token symbol
* @param networkId Network id
* @param tokenAddress ERC20 token address
* @returns ERC20 token symbol
*/
export async function getErc20TokenSymbol(networkId: number, tokenAddress: string) {
return await (await getErc20TokenContract(networkId, tokenAddress)).symbol();
}

/**
* Returns ERC20 token decimals
* @param networkId Network id
* @param tokenAddress ERC20 token address
* @returns ERC20 token decimals
*/
async function getErc20TokenDecimals(networkId: number, tokenAddress: string) {
return await (await getErc20TokenContract(networkId, tokenAddress)).decimals();
}

/**
* Returns ERC20 token balance of the funding wallet
* @param networkId Network id
* @param tokenAddress ERC20 token address
* @param fundingWalledAddress funding wallet address
* @returns ERC20 token balance of the funding wallet
*/
export async function getFundingWalletBalance(networkId: number, tokenAddress: string, privateKey: string) {
const contract = await getErc20TokenContract(networkId, tokenAddress);
const fundingWallet = await getFundingWallet(privateKey, contract.provider);
return await contract.balanceOf(await fundingWallet.getAddress());
}

/**
* Returns Transaction for the ERC20 token transfer
* @param networkId Network id
* @param tokenAddress ERC20 token address
* @param _evmPrivateEncrypted encrypted private key of the funding wallet address
* @param username github user name of the beneficiary
* @param amount Amount of ERC20 token to be transferred
* @returns Transaction for the ERC20 token transfer
*/
export async function transferFromFundingWallet(
context: Context,
networkId: number,
tokenAddress: string,
privateKey: string,
username: string,
amount: string
) {
// Obtain the beneficiary wallet address from the github user name
const { data: userData } = await context.octokit.rest.users.getByUsername({ username });
if (!userData) {
throw new Error(`GitHub user was not found for id ${username}`);
}
const userId = userData.id;
const { wallet } = context.adapters.supabase;
const beneficiaryWalletAddress = await wallet.getWalletByUserId(userId);
if (!beneficiaryWalletAddress) {
throw new Error("Beneficiary wallet not found");
}

const tokenDecimals = await getErc20TokenDecimals(networkId, tokenAddress);
const _contract = await getErc20TokenContract(networkId, tokenAddress);
// Construct the funding wallet from the privateKey
const fundingWallet = await getFundingWallet(privateKey, _contract.provider);

// send the transaction
const contract = new ethers.Contract(tokenAddress, _contract.abi, fundingWallet);
return await contract.transfer(beneficiaryWalletAddress, utils.parseUnits(amount, tokenDecimals));
}
144 changes: 106 additions & 38 deletions src/parser/permit-generation-module.ts → src/parser/payment-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { EnvConfig } from "../types/env-type";
import { BaseModule } from "../types/module";
import { Result } from "../types/results";
import { isAdmin, isCollaborative } from "../helpers/checkers";
import { getFundingWalletBalance, transferFromFundingWallet } from "../helpers/web3";

interface Payload {
evmNetworkId: number;
Expand All @@ -34,17 +35,34 @@ interface Payload {
issue: { node_id: string };
}

export class PermitGenerationModule extends BaseModule {
export class PaymentModule extends BaseModule {
readonly _configuration: PermitGenerationConfiguration | null = this.context.config.incentives.permitGeneration;
readonly _autoTransferMode: boolean = this.context.config.automaticTransferMode;
readonly _evmPrivateEncrypted: string = this.context.config.evmPrivateEncrypted;
readonly _evmNetworkId: number = this.context.config.evmNetworkId;
readonly _erc20RewardToken: string = this.context.config.erc20RewardToken;
readonly _supabase = createClient<Database>(this.context.env.SUPABASE_URL, this.context.env.SUPABASE_KEY);

async transform(data: Readonly<IssueActivity>, result: Result): Promise<Result> {
const canGeneratePermits = await this._canGeneratePermit(data);

if (!canGeneratePermits) {
this.context.logger.error("[PermitGenerationModule] Non collaborative issue detected, skipping.");
const canMakePayment = await this._canMakePayment(data);
const privateKey = await this._getPrivateKey(this._evmPrivateEncrypted);
if (!canMakePayment) {
this.context.logger.error("[PaymentModule] Non collaborative issue detected, skipping.");
return Promise.resolve(result);
}

const sumPayouts = await this._sumPayouts(result);
const fundingWalletBalance = await getFundingWalletBalance(this._evmNetworkId, this._erc20RewardToken, privateKey);

let shouldTransferDirectly = false;
if (this._autoTransferMode && sumPayouts < fundingWalletBalance) {
this.context.logger.debug(
"[PaymentModule] AutoTransformMode is enabled, " +
"and sufficient funds are available in the funding wallet, skipping."
);
shouldTransferDirectly = true;
}

const payload: Context["payload"] & Payload = {
...context.payload.inputs,
issueUrl: this.context.payload.issue.html_url,
Expand All @@ -65,7 +83,7 @@ export class PermitGenerationModule extends BaseModule {
);
if (!isPrivateKeyAllowed) {
this.context.logger.error(
"[PermitGenerationModule] Private key is not allowed to be used in this organization/repository."
"[PaymentModule] Private key is not allowed to be used in this organization/repository."
);
return Promise.resolve(result);
}
Expand Down Expand Up @@ -96,21 +114,22 @@ export class PermitGenerationModule extends BaseModule {

for (const [key, value] of Object.entries(result)) {
this.context.logger.debug(`Updating result for user ${key}`);
try {
const config: Context["config"] = {
evmNetworkId: payload.evmNetworkId,
evmPrivateEncrypted: payload.evmPrivateEncrypted,
permitRequests: [
{
amount: value.total,
username: key,
contributionType: "",
type: TokenType.ERC20,
tokenAddress: payload.erc20RewardToken,
},
],
};
const permits = await generatePayoutPermit(
const config: Context["config"] = {
evmNetworkId: payload.evmNetworkId,
evmPrivateEncrypted: payload.evmPrivateEncrypted,
permitRequests: [
{
amount: value.total,
username: key,
contributionType: "",
type: TokenType.ERC20,
tokenAddress: payload.erc20RewardToken,
},
],
};

if (shouldTransferDirectly) {
const tx = await transferFromFundingWallet(
{
env,
eventName,
Expand All @@ -128,19 +147,76 @@ export class PermitGenerationModule extends BaseModule {
octokit,
config,
},
config.permitRequests

this._evmNetworkId,
this._erc20RewardToken,
privateKey,
key,
value.total.toString()
);
result[key].permitUrl = `https://pay.ubq.fi?claim=${encodePermits(permits)}`;
await this._savePermitsToDatabase(result[key].userId, { issueUrl: payload.issueUrl, issueId }, permits);
} catch (e) {
this.context.logger.error(`[PermitGenerationModule] Failed to generate permits for user ${key}`, { e });
result[key].explorerUrl = `https://gnosisscan.io/tx/${tx.hash}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we can use any network and any ERC20 address, wouldn't this be a problem?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly! I addressed this by using the following:

 import { getNetworkExplorer, NetworkId } from "@ubiquity-dao/rpc-handler";

} else {
try {
const permits = await generatePayoutPermit(
{
env,
eventName,
logger: permitLogger,
payload,
adapters: createAdapters(this._supabase, {
env,
eventName,
octokit,
config,
logger: permitLogger,
payload,
adapters,
}),
octokit,
config,
},
config.permitRequests
);
result[key].permitUrl = `https://pay.ubq.fi?claim=${encodePermits(permits)}`;
await this._savePermitsToDatabase(result[key].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 ${key}`, { e });
}
}
}
return result;
}

// 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];
async _sumPayouts(result: Result) {
let sumPayouts = 0;
for (const value of Object.values(result)) {
sumPayouts += value.total;
}
return sumPayouts;
}

return result;
async _canMakePayment(data: Readonly<IssueActivity>) {
if (!data.self?.closed_by || !data.self.user) return false;

if (await isAdmin(data.self.user.login, this.context)) return true;

return isCollaborative(data);
}

async _getPrivateKey(evmPrivateEncrypted: string) {
try {
const privateKeyDecrypted = await decrypt(evmPrivateEncrypted, String(process.env.X25519_PRIVATE_KEY));
const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted);
const privateKey = privateKeyParsed.privateKey;
if (!privateKey) throw new Error("Private key is not defined");
return privateKey;
} catch (error) {
const errorMessage = `Failed to decrypt a private key: ${error}`;
this.context.logger.error(errorMessage);
throw new Error(errorMessage);
}
}

/**
Expand Down Expand Up @@ -199,14 +275,6 @@ export class PermitGenerationModule extends BaseModule {
return this._deductFeeFromReward(result, treasuryGithubData);
}

async _canGeneratePermit(data: Readonly<IssueActivity>) {
if (!data.self?.closed_by || !data.self.user) return false;

if (await isAdmin(data.self.user.login, this.context)) return true;

return isCollaborative(data);
}

_deductFeeFromReward(
result: Result,
treasuryGithubData: RestEndpointMethodTypes["users"]["getByUsername"]["response"]["data"]
Expand Down Expand Up @@ -388,7 +456,7 @@ export class PermitGenerationModule extends BaseModule {

get enabled(): boolean {
if (!Value.Check(permitGenerationConfigurationType, this._configuration)) {
this.context.logger.error("Invalid / missing configuration detected for PermitGenerationModule, disabling.");
this.context.logger.error("Invalid / missing configuration detected for PaymentModule, disabling.");
return false;
}
return true;
Expand Down
4 changes: 2 additions & 2 deletions src/parser/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ContentEvaluatorModule } from "./content-evaluator-module";
import { DataPurgeModule } from "./data-purge-module";
import { FormattingEvaluatorModule } from "./formatting-evaluator-module";
import { GithubCommentModule } from "./github-comment-module";
import { PermitGenerationModule } from "./permit-generation-module";
import { PaymentModule } from "./payment-module";
import { UserExtractorModule } from "./user-extractor-module";
import { getTaskReward } from "../helpers/label-price-extractor";
import { GitHubIssue } from "../github-types";
Expand All @@ -25,7 +25,7 @@ export class Processor {
.add(new DataPurgeModule(context))
.add(new FormattingEvaluatorModule(context))
.add(new ContentEvaluatorModule(context))
.add(new PermitGenerationModule(context))
.add(new PaymentModule(context))
.add(new GithubCommentModule(context));
this._context = context;
this._configuration = this._context.config.incentives;
Expand Down
11 changes: 10 additions & 1 deletion src/types/plugin-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@ export const pluginSettingsSchema = T.Object(
* The encrypted key to use for permit generation
*/
evmPrivateEncrypted: T.String({
description: "The encrypted key to use for permit generation",
description: "The encrypted key to use for permit generation and auto transfers",
examples: ["0x000..."],
}),
/**
* Reward token for ERC20 permits, default WXDAI for gnosis chain
*/
erc20RewardToken: T.String({ default: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d" }),
/**
* If set to false or if there are insufficient funds to settle the payment,
* permits will be generated instead of processing direct payouts.
*/
automaticTransferMode: T.Boolean({
default: true,
description:
"If set to false or if there are insufficient funds to settle the payment, permits will be generated instead of processing direct payouts.",
}),
incentives: T.Object(
{
/**
Expand Down
1 change: 1 addition & 0 deletions src/types/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface Result {
};
feeRate?: number;
permitUrl?: string;
explorerUrl?: string;
userId: number;
evaluationCommentHtml?: string;
};
Expand Down
Loading