Skip to content

feat: G$ Liquidity Adding Widget. (Ubeswap)#39

Open
kadiray34 wants to merge 1 commit intoGoodDollar:mainfrom
Ubeswap:main
Open

feat: G$ Liquidity Adding Widget. (Ubeswap)#39
kadiray34 wants to merge 1 commit intoGoodDollar:mainfrom
Ubeswap:main

Conversation

@kadiray34
Copy link
Copy Markdown
Contributor

@kadiray34 kadiray34 commented Apr 2, 2026

Description

This widget is for adding G$-USDGLO liquidity on Ubeswap. Users can add liquidity and manage through this widget.

Summary by Sourcery

Introduce a reusable Gooddollar liquidity web component for adding and tracking G$/USDGLO liquidity on Ubeswap, including on-chain interactions, range presets, and build configuration.

New Features:

  • Add Lit web component that lets users provide G$/USDGLO liquidity on Celo, handle token approvals, and submit add-liquidity transactions via viem.
  • Display live pool information, token balances, and derived token amounts with configurable price range presets (full, wide, narrow).
  • Expose a positions view showing the user’s G$/USDGLO liquidity NFT positions, token amounts, and unclaimed fees with links to manage positions on Ubeswap.
  • Provide custom theming, wallet connection hooks, and integration events for host apps to react to transaction lifecycle and minted positions.

Enhancements:

  • Add shared liquidity utilities for pool data loading, user balances, range math, and transaction services (approve, add/remove liquidity, collect fees) against Ubeswap’s position manager.
  • Introduce a small UI component library (tooltips, stepper, tx status, pool/position panels) to support a guided liquidity-provision flow.

Build:

  • Add standalone liquidity-widget package with tsup bundling config and TypeScript project setup for building distributable browser bundles.

Documentation:

  • Add README documenting how to embed, configure, and listen to events from the Gooddollar liquidity widget.

@kadiray34 kadiray34 requested review from a team and L03TJ3 April 2, 2026 14:45
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • In getUserPositions you sequentially call tokenOfOwnerByIndex and positions for each token; consider batching these with Promise.all or a multicall to avoid latency issues for users holding many positions.
  • Several calculations convert large bigint values to number (e.g. Number(formatEther(...)), Number(liquidity), Number(this.sqrtPriceX96)); if positions or pool values grow this may introduce precision loss or overflow, so it may be worth constraining/validating ranges or using bigint-based math where possible.
  • The 30s refreshInterval in GooddollarLiquidityWidget runs regardless of connection status and is not configurable; you might gate it on isConnected or expose the interval as a prop to reduce unnecessary RPC traffic for integrators.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `getUserPositions` you sequentially call `tokenOfOwnerByIndex` and `positions` for each token; consider batching these with `Promise.all` or a multicall to avoid latency issues for users holding many positions.
- Several calculations convert large `bigint` values to `number` (e.g. `Number(formatEther(...))`, `Number(liquidity)`, `Number(this.sqrtPriceX96)`); if positions or pool values grow this may introduce precision loss or overflow, so it may be worth constraining/validating ranges or using bigint-based math where possible.
- The 30s `refreshInterval` in `GooddollarLiquidityWidget` runs regardless of connection status and is not configurable; you might gate it on `isConnected` or expose the interval as a prop to reduce unnecessary RPC traffic for integrators.

## Individual Comments

