Skip to content

feat: integrate ubeswap savings widget#623

Open
Oluwatomilola wants to merge 2 commits intoGoodDollar:masterfrom
Oluwatomilola:feat/savings-integration
Open

feat: integrate ubeswap savings widget#623
Oluwatomilola wants to merge 2 commits intoGoodDollar:masterfrom
Oluwatomilola:feat/savings-integration

Conversation

@Oluwatomilola
Copy link

@Oluwatomilola Oluwatomilola commented Feb 14, 2026

Description

This PR integrates the GoodDollar Savings Widget into GoodDapp as a first-class page.

Changes include:

Added a new Savings menu item to the sidebar.

Created a new /savings page using the same layout structure as Bridge/Swap/Claim.

Embedded the GooddollarSavingsWidget via a dedicated wrapper component.

Added contextual FAQs for the Savings feature.

Introduced a small SDK abstraction layer to support wallet providers (wagmi/viem).

This implements the requirements described in issue #619.

How Has This Been Tested?

  1. Ran the app locally using yarn start.

Please describe the tests that you ran to verify your changes.

Checklist:

  • PR title matches follow: (Feature|Bug|Chore) Task Name
  • My code follows the style guidelines of this project
  • I have followed all the instructions described in the initial task (check Definitions of Done)
  • I have performed a self-review of my own code
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have added reference to a related issue in the repository
  • I have added a detailed description of the changes proposed in the pull request. I am as descriptive as possible, assisting reviewers as much as possible.
  • I have added screenshots related to my pull request (for frontend tasks)
  • I have pasted a gif showing the feature.
  • @sirpy

Copy link

@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 6 issues, and left some high level feedback:

  • Consider removing temp_widget.ts, which defines a second gooddollar-savings-widget implementation that is not wired into the app and largely duplicates SavingsWidget.ts, to avoid confusion and accidental divergence.
  • src/savings-sdk-source.ts appears to export from ./viem-sdk and ./wagmi-sdk, but the actual implementation lives under utils/savings-sdk/viem-sdk.ts and there is no wagmi-sdk in this diff; either wire this file up correctly or delete it if it’s a leftover.
  • In SavingsWidget, the isLoading flag is never set to true/false around the async calls in refreshData/loadStats/loadUserStats, so the UI’s Loading... states will never show; either remove the conditional loading text or track the loading state properly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider removing `temp_widget.ts`, which defines a second `gooddollar-savings-widget` implementation that is not wired into the app and largely duplicates `SavingsWidget.ts`, to avoid confusion and accidental divergence.
- `src/savings-sdk-source.ts` appears to export from `./viem-sdk` and `./wagmi-sdk`, but the actual implementation lives under `utils/savings-sdk/viem-sdk.ts` and there is no `wagmi-sdk` in this diff; either wire this file up correctly or delete it if it’s a leftover.
- In `SavingsWidget`, the `isLoading` flag is never set to `true/false` around the async calls in `refreshData`/`loadStats`/`loadUserStats`, so the UI’s `Loading...` states will never show; either remove the conditional loading text or track the loading state properly.

## Individual Comments

