Skip to content

feat: add optional automatic ERC20 transfer mode - #170

Closed
xx7412421-cloud wants to merge 8 commits into
ubiquity-os:developmentfrom
xx7412421-cloud:grantfox-6-automatic-transfer-codex
Closed

feat: add optional automatic ERC20 transfer mode#170
xx7412421-cloud wants to merge 8 commits into
ubiquity-os:developmentfrom
xx7412421-cloud:grantfox-6-automatic-transfer-codex

Conversation

@xx7412421-cloud

Copy link
Copy Markdown

Resolves #6

Summary

  • Adds an optional transfer: true setting that routes ERC20 payout requests through a direct token transfer path instead of permit signature generation.
  • Adds generateErc20Transfer, which resolves the beneficiary wallet from the request username, decrypts the configured admin wallet, reads token decimals, estimates transfer gas, applies a 20% gas buffer, and sends the ERC20 transfer.
  • Adds operator fee support through worker-level environment settings: UBIQUITY_FEE_BPS and UBIQUITY_FEE_RECIPIENT.
  • Keeps existing ERC20 permit and ERC721 permit behavior unchanged when transfer is not enabled.

Safety And Edge Cases

  • The handler fails closed if token decimals cannot be read instead of assuming 18 decimals.
  • Fee basis points must be between 0 and 10000.
  • A fee recipient is required when a non-zero fee is configured, avoiding hidden ENS assumptions on non-ENS networks.
  • The beneficiary transfer is sent before the optional fee transfer, so a fee cannot be collected if the beneficiary payout fails.

Verification

  • bun x jest tests/generate-payout-permit.test.ts --runInBand
  • bun x jest --runInBand
  • bun run build
  • bun x prettier --check src/types/plugin-input.ts src/types/env.ts src/types/permits.ts src/handlers/generate-payout-permit.ts src/handlers/index.ts src/handlers/generate-erc20-transfer.ts tests/generate-payout-permit.test.ts
  • bun x eslint src/types/plugin-input.ts src/types/env.ts src/types/permits.ts src/handlers/generate-payout-permit.ts src/handlers/index.ts src/handlers/generate-erc20-transfer.ts tests/generate-payout-permit.test.ts
  • bun x cspell src/types/plugin-input.ts src/types/env.ts src/types/permits.ts src/handlers/generate-payout-permit.ts src/handlers/index.ts src/handlers/generate-erc20-transfer.ts tests/generate-payout-permit.test.ts

@ubiquity-os-beta ubiquity-os-beta Bot closed this Jun 20, 2026
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an optional transfer: true config mode to the permit-generation plugin. When enabled, ERC20 payout requests bypass permit signature generation and instead execute direct on-chain token transfers via a new generateErc20Transfer handler. The handler decrypts an admin wallet, reads token decimals on-chain, estimates gas with a configurable buffer, and sends transfers to the beneficiary then optionally to a fee recipient. Operator fee settings (UBIQUITY_FEE_BPS, UBIQUITY_FEE_RECIPIENT) are added as optional worker env vars. New types Erc20TransferReward, TransferReward, and PayoutReward are introduced, and generatePayoutPermit is updated to return PayoutReward[].

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main feature: adding optional automatic ERC20 transfer mode. It accurately reflects the primary change across the changeset.
Description check ✅ Passed The description follows the template, links issue #6, provides a comprehensive summary of changes, safety considerations, and verification commands.
Linked Issues check ✅ Passed The PR fully addresses issue #6 requirements: optional transfer mode via config, gas estimation, operator fee support (UBIQUITY_FEE_BPS/UBIQUITY_FEE_RECIPIENT), and fee transfer mechanism.
Out of Scope Changes check ✅ Passed All changes are scoped to issue #6: transfer mode feature, fee support, type definitions, handler implementation, environment variables, and tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/generate-payout-permit.test.ts (2)

58-67: ⚡ Quick win

Align the mocked transfer payload with the actual reward contract.

The mock and assertion omit tokenType and owner, which are part of the transfer reward shape returned by src/handlers/generate-erc20-transfer.ts. Including them will catch contract drift earlier.

Suggested patch
     (generateErc20Transfer as jest.Mock).mockReturnValue({
       type: "erc20-transfer",
+      tokenType: "ERC20",
       tokenAddress: "TOKEN_ADDRESS",
       beneficiary: SPENDER,
       amount: "100",
+      owner: "0x0000000000000000000000000000000000000001",
       networkId: 1,
       transactionHash: "0xabc",
       gasEstimate: "21000",
       feeTransfers: [],
     });
...
     expect(result).toMatchObject([
       {
         type: "erc20-transfer",
+        tokenType: "ERC20",
         tokenAddress: "TOKEN_ADDRESS",
         beneficiary: SPENDER,
         amount: "100",
+        owner: "0x0000000000000000000000000000000000000001",
         networkId: 1,
         transactionHash: "0xabc",
         gasEstimate: "21000",
         feeTransfers: [],
       },
     ]);