### Comment 1
<location path="packages/liquidity-widget/src/liquidity/tx-service.ts" line_range="20-23" />
<code_context>
+  account: `0x${string}`,
+  tokenAddress: `0x${string}`,
+  amount: bigint,
+  bufferPercent: number = 5,
+  callbacks?: TxCallbacks,
+): Promise<`0x${string}`> {
+  const bufferedAmount = amount + (amount * BigInt(bufferPercent) / 100n);
+
+  const { request } = await publicClient.simulateContract({
</code_context>
<issue_to_address>
**issue (bug_risk):** BigInt(bufferPercent) will throw for non-integer percentages, causing unexpected runtime errors.

Since `bufferPercent` is a `number`, callers can pass non-integers (e.g. 2.5), and `BigInt(bufferPercent)` will throw a `RangeError`. Either change the logic to support fractional values (e.g. scale to an integer before converting to `bigint` and use a matching divisor), or restrict/validate `bufferPercent` so only integers are allowed before calling `BigInt`.
</issue_to_address>

### Comment 2
<location path="packages/liquidity-widget/src/liquidity/tx-service.ts" line_range="87-96" />
<code_context>
+  return hash;
+}
+
+export async function removeLiquidity(
+  publicClient: PublicClient,
+  walletClient: WalletClient,
+  account: `0x${string}`,
+  tokenId: bigint,
+  liquidity: bigint,
+  callbacks?: TxCallbacks,
+): Promise<`0x${string}`> {
+  const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);
+
+  const { request: decreaseReq } = await publicClient.simulateContract({
+    account,
+    address: POSITION_MANAGER,
+    abi: POSITION_MANAGER_ABI,
+    functionName: 'decreaseLiquidity',
+    args: [{
+      tokenId,
+      liquidity: BigInt(liquidity) as unknown as bigint & { __brand: 'uint128' },
+      amount0Min: 0n,
+      amount1Min: 0n,
</code_context>
<issue_to_address>
**issue (bug_risk):** removeLiquidity does not enforce the uint128 limit, risking contract reverts for oversized liquidity values.

The `liquidity` value is cast/braned as `uint128` without validation, so callers can pass values > 2^128-1 and cause on-chain reverts. Since `MAX_UINT128` is already defined, add an explicit check (e.g. `liquidity <= MAX_UINT128`) or clamp before branding to keep the API safe and predictable.
</issue_to_address>

### Comment 3
<location path="packages/liquidity-widget/src/GooddollarLiquidityWidget.ts" line_range="533-536" />
<code_context>
+    this._validateInputs();
+  }
+
+  private _handleGdMax() {
+    this.gdInput = Number(formatEther(this.gdBalance)).toString();
+    this._calcUsdgloFromGd();
+    this.inputError = '';
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Converting formatted token balances through Number() can lose precision for large values.

Because this value is passed back into `parseEther`, the round‑trip via `Number` can produce a slightly different amount than the actual balance. Consider keeping the string returned by `formatEther`, or using a decimal-safe helper that avoids converting through `Number`.

```suggestion
  private _handleGdMax() {
    // Use the string returned by formatEther directly to avoid precision loss
    this.gdInput = formatEther(this.gdBalance);
    this._calcUsdgloFromGd();
    this.inputError = '';
```
</issue_to_address>

### Comment 4
<location path="packages/liquidity-widget/README.md" line_range="7" />
<code_context>
+
+## Integrating The Component
+
+Can be used in any website, for a quick setup:
+
+1. **Download the Script**: Download the `index.global.js` file from the project releases or build it from the source.
</code_context>
<issue_to_address>
**suggestion (typo):** This sentence is missing a subject and has an unnecessary comma.

Consider: “It can be used on any website for a quick setup:” to add a clear subject and remove the unnecessary comma.

```suggestion
It can be used on any website for a quick setup:
```
</issue_to_address>

### Comment 5
<location path="packages/liquidity-widget/src/GooddollarLiquidityWidget.ts" line_range="474" />
<code_context>
+    if (isFinite(gd) && gd >= 0) this.gdInput = formatAmount(gd);
+  }
+
+  private _safeParseEther(value: string): bigint {
+    try {
+      if (!value || value.trim() === '' || value === '.') return 0n;
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the input parsing/validation logic and repeated tx-step mutations into small shared helpers to keep the widget component focused on wiring state and UI only.

You can reduce complexity in this widget without changing behavior by extracting some of the heavier non‑UI logic into small helpers. Two low‑risk wins:

---

### 1. Isolate input parsing & validation

`_safeParseEther`, `_validateInputs`, and the input handlers all embed regex, parsing, and balance checks directly in the component. You can centralize this into a pure helper so the component only wires values and state.

**Before (inside component):**

```ts
private _safeParseEther(value: string): bigint {
  try {
    if (!value || value.trim() === '' || value === '.') return 0n;
    return parseEther(value);
  } catch { return 0n; }
}

private _validateInputs(force = false) {
  this.inputError = '';
  const re = /^[0-9]*\.?[0-9]*$/;
  if (this.gdInput && !re.test(this.gdInput)) { this.inputError = 'Invalid G$ value'; return; }
  if (this.usdgloInput && !re.test(this.usdgloInput)) { this.inputError = 'Invalid USDGLO value'; return; }

  const gdNum = parseFloat(this.gdInput || '0');
  const usdgloNum = parseFloat(this.usdgloInput || '0');

  if (force && (isNaN(gdNum) || gdNum <= 0 || isNaN(usdgloNum) || usdgloNum <= 0)) {
    this.inputError = 'Please enter valid amounts';
    return;
  }
  if (gdNum > 0 && this._safeParseEther(this.gdInput) > this.gdBalance) {
    this.inputError = 'Insufficient G$ balance';
    return;
  }
  if (usdgloNum > 0 && this._safeParseEther(this.usdgloInput) > this.usdgloBalance) {
    this.inputError = 'Insufficient USDGLO balance';
    return;
  }
}
```

**After (new `validation.ts` + slimmer component):**

```ts
// validation.ts
import { parseEther } from 'viem';

export function safeParseEther(value: string): bigint {
  try {
    if (!value || value.trim() === '' || value === '.') return 0n;
    return parseEther(value);
  } catch {
    return 0n;
  }
}

const INPUT_RE = /^[0-9]*\.?[0-9]*$/;

export function validateInputs(params: {
  gdInput: string;
  usdgloInput: string;
  gdBalance: bigint;
  usdgloBalance: bigint;
  force?: boolean;
}): string | null {
  const { gdInput, usdgloInput, gdBalance, usdgloBalance, force } = params;

  if (gdInput && !INPUT_RE.test(gdInput)) return 'Invalid G$ value';
  if (usdgloInput && !INPUT_RE.test(usdgloInput)) return 'Invalid USDGLO value';

  const gdNum = parseFloat(gdInput || '0');
  const usdgloNum = parseFloat(usdgloInput || '0');

  if (force && (isNaN(gdNum) || gdNum <= 0 || isNaN(usdgloNum) || usdgloNum <= 0)) {
    return 'Please enter valid amounts';
  }
  if (gdNum > 0 && safeParseEther(gdInput) > gdBalance) return 'Insufficient G$ balance';
  if (usdgloNum > 0 && safeParseEther(usdgloInput) > usdgloBalance) return 'Insufficient USDGLO balance';

  return null;
}
```

```ts
// in component
import { safeParseEther, validateInputs } from './liquidity/validation';

private _validateInputs(force = false) {
  this.inputError = validateInputs({
    gdInput: this.gdInput,
    usdgloInput: this.usdgloInput,
    gdBalance: this.gdBalance,
    usdgloBalance: this.usdgloBalance,
    force,
  }) ?? '';
}

private _getButtonLabel(): string {
  if (this.txPhase === 'success') return 'Add More Liquidity';

  const gdWei = safeParseEther(this.gdInput);
  const usdgloWei = safeParseEther(this.usdgloInput);

  if (gdWei > 0n && this.gdAllowance < gdWei) return 'Approve & Add Liquidity';
  if (usdgloWei > 0n && this.usdgloAllowance < usdgloWei) return 'Approve & Add Liquidity';
  return 'Add Liquidity';
}
```

This makes handlers like `_handleGdInput/_handleUsdgloInput` just update strings and call the pure validator.

---

### 2. Encapsulate repeated step mutations in the tx flow

`_handleMainAction` mutates `steps` in place several times:

```ts
steps[0].status = 'active'; this.txSteps = [...steps];
...
steps[0].status = 'completed'; steps[0].txHash = hash; this.txSteps = [...steps];
...
steps[1].status = 'active'; this.txSteps = [...steps];
...
steps[1].status = 'completed'; steps[1].txHash = hash; this.txSteps = [...steps];
...
steps[2].status = 'active'; this.txSteps = [...steps];
...
steps[2].status = 'completed'; steps[2].txHash = hash; this.txSteps = [...steps];
```

You can keep the entire flow in the component but extract a tiny helper to update step status + tx hash, reducing repetition and making it easier to change step shape later.

```ts
// tx-steps.ts
import type { TxStepInfo } from './liquidity/types';

export function updateStep(
  steps: TxStepInfo[],
  index: number,
  patch: Partial<TxStepInfo>,
): TxStepInfo[] {
  const next = steps.slice();
  next[index] = { ...next[index], ...patch };
  return next;
}
```

```ts
// in component
import { updateStep } from './liquidity/tx-steps';

async _handleMainAction() {
  // ... existing setup ...

  const steps: TxStepInfo[] = [
    { label: 'Approve G$', status: needGdApproval ? 'pending' : 'skipped' },
    { label: 'Approve USDGLO', status: needUsdgloApproval ? 'pending' : 'skipped' },
    { label: 'Add Liquidity', status: 'pending' },
  ];
  this.txSteps = steps;
  this.txError = '';

  try {
    if (needGdApproval) {
      this.txPhase = 'approving-gd';
      this.txHash = '';
      this.txSteps = updateStep(steps, 0, { status: 'active' });

      const hash = await approveToken(/* ... */);

      this.txSteps = steps = updateStep(steps, 0, { status: 'completed', txHash: hash });
      await this.refreshData();
    }

    if (needUsdgloApproval) {
      this.txPhase = 'approving-usdglo';
      this.txHash = '';
      this.txSteps = updateStep(steps, 1, { status: 'active' });

      const hash = await approveToken(/* ... */);

      this.txSteps = steps = updateStep(steps, 1, { status: 'completed', txHash: hash });
      await this.refreshData();
    }

    this.txPhase = 'minting';
    this.txHash = '';
    this.txSteps = updateStep(steps, 2, { status: 'active' });

    const hash = await addLiquidity(/* ... */);

    this.txSteps = updateStep(steps, 2, { status: 'completed', txHash: hash });
    // ... rest unchanged ...
  } catch (error: any) {
    // unchanged
  }
}
```

This keeps the same behavior and UX while making `_handleMainAction` shorter, more regular, and easier to extend (e.g., new steps) without duplicating mutation patterns.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +20 to +23
bufferPercent: number = 5,
callbacks?: TxCallbacks,
): Promise<`0x${string}`> {
const bufferedAmount = amount + (amount * BigInt(bufferPercent) / 100n);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): BigInt(bufferPercent) will throw for non-integer percentages, causing unexpected runtime errors.

Since bufferPercent is a number, callers can pass non-integers (e.g. 2.5), and BigInt(bufferPercent) will throw a RangeError. Either change the logic to support fractional values (e.g. scale to an integer before converting to bigint and use a matching divisor), or restrict/validate bufferPercent so only integers are allowed before calling BigInt.

Comment on lines +87 to +96
export async function removeLiquidity(
publicClient: PublicClient,
walletClient: WalletClient,
account: `0x${string}`,
tokenId: bigint,
liquidity: bigint,
callbacks?: TxCallbacks,
): Promise<`0x${string}`> {
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): removeLiquidity does not enforce the uint128 limit, risking contract reverts for oversized liquidity values.

The liquidity value is cast/braned as uint128 without validation, so callers can pass values > 2^128-1 and cause on-chain reverts. Since MAX_UINT128 is already defined, add an explicit check (e.g. liquidity <= MAX_UINT128) or clamp before branding to keep the API safe and predictable.

Comment on lines +533 to +536
private _handleGdMax() {
this.gdInput = Number(formatEther(this.gdBalance)).toString();
this._calcUsdgloFromGd();
this.inputError = '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Converting formatted token balances through Number() can lose precision for large values.

Because this value is passed back into parseEther, the round‑trip via Number can produce a slightly different amount than the actual balance. Consider keeping the string returned by formatEther, or using a decimal-safe helper that avoids converting through Number.

Suggested change
private _handleGdMax() {
this.gdInput = Number(formatEther(this.gdBalance)).toString();
this._calcUsdgloFromGd();
this.inputError = '';
private _handleGdMax() {
// Use the string returned by formatEther directly to avoid precision loss
this.gdInput = formatEther(this.gdBalance);
this._calcUsdgloFromGd();
this.inputError = '';

@L03TJ3
Copy link
Copy Markdown
Collaborator

L03TJ3 commented Apr 6, 2026

@kadiray34 can you make a feature-branch in your work and create the pull-request against that branch instead of working on main

Comment on lines +25 to +31
export const POOL_ABI = parseAbi([
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
]);

export const POSITION_MANAGER_ABI = [
{
inputs: [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why choose to mix ABI definitions?
parseAbi is more readable from my pov + is used in almost any other sdk (including savings sdk)

) {
const [gdBalance, usdgloBalance, gdAllowance, usdgloAllowance] = await Promise.all([
publicClient.readContract({
address: GD_TOKEN, abi: (await import('./constants')).ERC20_ABI,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. no need for dynamic import, can just import ERC20_ABI.

  2. can use publicClient.multicall

  const [gdBalance, usdgloBalance, gdAllowance, usdgloAllowance] =
    await publicClient.multicall({
      contracts: [
        { address: GD_TOKEN, abi: ERC20_ABI, functionName: 'balanceOf', args: [userAddress] },
        { address: USDGLO_TOKEN, abi: ERC20_ABI, functionName: 'balanceOf', args: [userAddress] },
        { address: GD_TOKEN, abi: ERC20_ABI, functionName: 'allowance', args: [userAddress, POSITION_MANAGER] },
        { address: USDGLO_TOKEN, abi: ERC20_ABI, functionName: 'allowance', args: [userAddress, POSITION_MANAGER] },
      ],
      allowFailure: false,
    });

return hash;
}

export function parseTxError(error: any): string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for easier extending possible errors to support, consider using a error message map instead of repeating this logic

splitting: false,
sourcemap: false,
clean: true,
dts: false,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the webcomponent might be released as npm package for environments that support it.
considering react/ts environments dts should be turned on

this.txSteps = [...steps];
this.txError = '';

try {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems the steps are not applying their statusses as expected/intended.
not sure if its fix here or in lw-stepper

UI:

Image

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.

2 participants