### Comment 1
<location> `src/components/Savings/SavingsWidget.ts:566-575` </location>
<code_context>
+    private validateInput(force = false) {
</code_context>

<issue_to_address>
**issue (bug_risk):** Guard `parseEther` against malformed but regex-accepted inputs (e.g. trailing decimal point) to avoid runtime errors.

The current regex allows strings like `"1."` or `"."`, which `parseFloat` accepts but cause `parseEther` to throw, leading to uncaught exceptions. Either tighten the regex to reject trailing/standalone dots or wrap `parseEther` in try/catch and set `inputError` on failure to make the input handling safe.
</issue_to_address>

### Comment 2
<location> `src/components/Savings/SavingsWidget.ts:278-287` </location>
<code_context>
+            isLoading: { type: Boolean, state: true },
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider actually driving `isLoading` to reflect fetch state instead of leaving it permanently `false`.

`isLoading` is never set to `true`, so the UI will never show the loading state for balances, rewards, or APR. Consider toggling `isLoading` around `refreshData` / `loadStats` / `loadUserStats` so users see loading feedback during network requests.
</issue_to_address>

### Comment 3
<location> `src/savings-sdk-source.ts:1-3` </location>
<code_context>
+export * from './viem-sdk'
+export { useGooddollarSavings } from './wagmi-sdk'
+export type { GlobalStats, UserStats } from './viem-sdk'
</code_context>

<issue_to_address>
**issue (bug_risk):** The re-export paths here look inconsistent with the actual file location under `utils/savings-sdk`.

Since `GooddollarSavingsSDK` lives in `src/utils/savings-sdk/viem-sdk.ts`, these root-level exports will look for `src/viem-sdk.ts`, which doesn’t exist. If this is intended as a public entrypoint, the exports should target `'./utils/savings-sdk/viem-sdk'` (and similarly adjust the path for `wagmi-sdk`) to avoid build-time failures.
</issue_to_address>

### Comment 4
<location> `temp_widget.ts:7-8` </location>
<code_context>
+import { celo } from 'viem/chains';
+import { GooddollarSavingsSDK } from '@goodsdks/savings-sdk';
+
+@customElement('gooddollar-savings-widget')
+export class GooddollarSavingsWidget extends LitElement {
+  static styles = css`
+    :host {
</code_context>

<issue_to_address>
**issue (bug_risk):** Avoid registering the same custom element name twice across `temp_widget.ts` and `SavingsWidget.ts`.

This module also defines `gooddollar-savings-widget`, but `src/components/Savings/SavingsWidget.ts` already registers that tag via `customElements.define`. If both modules run, the second registration will throw a `DOMException`. If this file is only experimental, consider excluding it from the build or using a different tag name.
</issue_to_address>

### Comment 5
<location> `src/components/Savings/SavingsWidget.ts:15` </location>
<code_context>
+import { celo } from 'viem/chains'
+import { GooddollarSavingsSDK } from '../../utils/savings-sdk'
+
+export class SavingsWidget extends LitElement {
+    static get styles() {
+        return css`
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the shared savings business logic (SDK setup, state, actions, and validation) into a reusable controller used by both this widget and `temp_widget.ts` to keep the components focused on UI only.

You can keep this implementation but reduce complexity (and the duplication with `temp_widget.ts`) by extracting the “savings logic” into a shared controller and using it from both widgets.

### 1. Extract SDK + state + actions into a controller

Move all non‑UI concerns out of the component:

- web3 wiring (`walletClient`, `publicClient`, `sdk`, `userAddress`)
- data loading (`refreshData`, `loadStats`, `loadUserStats`, `resetUserStats`)
- staking flows (`handleStake`, `handleUnstake`, `handleClaim`)
- formatting / validation (`formatBigInt`, `toEtherNumber`, `formatPercent`, `validateInput`)

For example, create `savings-controller.ts`:

```ts
// savings-controller.ts
import {
  createWalletClient,
  createPublicClient,
  custom,
  PublicClient,
  WalletClient,
  http,
  formatEther,
  parseEther,
} from 'viem'
import { celo } from 'viem/chains'
import { GooddollarSavingsSDK } from '../../utils/savings-sdk'

export type SavingsState = {
  walletBalance: bigint
  currentStake: bigint
  unclaimedRewards: bigint
  totalStaked: bigint
  userWeeklyRewards: bigint
  annualAPR: number
}

export class SavingsController {
  private walletClient: WalletClient | null = null
  private publicClient: PublicClient | null = null
  private sdk: GooddollarSavingsSDK | null = null
  private userAddress: string | null = null

  state: SavingsState = {
    walletBalance: 0n,
    currentStake: 0n,
    unclaimedRewards: 0n,
    totalStaked: 0n,
    userWeeklyRewards: 0n,
    annualAPR: 0,
  }

  constructor(private web3Provider?: any) {}

  async refreshData() {
    if (!this.publicClient) {
      this.publicClient = createPublicClient({
        chain: celo,
        transport: http(),
      }) as unknown as PublicClient
    }

    if (this.web3Provider && this.web3Provider.isConnected) {
      this.walletClient = createWalletClient({
        chain: celo,
        transport: custom(this.web3Provider),
      })
      this.sdk = new GooddollarSavingsSDK(this.publicClient!, this.walletClient)
      await this.loadStats()

      const accounts = await this.web3Provider.request({ method: 'eth_accounts' })
      if (accounts.length > 0) {
        this.userAddress = accounts[0]
        await this.loadUserStats()
      } else {
        this.resetUserStats()
      }
    } else {
      this.sdk = new GooddollarSavingsSDK(this.publicClient!)
      await this.loadStats()
    }
  }

  private async loadStats() {
    if (!this.sdk) return
    const globalStats = await this.sdk.getGlobalStats()
    this.state.totalStaked = globalStats.totalStaked
    this.state.annualAPR = globalStats.annualAPR
  }

  private async loadUserStats() {
    if (!this.sdk || !this.userAddress) return
    const userStats = await this.sdk.getUserStats()
    this.state.walletBalance = userStats.walletBalance
    this.state.currentStake = userStats.currentStake
    this.state.unclaimedRewards = userStats.unclaimedRewards
    this.state.userWeeklyRewards = userStats.userWeeklyRewards
  }

  private resetUserStats() {
    this.state.walletBalance = 0n
    this.state.currentStake = 0n
    this.state.unclaimedRewards = 0n
    this.state.userWeeklyRewards = 0n
  }

  formatBigInt(num: bigint) {
    if (!num) return '0'
    return Intl.NumberFormat().format(this.toEtherNumber(num))
  }

  toEtherNumber(num: bigint) {
    return Number(formatEther(num))
  }

  formatPercent(num: number) {
    return `${num.toFixed(2)}%`
  }

  validateInput(
    inputAmount: string,
    activeTab: 'stake' | 'unstake',
  ): { error: string; amountWei?: bigint } {
    if (!inputAmount || inputAmount.trim() === '') {
      return { error: '' }
    }

    const validInputRegex = /^[0-9]*\.?[0-9]*$/
    if (!validInputRegex.test(inputAmount)) {
      return { error: 'Invalid value' }
    }

    const numValue = parseFloat(inputAmount)
    if (isNaN(numValue) || numValue <= 0) {
      return { error: 'Please enter a valid amount' }
    }

    const amountWei = parseEther(inputAmount)

    if (activeTab === 'stake' && amountWei > this.state.walletBalance) {
      return { error: 'Insufficient balance' }
    }

    if (activeTab === 'unstake' && amountWei > this.state.currentStake) {
      return { error: 'Max amount exceeded' }
    }

    return { error: '', amountWei }
  }

  async stake(amountWei: bigint) {
    if (!this.sdk || !this.userAddress) throw new Error('Wallet not connected')
    const receipt = await this.sdk.stake(amountWei)
    await this.refreshData()
    return receipt
  }

  async unstake(amountWei: bigint) {
    if (!this.sdk || !this.userAddress) throw new Error('Wallet not connected')
    const receipt = await this.sdk.unstake(amountWei)
    await this.refreshData()
    return receipt
  }

  async claim() {
    if (!this.sdk || !this.userAddress) throw new Error('Wallet not connected')
    const receipt = await this.sdk.claimReward()
    await this.refreshData()
    return receipt
  }
}
```

### 2. Use the controller in this widget (and `temp_widget.ts`)

The widget becomes mostly wiring + template, and `temp_widget.ts` can reuse the same controller:

```ts
// savings-widget.ts
import { SavingsController } from './savings-controller'

export class SavingsWidget extends LitElement {
  controller?: SavingsController

  static get properties() {
    return {
      web3Provider: { type: Object },
      // ...
      inputAmount: { type: String, state: true },
      inputError: { type: String, state: true },
    }
  }

  constructor() {
    super()
    this.inputAmount = '0.0'
    this.inputError = ''
  }

  updated(changedProps: Map<string, any>) {
    if (changedProps.has('web3Provider')) {
      this.controller = new SavingsController(this.web3Provider)
      void this.refreshData()
    }
  }

  private async refreshData() {
    if (!this.controller) return
    this.isLoading = true
    await this.controller.refreshData()
    const s = this.controller.state
    this.walletBalance = s.walletBalance
    this.currentStake = s.currentStake
    this.unclaimedRewards = s.unclaimedRewards
    this.totalStaked = s.totalStaked
    this.userWeeklyRewards = s.userWeeklyRewards
    this.annualAPR = s.annualAPR
    this.isLoading = false
  }

  private handleInputChange = (e: Event) => {
    this.inputAmount = (e.target as HTMLInputElement).value
    this.validateInput()
  }

  private validateInput(force = false) {
    if (!this.controller) return
    const { error } = this.controller.validateInput(
      this.inputAmount,
      this.activeTab as 'stake' | 'unstake',
    )
    this.inputError = force && !this.inputAmount ? 'Please enter a valid amount' : error
  }

  private handleStake = async () => {
    if (!this.controller) return
    const { error, amountWei } = this.controller.validateInput(
      this.inputAmount,
      'stake',
    )
    if (error || !amountWei) {
      this.inputError = error
      return
    }

    try {
      this.txLoading = true
      this.transactionError = ''
      await this.controller.stake(amountWei)
      this.inputAmount = '0.0'
      this.inputError = ''
    } catch (e: any) {
      this.transactionError = e.message || 'Staking failed'
    } finally {
      this.txLoading = false
    }
  }

  // handleUnstake / handleClaim can mirror handleStake but call controller.unstake / controller.claim
}
```

`temp_widget.ts` can then instantiate `SavingsController` in the same way, but keep its current decorators / class name / SDK import path differences. This removes the duplicated business logic and makes future changes to staking/claiming flows, validation, and formatting single‑sourced while preserving all current behavior.
</issue_to_address>

### Comment 6
<location> `temp_widget.ts:8` </location>
<code_context>
+import { GooddollarSavingsSDK } from '@goodsdks/savings-sdk';
+
+@customElement('gooddollar-savings-widget')
+export class GooddollarSavingsWidget extends LitElement {
+  static styles = css`
+    :host {
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the shared savings widget business logic into a reusable controller/module so both this widget and the existing one become thin UI wrappers instead of duplicating code.

The main complexity here is maintaining two near-identical implementations of the same widget. You can keep this new `gooddollar-savings-widget` while eliminating duplication by extracting the shared logic into a reusable module and turning both components into thin wrappers.

For example, pull the SDK wiring, state shape, and handlers into a class or composable function:

```ts
// src/components/Savings/sharedSavingsLogic.ts
import { GooddollarSavingsSDK } from '@goodsdks/savings-sdk';
import { createWalletClient, createPublicClient, custom, http, formatEther, parseEther } from 'viem';
import { celo } from 'viem/chains';

export interface SavingsState {
  activeTab: 'stake' | 'unstake';
  inputAmount: string;
  walletBalance: bigint;
  currentStake: bigint;
  unclaimedRewards: bigint;
  totalStaked: bigint;
  userWeeklyRewards: bigint;
  annualAPR: number;
  isLoading: boolean;
  txLoading: boolean;
  isClaiming: boolean;
  inputError: string;
  transactionError: string;
}

export class SavingsController {
  private publicClient = createPublicClient({ chain: celo, transport: http() });
  private walletClient: any = null;
  private sdk: GooddollarSavingsSDK | null = null;
  private userAddress: string | null = null;

  state: SavingsState = {
    activeTab: 'stake',
    inputAmount: '0.0',
    walletBalance: 0n,
    currentStake: 0n,
    unclaimedRewards: 0n,
    totalStaked: 0n,
    userWeeklyRewards: 0n,
    annualAPR: 0,
    isLoading: false,
    txLoading: false,
    isClaiming: false,
    inputError: '',
    transactionError: '',
  };

  constructor(private getWeb3Provider: () => any) {}

  async refreshData() {
    const web3Provider = this.getWeb3Provider();
    if (web3Provider && web3Provider.isConnected) {
      this.walletClient = createWalletClient({ chain: celo, transport: custom(web3Provider) });
      this.sdk = new GooddollarSavingsSDK(this.publicClient, this.walletClient);
      await this.loadStats();
      const accounts = await web3Provider.request({ method: 'eth_accounts' });
      this.userAddress = accounts[0] ?? null;
      this.userAddress ? await this.loadUserStats() : this.resetUserStats();
    } else {
      this.sdk = new GooddollarSavingsSDK(this.publicClient);
      await this.loadStats();
    }
  }

  // ...move loadStats, loadUserStats, resetUserStats, format helpers,
  // validateInput, handleStake/Unstake/Claim into methods here...
}
```

Then in this Lit component, bind to the controller instead of duplicating all logic:

```ts
// temp_widget.ts (or gooddollar-savings-widget.ts)
import { SavingsController } from './sharedSavingsLogic';

@customElement('gooddollar-savings-widget')
export class GooddollarSavingsWidget extends LitElement {
  @property({ type: Object }) web3Provider: any = null;
  @property({ type: Function }) connectWallet?: () => void;

  private controller = new SavingsController(() => this.web3Provider);

  // expose controller.state to the template:
  get state() {
    return this.controller.state;
  }

  connectedCallback() {
    super.connectedCallback();
    this.interval = setInterval(() => this.controller.refreshData(), 30_000);
    this.controller.refreshData();
  }

  // template stays almost the same but uses `this.state.*`
  render() {
    const { activeTab, inputAmount, walletBalance, currentStake } = this.state;
    // ...
  }
}
```

The existing `SavingsWidget.ts` can then use the same `SavingsController` (with or without decorators), so only the Lit wiring and markup differences live in each file, and all business logic (SDK interaction, validation, refresh, error handling) is defined once.
</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.

@sirpy
Copy link
Contributor

sirpy commented Feb 16, 2026

@Oluwatomilola Hey.
The savings widget already exists (See link in the original issue)
You were supposed to integrate that widget not write a new one

@sirpy sirpy linked an issue Feb 16, 2026 that may be closed by this pull request
3 tasks
@Oluwatomilola
Copy link
Author

Apologies for the mixup.
I will go through the savings widget again, remove the excesses and integrate the widget.

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.

Feat: Integrate ubeswap savings widget

2 participants

Comments