Also applies to: 113-124


127-136: ⚡ Quick win

Add guard-path tests for fee and gas-buffer validation.

These tests only verify happy paths. Add assertions for invalid fee bps (-1, 10001) and negative gas buffer to lock in the safety behavior.

Suggested patch
   it("should split operator fee from direct transfer amount", () => {
     expect(splitTransferAmount("1000", 250)).toEqual({
       beneficiaryAmount: "975",
       operatorFeeAmount: "25",
     });
   });

+  it("should reject invalid operator fee bps", () => {
+    expect(() => splitTransferAmount("1000", -1)).toThrow("between 0 and 10000");
+    expect(() => splitTransferAmount("1000", 10001)).toThrow("between 0 and 10000");
+  });
+
   it("should add a configurable gas buffer to direct transfer estimates", () => {
     expect(addGasBuffer("21000", 2000).toString()).toBe("25200");
   });
+
+  it("should reject negative gas buffer bps", () => {
+    expect(() => addGasBuffer("21000", -1)).toThrow("must not be negative");
+  });

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b04e8ee2-3da7-4650-9f1c-627fae6b742d

📥 Commits

Reviewing files that changed from the base of the PR and between 6bcfa62 and ee39cc2.

📒 Files selected for processing (8)
  • SUBMISSION_NOTE.md
  • src/handlers/generate-erc20-transfer.ts
  • src/handlers/generate-payout-permit.ts
  • src/handlers/index.ts
  • src/types/env.ts
  • src/types/permits.ts
  • src/types/plugin-input.ts
  • tests/generate-payout-permit.test.ts

Comment on lines +102 to +113
const feeBasisPoints = parseFeeBps(feeBps);
const { beneficiaryAmount, operatorFeeAmount } = splitTransferAmount(grossAmount, feeBasisPoints);

const beneficiaryTransfer = await sendTokenTransfer(tokenContract, walletAddress, beneficiaryAmount);
const feeTransfers = [];

