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 39 commits into
base: development
Choose a base branch
from

Conversation

hhio618
Copy link

@hhio618 hhio618 commented Jan 6, 2025

Resolves #226

if (!canGeneratePermits) {
this.context.logger.error("[PermitGenerationModule] Non collaborative issue detected, skipping.");
return Promise.resolve(result);
}

const sumPayouts = await this._sumPayouts(result);
const fundingWalletBalance = await getFundingWalletBalance(this._evmNetworkId, this._erc20RewardToken, this._fudningWalletAddress);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const fundingWalletBalance = await getFundingWalletBalance(this._evmNetworkId, this._erc20RewardToken, this._fudningWalletAddress);
const fundingWalletBalance = await getFundingWalletBalance(this._evmNetworkId, this._erc20RewardToken, this._fundingWalletAddress);

const fundingWalletBalance = await getFundingWalletBalance(this._evmNetworkId, this._erc20RewardToken, this._fudningWalletAddress);

if (this._autoTransferMode && sumPayouts < fundingWalletBalance) {
this.context.logger.debug("[PermitGenerationModule] AutoTransforMode is enabled and there are enough funds, skipping.");
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.context.logger.debug("[PermitGenerationModule] AutoTransforMode is enabled and there are enough funds, skipping.");
this.context.logger.debug("[PermitGenerationModule] AutoTransferMode is enabled and there are enough funds, skipping.");

@0x4007
Copy link
Member

0x4007 commented Jan 6, 2025

@gentlementlegen There should be formatting checks with linter and cspell

@gentlementlegen
Copy link
Member

gentlementlegen commented Jan 6, 2025

@0x4007 Yes they seems to happen? The following run failed: https://github.com/ubiquity-os-marketplace/text-conversation-rewards/actions/runs/12629551075/job/35187679129?pr=235

This is what I see
image

@hhio618 hhio618 force-pushed the development branch 3 times, most recently from 52b8f03 to c7d396e Compare January 7, 2025 16:28
@gentlementlegen
Copy link
Member

@hhio618 I saw you marked it as [WIP] in the title, if that's the case this pull-request should be a draft so we don't review it yet.

@hhio618
Copy link
Author

hhio618 commented Jan 8, 2025

@gentlementlegen I'll definitely keep that in mind for the future! For now, this task is ready for review.

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";

@gentlementlegen gentlementlegen changed the title [WIP] feat: add auto transfer mode feat: add auto transfer mode Jan 8, 2025
@gentlementlegen
Copy link
Member

@hhio618 If you can please fix the tests:

FAIL tests/permit-generatable.test.ts
  ● Test suite failed to run

    tests/permit-generatable.test.ts:162:49 - error TS2307: Cannot find module '../src/parser/permit-generation-module' or its corresponding type declarations.

    162 const { PermitGenerationModule } = await import("../src/parser/permit-generation-module");

(cf https://github.com/ubiquity-os-marketplace/text-conversation-rewards/actions/runs/12664483458/job/35292699217?pr=235#step:5:82)

@hhio618
Copy link
Author

hhio618 commented Jan 9, 2025

@gentlementlegen I'm on it! I'll update the PR once the tests are fixed.

return Promise.resolve(result);
}

const totalPayable = await this._getTotalPayable(result);
const fundingWalletBalance = await getErc20Balance(this._evmNetworkId, this._erc20RewardToken, privateKey);
Copy link
Member

Choose a reason for hiding this comment

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

shouldn't you pass the address instead of the private key?

@@ -96,51 +116,115 @@ export class PermitGenerationModule extends BaseModule {

for (const [key, value] of Object.entries(result)) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
for (const [key, value] of Object.entries(result)) {
for (const [username, reward] of Object.entries(result)) {

I know this is code that you didn't change but still variables shouldn't have generic names

Comment on lines 134 to 142
const beneficiaryWalletAddress = await this._getBeneficiaryWalletAddress(key);
const signedTxData = await createTransferSignedTx(
this._evmNetworkId,
this._erc20RewardToken,
privateKey,
beneficiaryWalletAddress,
value.total.toString()
);
const tx = await sendSignedTx(this._evmNetworkId, signedTxData);
Copy link
Member

Choose a reason for hiding this comment

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

is there a reason to split into two operations (sign + send) instead of just doing it in one operation

Copy link
Author

Choose a reason for hiding this comment

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

Hey! So, I initially tried mocking the ethers module to test things out, but it didn't quite work. I switched to using an Erc20Wrapper class, and that seems to do the trick for testing everything and also helps with the getErc20TokenSymbol thing you mentioned.

Comment on lines 59 to 66
let shouldTransferDirectly = false;
if (this._autoTransferMode && totalPayable < fundingWalletBalance) {
this.context.logger.debug(
"[PaymentModule] AutoTransformMode is enabled, " +
"and sufficient funds are available in the funding wallet, skipping."
);
shouldTransferDirectly = true;
}
Copy link
Member

Choose a reason for hiding this comment

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

you also need to check if the wallet has enough balance to cover gas fees

Copy link
Author

Choose a reason for hiding this comment

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

Okay, I'll take care of that and add some extra tests. Then we can do another review.

const userId = userData.id;
const { data: walletData } = await this._supabase.from("wallets").select("address").eq("id", userId).single();
if (!walletData?.address) {
throw new Error("Beneficiary wallet not found");
Copy link
Member

Choose a reason for hiding this comment

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

we usually use logger to throw errors like

throw this.logger.error("Beneficiary wallet not found");

if (!networkExplorer) {
return "https://blockscan.com";
}
return networkExplorer[0].url;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return networkExplorer[0].url;
return networkExplorer[0]?.url;

Comment on lines 71 to 72
describe("getEvmWallet()", () => {
it("Should return ERC20 token decimals", async () => {
Copy link
Member

Choose a reason for hiding this comment

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

wrong test name

"0x94d7a85efef179560f9b821cadd20056600fdb9d",
parseUnits("100", 18).toString()
);
expect(tx).toEqual("sdvfds");
Copy link
Member

Choose a reason for hiding this comment

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

what is this random string?

@@ -182,14 +202,49 @@ describe("permit-generation-module.ts", () => {
});
});

describe("Auto transfer mode tests", () => {
Copy link
Member

Choose a reason for hiding this comment

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

you should also test the actual transfer, not just helper functions

@0x4007
Copy link
Member

0x4007 commented Jan 23, 2025

I suppose that it would be best to use the existing deployments instead of deploying a new one.

@hhio618
Copy link
Author

hhio618 commented Jan 23, 2025

I suppose that it would be best to use the existing deployments instead of deploying a new one.

Certainly! It's available on multiple chains at this address: 0xD152f549545093347A162Dce210e7293f1452150

@hhio618
Copy link
Author

hhio618 commented Jan 24, 2025

For handling reopen issues, you can look at the previous comment and check transaction hashes.

@whilefoo Could you provide more details on this part?

@whilefoo
Copy link
Member

@whilefoo Could you provide more details on this part?

For example, the issue is closed and rewards are sent directly (auto transfer), then someone opens the issue and closes it again, it should not send the reward again. That's why you should check the previous comments which has rewards and see if it has transaction hashes, or if it has permit urls (auto transfer mode could've been toggled)

@0x4007
Copy link
Member

0x4007 commented Jan 24, 2025

@whilefoo Could you provide more details on this part?

For example, the issue is closed and rewards are sent directly (auto transfer), then someone opens the issue and closes it again, it should not send the reward again. That's why you should check the previous comments which has rewards and see if it has transaction hashes, or if it has permit urls (auto transfer mode could've been toggled)

I overlooked this complexity. We rely on nonces for permits I wonder if there is a similar strategy that we can follow for transfers, and to have them be automatically invalid if the same "nonce" for the task is already used.

@hhio618
Copy link
Author

hhio618 commented Jan 26, 2025

What do you think about generating a permit request with multiple recipients and executing it immediately? This way, we can take advantage of the nonce functionality again!

@whilefoo
Copy link
Member

whilefoo commented Jan 26, 2025

What do you think about generating a permit request with multiple recipients and executing it immediately?

That's a good idea, we can use batch version of permitTransferFrom where owner and spender are the funding wallet and transferDetails is filled with all reward recipients. Then you can immediately call the permit which will transfer tokens to all wallets and still keep the nonce functionality

@0x4007
Copy link
Member

0x4007 commented Jan 26, 2025

If it's technically possible then yes it seems the most secure.

@hhio618
Copy link
Author

hhio618 commented Jan 27, 2025

@whilefoo It's almost complete, but there's one thing to consider:
We need to save the transfer data to Supabase. Do you have any suggestions on how the record should be structured?

@hhio618 hhio618 requested a review from whilefoo January 30, 2025 14:21
src/helpers/web3.ts Outdated Show resolved Hide resolved
src/parser/payment-module.ts Outdated Show resolved Hide resolved
tests/helpers/web3.test.ts Outdated Show resolved Hide resolved
Comment on lines 278 to 282
const totalFee = await permit2Wrapper.estimatePermitTransferFromGas(fundingWallet, batchTransferPermit);
const totalReward = beneficiaries.amounts.reduce(
(accumulator, current) => accumulator.add(current),
BigNumber.from(0)
);
Copy link
Member

Choose a reason for hiding this comment

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

won't estimate throw UNPREDICTABLE_GAS_LIMIT if there are not enough funds? so the funds should be checked first and after that gas fee, but UNPREDICTABLE_GAS_LIMIT should be handled in any case

Copy link
Member

Choose a reason for hiding this comment

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

@hhio618 looks like you haven't handled this

Copy link
Author

Choose a reason for hiding this comment

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

Does this actually affect the flow? Just to mention, we will log this in the catch block regardless.

tests/payment-generatable.test.ts Outdated Show resolved Hide resolved
src/helpers/web3.ts Outdated Show resolved Hide resolved
src/parser/payment-module.ts Outdated Show resolved Hide resolved
@@ -114,27 +124,45 @@ export class PaymentModule extends BaseModule {
result = await this._applyFees(result, payload.erc20RewardToken);

if (this._autoTransferMode) {
// Generate the batch transfer nonce
const nonce = utils.keccak256(utils.toUtf8Bytes(issueId.toString()));
Copy link
Member

Choose a reason for hiding this comment

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

you need to check if the nonce is used, for example if the issue was re-opened and closed, and in that case stop permit generation

Copy link
Member

Choose a reason for hiding this comment

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

Currently, it will always generate a permit, but the permit may be invalid if it was already claimed.

This is especially useful if the assignee was unassigned just before the completion of the project, and the permits have to be regenerated.

Copy link
Member

Choose a reason for hiding this comment

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

Because this is auto transfer mode, it will try to create a permit and claim it again which will fail, so ideally it should stop the execution.

The situation you're describing will be problematic in auto transfer mode - the assignee that was unassigned will get the reward for other things like comments and it will be automatically transferred so after the user is assigned back and the issue is reopened and closed, the plugin would need to calculate the difference in rewards for each user and send it again.

Copy link
Member

Choose a reason for hiding this comment

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

the plugin would need to calculate the difference in rewards for each user and send it again.

Perhaps we can make a new spec for this, and for now lets just make it invalid because the original task nonce was already used?

Copy link
Member

Choose a reason for hiding this comment

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

for now lets just make it invalid because the original task nonce was already used?

What do you mean by make it invalid? The first time it will be automatically transferred and the second time it should execution because the nonce was used

Copy link
Member

Choose a reason for hiding this comment

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

Right. Anything after the first attempt for rewards distribution should fail due to the nonce.

src/parser/payment-module.ts Outdated Show resolved Hide resolved
src/parser/payment-module.ts Outdated Show resolved Hide resolved
@0x4007
Copy link
Member

0x4007 commented Feb 1, 2025

@whilefoo It's almost complete, but there's one thing to consider:

We need to save the transfer data to Supabase. Do you have any suggestions on how the record should be structured?

I'm pretty sure the Supabase SDK can output types which answers this question. @gentlementlegen RFC

@gentlementlegen
Copy link
Member

Yes types can be generated from supabase CLI. We are already saving permits there, and types are already present in this plugin, are changes needed in the structure?

@hhio618 hhio618 requested a review from whilefoo February 2, 2025 08:10
await Promise.all(
beneficiaries.usernames.map(async (username) => {
result[username].explorerUrl = `${networkExplorer}/tx/${tx.hash}`;
beneficiaries.map(async (beneficiary, idx) => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
beneficiaries.map(async (beneficiary, idx) => {
beneficiaries.forEach(async (beneficiary, idx) => {

Copy link
Author

Choose a reason for hiding this comment

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

Thank you for your suggestion! I just wanted to double-check something: since forEach does not wait for async functions to resolve, there's a possibility that the function could return before all the database records are saved. Could we consider using an alternative approach, like for...of or Promise.all, to ensure all records are saved properly? Let me know your thoughts!

Comment on lines 271 to 275
const totalFee = await permit2Wrapper.estimatePermitTransferFromGas(fundingWallet, batchTransferPermit);
const totalReward = beneficiaries.amounts.reduce(
(accumulator, current) => accumulator.add(current),
const totalReward = beneficiaries.reduce(
(accumulator, current) => accumulator.add(current.amount),
BigNumber.from(0)
);
Copy link
Member

Choose a reason for hiding this comment

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

also check allowance

Comment on lines 386 to 396
if (e.code === ethers.errors.INSUFFICIENT_FUNDS || e.message.includes(ethers.errors.INSUFFICIENT_FUNDS)) {
throw new Error(
this.context.logger.error(`Insufficient funds to complete the transaction`, { e }).logMessage.raw
);
return [tx, permits];
} catch (e) {
if (e instanceof Object) {
if ("code" in e && e.code === ethers.errors.INSUFFICIENT_FUNDS) {
throw new Error(
this.context.logger.error(`Error: Insufficient funds to complete the transaction`, { e }).logMessage.raw
);
} else if (
"message" in e &&
typeof e.message === "string" &&
e.message.includes(ethers.errors.INSUFFICIENT_FUNDS)
) {
throw new Error(this.context.logger.error("Error: Insufficient gas or balance detected").logMessage.raw);
}
}
attempt++;
this.context.logger.error(`Attempt ${attempt} failed: ${e}`, { e });
// Exponential backoff delay
this.context.logger.info(`Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2;
} else if (
e.code === ethers.errors.INSUFFICIENT_FUNDS ||
e.message.includes(ethers.errors.UNPREDICTABLE_GAS_LIMIT)
) {
throw new Error(this.context.logger.error("The gas limit could not be estimated", { e }).logMessage.raw);
} else {
throw new Error(this.context.logger.error(`Transaction failed: ${e.message}`, { e }).logMessage.raw);
Copy link
Member

Choose a reason for hiding this comment

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

Why is UNPREDICTABLE_GAS_LIMIT checked in the message? It's in the code, isn't it?
I would just check if code is UNPREDICTABLE_GAS_LIMIT, INSUFFICIENT_FUNDS (and also TRANSFER_FROM_FAILED I think? But that is not standard error so it might be in the message)

Copy link
Author

Choose a reason for hiding this comment

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

This could happen when Ethers occasionally sends a SERVER_ERROR code or a similar error, but includes the actual code within the message field.

);
return [tx, permits];
} catch (error) {
const e = error as { reason: string; code: string; message: string };
Copy link
Member

Choose a reason for hiding this comment

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

you should also check if those properties are actually present

@hhio618 hhio618 requested a review from whilefoo February 6, 2025 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Transfers then Permits
6 participants