-
Notifications
You must be signed in to change notification settings - Fork 32
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
base: development
Are you sure you want to change the base?
Conversation
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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."); |
@gentlementlegen There should be formatting checks with linter and cspell |
@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 |
52b8f03
to
c7d396e
Compare
@hhio618 I saw you marked it as |
@gentlementlegen I'll definitely keep that in mind for the future! For now, this task is ready for review. |
src/parser/payment-module.ts
Outdated
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}`; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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";
@hhio618 If you can please fix the tests:
|
@gentlementlegen I'm on it! I'll update the PR once the tests are fixed. |
src/parser/payment-module.ts
Outdated
return Promise.resolve(result); | ||
} | ||
|
||
const totalPayable = await this._getTotalPayable(result); | ||
const fundingWalletBalance = await getErc20Balance(this._evmNetworkId, this._erc20RewardToken, privateKey); |
There was a problem hiding this comment.
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?
src/parser/payment-module.ts
Outdated
@@ -96,51 +116,115 @@ export class PermitGenerationModule extends BaseModule { | |||
|
|||
for (const [key, value] of Object.entries(result)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
src/parser/payment-module.ts
Outdated
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
src/parser/payment-module.ts
Outdated
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; | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
src/parser/payment-module.ts
Outdated
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"); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return networkExplorer[0].url; | |
return networkExplorer[0]?.url; |
tests/helpers/web3.test.ts
Outdated
describe("getEvmWallet()", () => { | ||
it("Should return ERC20 token decimals", async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wrong test name
tests/helpers/web3.test.ts
Outdated
"0x94d7a85efef179560f9b821cadd20056600fdb9d", | ||
parseUnits("100", 18).toString() | ||
); | ||
expect(tx).toEqual("sdvfds"); |
There was a problem hiding this comment.
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?
tests/parser/payment-module.test.ts
Outdated
@@ -182,14 +202,49 @@ describe("permit-generation-module.ts", () => { | |||
}); | |||
}); | |||
|
|||
describe("Auto transfer mode tests", () => { |
There was a problem hiding this comment.
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
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 |
@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. |
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! |
That's a good idea, we can use batch version of |
If it's technically possible then yes it seems the most secure. |
@whilefoo It's almost complete, but there's one thing to consider: |
src/parser/payment-module.ts
Outdated
const totalFee = await permit2Wrapper.estimatePermitTransferFromGas(fundingWallet, batchTransferPermit); | ||
const totalReward = beneficiaries.amounts.reduce( | ||
(accumulator, current) => accumulator.add(current), | ||
BigNumber.from(0) | ||
); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
@@ -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())); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
I'm pretty sure the Supabase SDK can output types which answers this question. @gentlementlegen RFC |
Co-authored-by: whilefoo <[email protected]>
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? |
await Promise.all( | ||
beneficiaries.usernames.map(async (username) => { | ||
result[username].explorerUrl = `${networkExplorer}/tx/${tx.hash}`; | ||
beneficiaries.map(async (beneficiary, idx) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
beneficiaries.map(async (beneficiary, idx) => { | |
beneficiaries.forEach(async (beneficiary, idx) => { |
There was a problem hiding this comment.
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!
src/parser/payment-module.ts
Outdated
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) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also check allowance
src/parser/payment-module.ts
Outdated
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); |
There was a problem hiding this comment.
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
)
There was a problem hiding this comment.
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.
src/parser/payment-module.ts
Outdated
); | ||
return [tx, permits]; | ||
} catch (error) { | ||
const e = error as { reason: string; code: string; message: string }; |
There was a problem hiding this comment.
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
Resolves #226