if (!BigNumber.from(operatorFeeAmount).isZero()) {
if (!feeRecipient) {
throw new Error("UBIQUITY_FEE_RECIPIENT must be configured when UBIQUITY_FEE_BPS is greater than zero");
}
const resolvedFeeRecipient = await resolveTransferAddress(provider, feeRecipient);
const feeTransfer = await sendTokenTransfer(tokenContract, resolvedFeeRecipient, operatorFeeAmount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate fee config before sending the beneficiary transfer.

With UBIQUITY_FEE_BPS > 0 and no/invalid recipient, Line 105 sends the reduced beneficiary amount first, then Lines 109-113 throw. That creates an irreversible partial payout.

Proposed fix
   const grossAmount = utils.parseUnits(amount.toString(), tokenDecimals);
   const feeBasisPoints = parseFeeBps(feeBps);
+  let resolvedFeeRecipient: string | undefined;
+  if (feeBasisPoints > 0) {
+    if (!feeRecipient) {
+      throw new Error("UBIQUITY_FEE_RECIPIENT must be configured when UBIQUITY_FEE_BPS is greater than zero");
+    }
+    resolvedFeeRecipient = await resolveTransferAddress(provider, feeRecipient);
+  }
   const { beneficiaryAmount, operatorFeeAmount } = splitTransferAmount(grossAmount, feeBasisPoints);
 
   const beneficiaryTransfer = await sendTokenTransfer(tokenContract, walletAddress, beneficiaryAmount);
   const feeTransfers = [];
 
   if (!BigNumber.from(operatorFeeAmount).isZero()) {
-    if (!feeRecipient) {
-      throw new Error("UBIQUITY_FEE_RECIPIENT must be configured when UBIQUITY_FEE_BPS is greater than zero");
-    }
-    const resolvedFeeRecipient = await resolveTransferAddress(provider, feeRecipient);
+    if (!resolvedFeeRecipient) {
+      throw new Error("UBIQUITY_FEE_RECIPIENT must be configured when UBIQUITY_FEE_BPS is greater than zero");
+    }
     const feeTransfer = await sendTokenTransfer(tokenContract, resolvedFeeRecipient, operatorFeeAmount);

Comment on lines +145 to +150
const parsed = Number.parseInt(feeBps, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > BASIS_POINTS) {
throw new Error("UBIQUITY_FEE_BPS must be an integer between 0 and 10000");
}

return parsed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject malformed fee bps strings exactly.

parseInt accepts values like "25abc" or "25.9" as 25, so a bad secret can silently change fee collection.

Proposed fix
-  const parsed = Number.parseInt(feeBps, 10);
-  if (!Number.isFinite(parsed) || parsed < 0 || parsed > BASIS_POINTS) {
+  if (!/^\d+$/.test(feeBps)) {
+    throw new Error("UBIQUITY_FEE_BPS must be an integer between 0 and 10000");
+  }
+
+  const parsed = Number(feeBps);
+  if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > BASIS_POINTS) {
     throw new Error("UBIQUITY_FEE_BPS must be an integer between 0 and 10000");
   }

Comment on lines +187 to +195
async function sendTokenTransfer(tokenContract: Contract, beneficiary: string, amount: string) {
const gasEstimate = await tokenContract.estimateGas.transfer(beneficiary, amount);
const gasLimit = addGasBuffer(gasEstimate);
const transaction = await tokenContract.transfer(beneficiary, amount, { gasLimit });

return {
transactionHash: transaction.hash,
gasEstimate: gasEstimate.toString(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/handlers/generate-erc20-transfer.ts | head -200 | tail -30

Repository: ubiquity-os/permit-generation

Length of output: 1346


🏁 Script executed:

# Search for the function context
rg -A 20 "async function sendTokenTransfer" src/handlers/generate-erc20-transfer.ts

Repository: ubiquity-os/permit-generation

Length of output: 830


🏁 Script executed:

# Check for any wait() calls in the file to understand current patterns
rg "\.wait\(" src/handlers/generate-erc20-transfer.ts

Repository: ubiquity-os/permit-generation

Length of output: 55


🏁 Script executed:

# Check the imports to understand which Contract type is used
head -30 src/handlers/generate-erc20-transfer.ts

Repository: ubiquity-os/permit-generation

Length of output: 1174


🌐 Web query:

ethers.js Contract transfer() method does it wait for confirmation

💡 Result:

No, the ethers.js contract transfer method does not automatically wait for confirmation [1][2]. When you call a state-changing method like transfer on a contract instance connected to a Signer, ethers.js sends the transaction to the network and immediately returns a ContractTransactionResponse object (a promise that resolves to the transaction response) [1][3][4]. At this stage, the transaction has been submitted to the mempool but has not yet been mined or included in a block [1][2]. To wait for the transaction to be mined or to reach a specific number of confirmations, you must explicitly call the.wait method on the returned transaction object [1][3][5]. Example pattern: // Send the transaction (returns a TransactionResponse immediately) const tx = await contract.transfer(toAddress, amount); // Wait for the transaction to be included in a block (default: 1 confirmation) const receipt = await tx.wait; If you pass a number to.wait(confirms), the method will resolve once the transaction has been mined and has the specified number of confirmations [3][5].

Citations:


🏁 Script executed:

# Find calls to sendTokenTransfer to understand how the return value is used
rg "sendTokenTransfer" src/handlers/generate-erc20-transfer.ts -B 2 -A 2

Repository: ubiquity-os/permit-generation

Length of output: 807


🏁 Script executed:

# Check if there's any fee transfer logic after the beneficiary transfer
rg -A 50 "async function sendTokenTransfer" src/handlers/generate-erc20-transfer.ts | head -80

Repository: ubiquity-os/permit-generation

Length of output: 858


🏁 Script executed:

# Look for how transactionHash is used after being returned
rg "transactionHash" src/handlers/generate-erc20-transfer.ts -B 2 -A 2

Repository: ubiquity-os/permit-generation

Length of output: 541


🏁 Script executed:

# Check if there's any code that depends on the success of sendTokenTransfer before checking finality
rg -B 5 -A 10 "feeRecipient\|feeBps" src/handlers/generate-erc20-transfer.ts | head -100

Repository: ubiquity-os/permit-generation

Length of output: 55


Add transaction receipt check before reporting success.

tokenContract.transfer(...) broadcasts the transaction but doesn't wait for mining. Without explicit confirmation, a reverted or dropped transaction is reported as successful, and fee transfers run against unconfirmed state.

Proposed fix
   const gasEstimate = await tokenContract.estimateGas.transfer(beneficiary, amount);
   const gasLimit = addGasBuffer(gasEstimate);
   const transaction = await tokenContract.transfer(beneficiary, amount, { gasLimit });
+  const receipt = await transaction.wait(1);
+  if (receipt.status !== 1) {
+    throw new Error(`ERC20 transfer failed: ${transaction.hash}`);
+  }
 
   return {
-    transactionHash: transaction.hash,
+    transactionHash: receipt.transactionHash,
     gasEstimate: gasEstimate.toString(),
   };

Comment on lines +23 to +25
permit = context.config.transfer
? await generateErc20Transfer(context, username, amount, tokenAddress)
: await generateErc20PermitSignature(context, username, amount, tokenAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Add idempotency before executing direct transfers.

This branch performs irreversible ERC20 sends during payout generation. If a later request, fee transfer, or kernel callback fails, rerunning the job can duplicate already-sent payouts.

Use a persisted idempotency key per payout before calling generateErc20Transfer, and skip/return the existing transaction when it was already completed.

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.

Automatic Transfer

1 participant