From daf00944d401c220481db1bf52f8fa77122b6b5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 11:22:25 +0000 Subject: [PATCH 1/7] Initial plan From 468df59454f54ad63a9136980b23023438539d01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 11:31:06 +0000 Subject: [PATCH 2/7] fix: resolve savings sdk/widget staking and error handling issues Agent-Logs-Url: https://github.com/GoodDollar/GoodSDKs/sessions/3aa3109f-691f-4527-ba8f-f9a5ae0c76b8 Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- README.md | 1 + apps/demo-savings-widget/index.html | 50 +++++++++++++ apps/demo-savings-widget/package.json | 17 +++++ apps/demo-savings-widget/src/index.js | 58 +++++++++++++++ packages/savings-sdk/src/viem-sdk.ts | 41 +++++++++- .../src/GooddollarSavingsWidget.ts | 74 ++++++++++++++++++- yarn.lock | 11 ++- 7 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 apps/demo-savings-widget/index.html create mode 100644 apps/demo-savings-widget/package.json create mode 100644 apps/demo-savings-widget/src/index.js diff --git a/README.md b/README.md index d774b36..66370cb 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Common tasks: - `apps/demo-identity-app` – React demo covering identity + claim flows - `apps/engagement-app` – engagement rewards showcase - `apps/demo-webcomponents` – Vite playground for Lit components +- `apps/demo-savings-widget` – Vite demo to test the savings widget with an injected wallet Each app documents its development server and environment requirements inside its own README. diff --git a/apps/demo-savings-widget/index.html b/apps/demo-savings-widget/index.html new file mode 100644 index 0000000..46495b8 --- /dev/null +++ b/apps/demo-savings-widget/index.html @@ -0,0 +1,50 @@ + + + + + + GoodDollar Savings Widget Demo + + +
+
+

Savings Widget Demo

+

Wallet not connected

+ +
+ + +
+ + + diff --git a/apps/demo-savings-widget/package.json b/apps/demo-savings-widget/package.json new file mode 100644 index 0000000..2737c97 --- /dev/null +++ b/apps/demo-savings-widget/package.json @@ -0,0 +1,17 @@ +{ + "name": "demo-savings-widget", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 3001", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@goodsdks/savings-widget": "workspace:*" + }, + "devDependencies": { + "vite": "6.3.5" + } +} diff --git a/apps/demo-savings-widget/src/index.js b/apps/demo-savings-widget/src/index.js new file mode 100644 index 0000000..0100763 --- /dev/null +++ b/apps/demo-savings-widget/src/index.js @@ -0,0 +1,58 @@ +import "@goodsdks/savings-widget" + +const savingsWidget = document.getElementById("savingsWidget") +const walletStatus = document.getElementById("walletStatus") +const connectWalletButton = document.getElementById("connectWalletButton") + +const getProvider = () => window.ethereum ?? null + +const updateWalletStatus = async () => { + const provider = getProvider() + if (!provider?.request) { + walletStatus.textContent = "No injected wallet found (install MetaMask or Valora extension)" + connectWalletButton.disabled = true + connectWalletButton.style.opacity = "0.6" + return + } + + const accounts = await provider.request({ method: "eth_accounts" }) + if (accounts?.length) { + const [account] = accounts + walletStatus.textContent = `Connected: ${account.slice(0, 6)}...${account.slice(-4)}` + savingsWidget.web3Provider = provider + } else { + walletStatus.textContent = "Wallet not connected" + savingsWidget.web3Provider = null + } +} + +const connectWallet = async () => { + const provider = getProvider() + if (!provider?.request) { + walletStatus.textContent = "No injected wallet found (install MetaMask or Valora extension)" + return + } + + try { + await provider.request({ method: "eth_requestAccounts" }) + await updateWalletStatus() + } catch (error) { + console.error("Wallet connection failed:", error) + walletStatus.textContent = "Wallet connection was rejected" + } +} + +customElements.whenDefined("gooddollar-savings-widget").then(async () => { + savingsWidget.connectWallet = connectWallet + connectWalletButton.addEventListener("click", connectWallet) + + const provider = getProvider() + provider?.on?.("accountsChanged", () => { + updateWalletStatus().catch(console.error) + }) + provider?.on?.("chainChanged", () => { + updateWalletStatus().catch(console.error) + }) + + await updateWalletStatus() +}) diff --git a/packages/savings-sdk/src/viem-sdk.ts b/packages/savings-sdk/src/viem-sdk.ts index a26cfef..918cc12 100644 --- a/packages/savings-sdk/src/viem-sdk.ts +++ b/packages/savings-sdk/src/viem-sdk.ts @@ -28,6 +28,7 @@ const STAKING_CONTRACT_ADDRESS = "0x799a23dA264A157Db6F9c02BE62F82CE8d602A45" as const const GDOLLAR_CONTRACT_ADDRESS = "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A" as const +const CELO_MAINNET_CHAIN_ID = 42220 const stakingContract = { @@ -63,7 +64,7 @@ export class GooddollarSavingsSDK { walletClient?: WalletClient, ) { if (!publicClient) throw new Error("Public client is required") - if (!(publicClient.chain?.id === 42220)) { + if (!(publicClient.chain?.id === CELO_MAINNET_CHAIN_ID)) { throw new Error("Public client must be connected to Celo mainnet") } this.publicClient = publicClient @@ -74,7 +75,7 @@ export class GooddollarSavingsSDK { } setWalletClient(walletClient: WalletClient) { - if (!(walletClient.chain?.id === 42220)) { + if (!(walletClient.chain?.id === CELO_MAINNET_CHAIN_ID)) { throw new Error("Wallet client must be connected to Celo mainnet") } this.walletClient = walletClient @@ -137,8 +138,15 @@ export class GooddollarSavingsSDK { ]) let userWeeklyRewards = BigInt(0) - if (staked > BigInt(0) && this.totalStaked == BigInt(0)) { + if (this.totalStaked === BigInt(0)) { await this.getGlobalStats() + } + + if ( + staked > BigInt(0) && + this.totalStaked > BigInt(0) && + this.cachedRewardRate > BigInt(0) + ) { const oneWeekSeconds = BigInt(7 * 24 * 60 * 60) userWeeklyRewards = (this.cachedRewardRate * oneWeekSeconds * staked) / this.totalStaked @@ -208,6 +216,7 @@ export class GooddollarSavingsSDK { onHash?: (hash: `0x${string}`) => void, ) { if (!this.walletClient) throw new Error("Wallet client not initialized") + await this.assertWalletOnCeloMainnet() const account = await this.getAccount() @@ -250,7 +259,7 @@ export class GooddollarSavingsSDK { }) if (allowance < amount) { - await this.submitAndWait( + const approvalReceipt = await this.submitAndWait( { ...gdollarContract, functionName: "approve", @@ -258,6 +267,30 @@ export class GooddollarSavingsSDK { }, onHash, ) + + if (approvalReceipt.status !== "success") { + throw new Error("Approval transaction failed") + } + + const updatedAllowance = await this.publicClient.readContract({ + ...gdollarContract, + functionName: "allowance", + args: [account, STAKING_CONTRACT_ADDRESS], + }) + + if (updatedAllowance < amount) { + throw new Error( + "Approval is still insufficient. Please wait for confirmation and try staking again.", + ) + } + } + } + + private async assertWalletOnCeloMainnet() { + if (!this.walletClient) return + const walletChainId = await this.walletClient.getChainId() + if (walletChainId !== CELO_MAINNET_CHAIN_ID) { + throw new Error("Wrong network. Please switch your wallet to Celo mainnet.") } } diff --git a/packages/savings-widget/src/GooddollarSavingsWidget.ts b/packages/savings-widget/src/GooddollarSavingsWidget.ts index 152de82..11c7d1c 100644 --- a/packages/savings-widget/src/GooddollarSavingsWidget.ts +++ b/packages/savings-widget/src/GooddollarSavingsWidget.ts @@ -4,6 +4,9 @@ import { createWalletClient, createPublicClient, custom, PublicClient, WalletCli import { celo } from 'viem/chains'; import { GooddollarSavingsSDK } from '@goodsdks/savings-sdk'; +const CELO_CHAIN_ID = 42220; +const CELO_CHAIN_ID_HEX = '0xa4ec'; + @customElement('gooddollar-savings-widget') export class GooddollarSavingsWidget extends LitElement { static styles = css` @@ -581,6 +584,9 @@ export class GooddollarSavingsWidget extends LitElement { } try { + const isCeloNetwork = await this.ensureCeloNetwork(); + if (!isCeloNetwork) return; + this.txLoading = true; this.transactionError = ''; const amount = parseEther(this.inputAmount); @@ -593,7 +599,7 @@ export class GooddollarSavingsWidget extends LitElement { } } catch (error: any) { console.error('Staking error:', error); - this.transactionError = error.message || 'Staking failed'; + this.transactionError = this.toUserErrorMessage(error, 'Staking failed'); } finally { this.txLoading = false; } @@ -607,6 +613,9 @@ export class GooddollarSavingsWidget extends LitElement { } try { + const isCeloNetwork = await this.ensureCeloNetwork(); + if (!isCeloNetwork) return; + this.txLoading = true; this.transactionError = ''; const amount = parseEther(this.inputAmount); @@ -619,7 +628,7 @@ export class GooddollarSavingsWidget extends LitElement { } } catch (error: any) { console.error('Unstaking error:', error); - this.transactionError = error.message || 'Unstaking failed'; + this.transactionError = this.toUserErrorMessage(error, 'Unstaking failed'); } finally { this.txLoading = false; } @@ -629,6 +638,9 @@ export class GooddollarSavingsWidget extends LitElement { if (!this.sdk || !this.userAddress) return; try { + const isCeloNetwork = await this.ensureCeloNetwork(); + if (!isCeloNetwork) return; + this.isClaiming = true; this.transactionError = ''; const receipt = await this.sdk.claimReward(); @@ -638,9 +650,65 @@ export class GooddollarSavingsWidget extends LitElement { } } catch (error: any) { console.error('Claim error:', error); - this.transactionError = error.message || 'Claim failed'; + this.transactionError = this.toUserErrorMessage(error, 'Claim failed'); } finally { this.isClaiming = false; } } + + private async ensureCeloNetwork(): Promise { + if (!this.web3Provider?.request) return true; + + const chainIdHex = await this.web3Provider.request({ method: 'eth_chainId' }); + const currentChainId = parseInt(chainIdHex, 16); + if (currentChainId === CELO_CHAIN_ID) return true; + + try { + await this.web3Provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: CELO_CHAIN_ID_HEX }], + }); + this.transactionError = ''; + return true; + } catch (error: any) { + this.transactionError = + this.toUserErrorMessage(error) || 'Please switch to Celo mainnet to continue.'; + return false; + } + } + + private toUserErrorMessage(error: any, fallback: string = 'Transaction failed') { + if (!error) return fallback; + + const shortMessage = error?.shortMessage || error?.cause?.shortMessage; + const message = shortMessage || error?.message || String(error); + const lines = message + .split('\n') + .map((line: string) => line.trim()) + .filter(Boolean); + const firstLine = lines[0] || fallback; + const cleaned = firstLine + .split('Contract Call:')[0] + .split('Docs:')[0] + .split('Details:')[0] + .trim(); + + const lower = cleaned.toLowerCase(); + if (lower.includes('user rejected') || lower.includes('rejected the request')) { + return 'Transaction rejected in wallet.'; + } + if (lower.includes('insufficient funds')) { + return 'Insufficient funds to pay for gas.'; + } + if ( + lower.includes('wrong network') || + lower.includes('chain mismatch') || + lower.includes('switch your wallet to celo') || + lower.includes('unsupported chain') + ) { + return 'Please switch to Celo mainnet to continue.'; + } + + return cleaned || fallback; + } } diff --git a/yarn.lock b/yarn.lock index 4bbf83b..44de2eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2228,7 +2228,7 @@ __metadata: languageName: unknown linkType: soft -"@goodsdks/savings-widget@workspace:packages/savings-widget": +"@goodsdks/savings-widget@workspace:*, @goodsdks/savings-widget@workspace:packages/savings-widget": version: 0.0.0-use.local resolution: "@goodsdks/savings-widget@workspace:packages/savings-widget" dependencies: @@ -10611,6 +10611,15 @@ __metadata: languageName: unknown linkType: soft +"demo-savings-widget@workspace:apps/demo-savings-widget": + version: 0.0.0-use.local + resolution: "demo-savings-widget@workspace:apps/demo-savings-widget" + dependencies: + "@goodsdks/savings-widget": "workspace:*" + vite: "npm:6.3.5" + languageName: unknown + linkType: soft + "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" From 58f92ef5313f0c171dffd7d076c7589e1a0eef5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 11:33:05 +0000 Subject: [PATCH 3/7] chore: refine savings network switch and demo copy Agent-Logs-Url: https://github.com/GoodDollar/GoodSDKs/sessions/3aa3109f-691f-4527-ba8f-f9a5ae0c76b8 Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- apps/demo-savings-widget/src/index.js | 4 ++-- packages/savings-widget/src/GooddollarSavingsWidget.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/demo-savings-widget/src/index.js b/apps/demo-savings-widget/src/index.js index 0100763..4e25a5c 100644 --- a/apps/demo-savings-widget/src/index.js +++ b/apps/demo-savings-widget/src/index.js @@ -9,7 +9,7 @@ const getProvider = () => window.ethereum ?? null const updateWalletStatus = async () => { const provider = getProvider() if (!provider?.request) { - walletStatus.textContent = "No injected wallet found (install MetaMask or Valora extension)" + walletStatus.textContent = "No injected wallet found (install a browser wallet like MetaMask)" connectWalletButton.disabled = true connectWalletButton.style.opacity = "0.6" return @@ -29,7 +29,7 @@ const updateWalletStatus = async () => { const connectWallet = async () => { const provider = getProvider() if (!provider?.request) { - walletStatus.textContent = "No injected wallet found (install MetaMask or Valora extension)" + walletStatus.textContent = "No injected wallet found (install a browser wallet like MetaMask)" return } diff --git a/packages/savings-widget/src/GooddollarSavingsWidget.ts b/packages/savings-widget/src/GooddollarSavingsWidget.ts index 11c7d1c..2bd7406 100644 --- a/packages/savings-widget/src/GooddollarSavingsWidget.ts +++ b/packages/savings-widget/src/GooddollarSavingsWidget.ts @@ -672,7 +672,7 @@ export class GooddollarSavingsWidget extends LitElement { return true; } catch (error: any) { this.transactionError = - this.toUserErrorMessage(error) || 'Please switch to Celo mainnet to continue.'; + this.toUserErrorMessage(error, 'Failed to switch wallet network.'); return false; } } From ac517fd0b86fad946258e917536587ce071689f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 11:34:49 +0000 Subject: [PATCH 4/7] chore: tighten savings widget error typing Agent-Logs-Url: https://github.com/GoodDollar/GoodSDKs/sessions/3aa3109f-691f-4527-ba8f-f9a5ae0c76b8 Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com> --- .../savings-widget/src/GooddollarSavingsWidget.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/savings-widget/src/GooddollarSavingsWidget.ts b/packages/savings-widget/src/GooddollarSavingsWidget.ts index 2bd7406..9ff44a5 100644 --- a/packages/savings-widget/src/GooddollarSavingsWidget.ts +++ b/packages/savings-widget/src/GooddollarSavingsWidget.ts @@ -671,17 +671,21 @@ export class GooddollarSavingsWidget extends LitElement { this.transactionError = ''; return true; } catch (error: any) { - this.transactionError = - this.toUserErrorMessage(error, 'Failed to switch wallet network.'); + this.transactionError = this.toUserErrorMessage(error, 'Failed to switch wallet network.'); return false; } } - private toUserErrorMessage(error: any, fallback: string = 'Transaction failed') { + private toUserErrorMessage(error: unknown, fallback: string = 'Transaction failed') { if (!error) return fallback; - const shortMessage = error?.shortMessage || error?.cause?.shortMessage; - const message = shortMessage || error?.message || String(error); + const maybeError = error as { + shortMessage?: string; + message?: string; + cause?: { shortMessage?: string }; + }; + const shortMessage = maybeError.shortMessage || maybeError.cause?.shortMessage; + const message = shortMessage || maybeError.message || String(error); const lines = message .split('\n') .map((line: string) => line.trim()) From 9d754077a36acd5385011e93d8f185f3de13218a Mon Sep 17 00:00:00 2001 From: Kadir Ay Date: Mon, 18 May 2026 13:42:50 +0300 Subject: [PATCH 5/7] fix: invalid approve amount --- packages/savings-sdk/src/viem-sdk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/savings-sdk/src/viem-sdk.ts b/packages/savings-sdk/src/viem-sdk.ts index 918cc12..073c551 100644 --- a/packages/savings-sdk/src/viem-sdk.ts +++ b/packages/savings-sdk/src/viem-sdk.ts @@ -228,7 +228,7 @@ export class GooddollarSavingsSDK { const hash = await this.walletClient.writeContract(request) if (onHash) onHash(hash) - const receipt = await this.publicClient.waitForTransactionReceipt({ hash }) + const receipt = await this.publicClient.waitForTransactionReceipt({ hash, confirmations: 2 }) return receipt } From 49780ebd23d70e83592fc615b91f477a9226eba0 Mon Sep 17 00:00:00 2001 From: Kadir Ay Date: Mon, 18 May 2026 15:18:02 +0300 Subject: [PATCH 6/7] feat: XDC Network support added for savings widget --- packages/savings-sdk/src/index.ts | 7 +- packages/savings-sdk/src/viem-sdk.ts | 141 ++++--- packages/savings-widget/README.md | 19 +- .../src/GooddollarSavingsWidget.ts | 383 ++++++++++++++++-- 4 files changed, 458 insertions(+), 92 deletions(-) diff --git a/packages/savings-sdk/src/index.ts b/packages/savings-sdk/src/index.ts index 7e46c5b..34bb361 100644 --- a/packages/savings-sdk/src/index.ts +++ b/packages/savings-sdk/src/index.ts @@ -1,3 +1,8 @@ export * from "./viem-sdk"; export { useGooddollarSavings } from "./wagmi-sdk"; -export type { GlobalStats, UserStats } from "./viem-sdk"; +export type { GlobalStats, UserStats, ChainConfig } from "./viem-sdk"; +export { + SUPPORTED_CHAIN_IDS, + isSupportedChain, + getChainConfig, +} from "./viem-sdk"; diff --git a/packages/savings-sdk/src/viem-sdk.ts b/packages/savings-sdk/src/viem-sdk.ts index 073c551..2d5c1c0 100644 --- a/packages/savings-sdk/src/viem-sdk.ts +++ b/packages/savings-sdk/src/viem-sdk.ts @@ -24,22 +24,40 @@ const G$__ABI = parseAbi([ "function allowance(address owner, address spender) view returns (uint256)", ]) -const STAKING_CONTRACT_ADDRESS = - "0x799a23dA264A157Db6F9c02BE62F82CE8d602A45" as const -const GDOLLAR_CONTRACT_ADDRESS = - "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A" as const +export interface ChainConfig { + chainId: number + name: string + stakingAddress: `0x${string}` + gdollarAddress: `0x${string}` +} + const CELO_MAINNET_CHAIN_ID = 42220 +const XDC_MAINNET_CHAIN_ID = 50 + +const CHAIN_CONFIGS: Record = { + [CELO_MAINNET_CHAIN_ID]: { + chainId: CELO_MAINNET_CHAIN_ID, + name: "Celo", + stakingAddress: "0x799a23dA264A157Db6F9c02BE62F82CE8d602A45", + gdollarAddress: "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A", + }, + [XDC_MAINNET_CHAIN_ID]: { + chainId: XDC_MAINNET_CHAIN_ID, + name: "XDC", + stakingAddress: "0x61a1Da2a81FbaE6b1B3A45D94355A6A5c5973A52", + gdollarAddress: "0xEC2136843a983885AebF2feB3931F73A8eBEe50c", + }, +} +export const SUPPORTED_CHAIN_IDS: number[] = [CELO_MAINNET_CHAIN_ID, XDC_MAINNET_CHAIN_ID] -const stakingContract = { - address: STAKING_CONTRACT_ADDRESS, - abi: STAKING_CONTRACT_ABI, -} as const +export function isSupportedChain(chainId: number | undefined): boolean { + return typeof chainId === "number" && chainId in CHAIN_CONFIGS +} -const gdollarContract = { - address: GDOLLAR_CONTRACT_ADDRESS, - abi: G$__ABI, -} as const +export function getChainConfig(chainId: number): ChainConfig | undefined { + return CHAIN_CONFIGS[chainId] +} export interface GlobalStats { totalStaked: bigint // in GDollars wei @@ -56,27 +74,56 @@ export interface UserStats { export class GooddollarSavingsSDK { private publicClient: PublicClient private walletClient: WalletClient | null = null + private chainConfig: ChainConfig + private stakingContract: { + address: `0x${string}` + abi: typeof STAKING_CONTRACT_ABI + } + private gdollarContract: { + address: `0x${string}` + abi: typeof G$__ABI + } private totalStaked: bigint = BigInt(0) private cachedRewardRate: bigint = BigInt(0) - constructor( - publicClient: PublicClient, - walletClient?: WalletClient, - ) { + constructor(publicClient: PublicClient, walletClient?: WalletClient) { if (!publicClient) throw new Error("Public client is required") - if (!(publicClient.chain?.id === CELO_MAINNET_CHAIN_ID)) { - throw new Error("Public client must be connected to Celo mainnet") + const chainId = publicClient.chain?.id + const config = chainId !== undefined ? CHAIN_CONFIGS[chainId] : undefined + if (!config) { + throw new Error( + `Unsupported chain id ${chainId}. Supported chains: ${SUPPORTED_CHAIN_IDS.join(", ")}`, + ) } this.publicClient = publicClient + this.chainConfig = config + this.stakingContract = { + address: config.stakingAddress, + abi: STAKING_CONTRACT_ABI, + } + this.gdollarContract = { + address: config.gdollarAddress, + abi: G$__ABI, + } this.walletClient = null if (walletClient) { this.setWalletClient(walletClient) } } + get chainId(): number { + return this.chainConfig.chainId + } + + get chainName(): string { + return this.chainConfig.name + } + setWalletClient(walletClient: WalletClient) { - if (!(walletClient.chain?.id === CELO_MAINNET_CHAIN_ID)) { - throw new Error("Wallet client must be connected to Celo mainnet") + if (walletClient.chain?.id !== this.chainConfig.chainId) { + throw new Error( + `Wallet client must be connected to ${this.chainConfig.name} (chain id ${this.chainConfig.chainId})`, + ) } this.walletClient = walletClient } @@ -84,15 +131,15 @@ export class GooddollarSavingsSDK { async getGlobalStats(): Promise { const [totalSupply, periodFinish, effectiveRewardRate] = await Promise.all([ this.publicClient.readContract({ - ...stakingContract, + ...this.stakingContract, functionName: "totalSupply", }), this.publicClient.readContract({ - ...stakingContract, + ...this.stakingContract, functionName: "periodFinish", }), this.publicClient.readContract({ - ...stakingContract, + ...this.stakingContract, functionName: "getEffectiveRewardRate", }), ]) @@ -121,17 +168,17 @@ export class GooddollarSavingsSDK { const [balance, staked, earned] = await Promise.all([ this.publicClient.readContract({ - ...gdollarContract, + ...this.gdollarContract, functionName: "balanceOf", args: [account], }), this.publicClient.readContract({ - ...stakingContract, + ...this.stakingContract, functionName: "balanceOf", args: [account], }), this.publicClient.readContract({ - ...stakingContract, + ...this.stakingContract, functionName: "earned", args: [account], }), @@ -166,7 +213,7 @@ export class GooddollarSavingsSDK { const account = await this.getAccount() const balance = await this.publicClient.readContract({ - ...gdollarContract, + ...this.gdollarContract, functionName: "balanceOf", args: [account], }) @@ -179,7 +226,7 @@ export class GooddollarSavingsSDK { return this.submitAndWait( { - ...stakingContract, + ...this.stakingContract, functionName: "stake", args: [amount], }, @@ -192,7 +239,7 @@ export class GooddollarSavingsSDK { return this.submitAndWait( { - ...stakingContract, + ...this.stakingContract, functionName: "withdraw", args: [amount], }, @@ -203,7 +250,7 @@ export class GooddollarSavingsSDK { async claimReward(onHash?: (hash: `0x${string}`) => void) { return this.submitAndWait( { - ...stakingContract, + ...this.stakingContract, functionName: "getReward", args: [], }, @@ -216,7 +263,7 @@ export class GooddollarSavingsSDK { onHash?: (hash: `0x${string}`) => void, ) { if (!this.walletClient) throw new Error("Wallet client not initialized") - await this.assertWalletOnCeloMainnet() + await this.assertWalletOnActiveChain() const account = await this.getAccount() @@ -228,14 +275,14 @@ export class GooddollarSavingsSDK { const hash = await this.walletClient.writeContract(request) if (onHash) onHash(hash) - const receipt = await this.publicClient.waitForTransactionReceipt({ hash, confirmations: 2 }) + const receipt = await this.publicClient.waitForTransactionReceipt({ + hash, + confirmations: 2, + }) return receipt } - /** - * Helper method to get the current account address from wallet client - */ private async getAccount(): Promise<`0x${string}`> { if (!this.walletClient) throw new Error("Wallet client not initialized") const [account] = await this.walletClient.getAddresses() @@ -243,27 +290,23 @@ export class GooddollarSavingsSDK { return account } - /** - * Helper method to ensure the staking contract has sufficient allowance to spend G$ tokens - * If allowance is insufficient, it will request approval from the user - */ private async ensureAllowance( amount: bigint, onHash?: (hash: `0x${string}`) => void, ) { const account = await this.getAccount() const allowance = await this.publicClient.readContract({ - ...gdollarContract, + ...this.gdollarContract, functionName: "allowance", - args: [account, STAKING_CONTRACT_ADDRESS], + args: [account, this.chainConfig.stakingAddress], }) if (allowance < amount) { const approvalReceipt = await this.submitAndWait( { - ...gdollarContract, + ...this.gdollarContract, functionName: "approve", - args: [STAKING_CONTRACT_ADDRESS, amount], + args: [this.chainConfig.stakingAddress, amount], }, onHash, ) @@ -273,9 +316,9 @@ export class GooddollarSavingsSDK { } const updatedAllowance = await this.publicClient.readContract({ - ...gdollarContract, + ...this.gdollarContract, functionName: "allowance", - args: [account, STAKING_CONTRACT_ADDRESS], + args: [account, this.chainConfig.stakingAddress], }) if (updatedAllowance < amount) { @@ -286,11 +329,13 @@ export class GooddollarSavingsSDK { } } - private async assertWalletOnCeloMainnet() { + private async assertWalletOnActiveChain() { if (!this.walletClient) return const walletChainId = await this.walletClient.getChainId() - if (walletChainId !== CELO_MAINNET_CHAIN_ID) { - throw new Error("Wrong network. Please switch your wallet to Celo mainnet.") + if (walletChainId !== this.chainConfig.chainId) { + throw new Error( + `Wrong network. Please switch your wallet to ${this.chainConfig.name}.`, + ) } } diff --git a/packages/savings-widget/README.md b/packages/savings-widget/README.md index afb4721..a35b267 100644 --- a/packages/savings-widget/README.md +++ b/packages/savings-widget/README.md @@ -62,4 +62,21 @@ Customize the `gooddollar-savings-widget` using these properties: Defines the function when the "Connect Wallet" button is clicked. - **`web3Provider`**: _(Set via JavaScript property)_ - The web3Provider object when the wallet is connected. Wallet connection logic should be handeled outside of this component. \ No newline at end of file + The web3Provider object when the wallet is connected. Wallet connection logic should be handeled outside of this component. + +- **`supported-chains`**: _(HTML attribute or JS property `supportedChains`)_ + Optional JSON array of supported chain ids the widget should accept (e.g. `supported-chains="[42220, 50]"`). Defaults to `[42220, 50]` (Celo and XDC). + +- **`default-chain-id`**: _(HTML attribute or JS property `defaultChainId`)_ + Numeric chain id used to display global stats when no wallet is connected or when the wallet is on an unsupported chain. Defaults to the first entry in `supported-chains`. + +### Networks + +The widget currently supports the following networks out of the box: + +| Network | Chain ID | +| --- | --- | +| Celo Mainnet | `42220` | +| XDC Network | `50` | + +When the connected wallet is on one of these networks, the widget automatically targets that chain. If the wallet is on a different network, the widget displays a wrong-network alert with a button to switch to the active chain. \ No newline at end of file diff --git a/packages/savings-widget/src/GooddollarSavingsWidget.ts b/packages/savings-widget/src/GooddollarSavingsWidget.ts index 9ff44a5..8f86fc4 100644 --- a/packages/savings-widget/src/GooddollarSavingsWidget.ts +++ b/packages/savings-widget/src/GooddollarSavingsWidget.ts @@ -1,11 +1,31 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { createWalletClient, createPublicClient, custom, PublicClient, WalletClient, http, formatEther, parseEther } from 'viem' -import { celo } from 'viem/chains'; -import { GooddollarSavingsSDK } from '@goodsdks/savings-sdk'; - -const CELO_CHAIN_ID = 42220; -const CELO_CHAIN_ID_HEX = '0xa4ec'; +import { + createWalletClient, + createPublicClient, + custom, + type Chain, + type PublicClient, + type WalletClient, + http, + formatEther, + parseEther, +} from 'viem' +import { celo, xdc } from 'viem/chains'; +import { + GooddollarSavingsSDK, + SUPPORTED_CHAIN_IDS, + getChainConfig, + isSupportedChain, +} from '@goodsdks/savings-sdk'; + +const CHAINS_BY_ID: Record = { + [celo.id]: celo, + [xdc.id]: xdc, +}; + +const DEFAULT_SUPPORTED_CHAIN_IDS = SUPPORTED_CHAIN_IDS.slice(); +const DEFAULT_CHAIN_ID = DEFAULT_SUPPORTED_CHAIN_IDS[0] ?? celo.id; @customElement('gooddollar-savings-widget') export class GooddollarSavingsWidget extends LitElement { @@ -33,6 +53,14 @@ export class GooddollarSavingsWidget extends LitElement { margin-bottom: 24px; } + .header-text { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .logo { width: 48px; height: 48px; @@ -56,6 +84,65 @@ export class GooddollarSavingsWidget extends LitElement { margin: 0; } + .chain-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #0369a1; + background: #e0f2fe; + border: 1px solid #bae6fd; + line-height: 1; + white-space: nowrap; + margin-left: auto; + } + + .chain-pill::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: #0ea5e9; + } + + .network-alert { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + background: #fef3c7; + border: 1px solid #f59e0b; + color: #92400e; + border-radius: 12px; + padding: 12px 14px; + margin-bottom: 16px; + font-size: 14px; + } + + .network-alert-text { + flex: 1; + line-height: 1.4; + } + + .network-alert-action { + background: #f59e0b; + color: #ffffff; + border: none; + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + } + + .network-alert-action:hover { + background: #d97706; + } + .tab-container { display: flex; background: #f9fafb; @@ -261,6 +348,12 @@ export class GooddollarSavingsWidget extends LitElement { @property({ type: Function }) connectWallet: (() => void) | undefined = undefined; + @property({ type: Array, attribute: 'supported-chains' }) + supportedChains: number[] = DEFAULT_SUPPORTED_CHAIN_IDS; + + @property({ type: Number, attribute: 'default-chain-id' }) + defaultChainId: number = DEFAULT_CHAIN_ID; + @state() activeTab: string = 'stake'; @@ -303,13 +396,23 @@ export class GooddollarSavingsWidget extends LitElement { @state() transactionError: string = ''; + @state() + activeChainId: number = DEFAULT_CHAIN_ID; + + @state() + walletChainId: number | null = null; + private walletClient: WalletClient | null = null; - private publicClient: PublicClient | null = null; + private publicClients: Map = new Map(); private sdk: GooddollarSavingsSDK | null = null; private userAddress: string | null = null; + private providerListenersAttachedTo: any = null; + private chainChangedHandler: ((chainIdHex: string) => void) | null = null; + private accountsChangedHandler: ((accounts: string[]) => void) | null = null; connectedCallback(): void { super.connectedCallback(); + this.activeChainId = this.resolveActiveChainId(); this.interval = setInterval( () => this.refreshData(), 30_000 @@ -320,27 +423,65 @@ export class GooddollarSavingsWidget extends LitElement { if (this.interval) { clearInterval(this.interval); } + this.detachProviderListeners(); + super.disconnectedCallback(); } updated(changedProperties: Map) { if (changedProperties.has('web3Provider')) { + this.detachProviderListeners(); + this.attachProviderListeners(); this.refreshData(); } + if ( + changedProperties.has('supportedChains') || + changedProperties.has('defaultChainId') + ) { + const next = this.resolveActiveChainId(); + if (next !== this.activeChainId) { + this.activeChainId = next; + this.sdk = null; + this.refreshData(); + } + } if (changedProperties.has('walletBalance') || changedProperties.has('currentStake')) { this.validateInput(); } } render() { - const isConnected = !!(this.web3Provider && this.web3Provider.isConnected && this.userAddress); + const isWalletPresent = !!(this.web3Provider && this.web3Provider.isConnected); + const isConnected = !!(isWalletPresent && this.userAddress); + const activeChainName = this.getChainName(this.activeChainId); + const showWrongNetworkAlert = + isWalletPresent && + this.walletChainId !== null && + this.walletChainId !== this.activeChainId; + const wrongNetworkMessage = this.buildWrongNetworkMessage(); + return html`
-

Gooddollar Savings

+
+

Gooddollar Savings

+ ${activeChainName} +
+ ${showWrongNetworkAlert + ? html` + + ` + : '' + } +
- ${this.isLoading ? 'Loading...' : this.formatBigInt(this.unclaimedRewards)} -
+ ${this.isStreaming + ? '' + : html` + Unclaimed Rewards +
+ + ${this.isLoading ? 'Loading...' : this.formatBigInt(this.unclaimedRewards)} +
+ ` + }
${this.transactionError ? html`
${this.transactionError}
` : ''} @@ -564,8 +569,12 @@ export class GooddollarSavingsWidget extends LitElement {
- Your Weekly Rewards - ${this.isLoading ? 'Loading...' : this.formatBigInt(this.userWeeklyRewards)} + Your Daily Rewards + ${this.isLoading ? 'Loading...' : this.formatBigInt(this.userDailyRewards)} +
+
+ Your Total Rewards So Far + ${this.isLoading ? 'Loading...' : this.formatBigInt(this.streamedRewards)}
` : "" @@ -597,7 +606,7 @@ export class GooddollarSavingsWidget extends LitElement { private getSupportedChainsSafe(): number[] { const list = (this.supportedChains ?? []).filter((id) => - isSupportedChain(Number(id)), + isSupportedChainId(Number(id)), ); return list.length > 0 ? list.map(Number) : DEFAULT_SUPPORTED_CHAIN_IDS; } @@ -605,12 +614,12 @@ export class GooddollarSavingsWidget extends LitElement { private getPublicClient(chainId: number): PublicClient { const cached = this.publicClients.get(chainId); if (cached) return cached; - const chain = CHAINS_BY_ID[chainId]; - if (!chain) { + const config = getSavingsChainConfig(chainId); + if (!config) { throw new Error(`Unsupported chain id ${chainId}`); } const client = createPublicClient({ - chain, + chain: config.chain, transport: http(), }) as unknown as PublicClient; this.publicClients.set(chainId, client); @@ -618,7 +627,7 @@ export class GooddollarSavingsWidget extends LitElement { } private getChainName(chainId: number): string { - return getChainConfig(chainId)?.name ?? `Chain ${chainId}`; + return getSavingsChainConfig(chainId)?.label ?? `Chain ${chainId}`; } private buildWrongNetworkMessage(): string { @@ -663,11 +672,12 @@ export class GooddollarSavingsWidget extends LitElement { this.resetUserStats(); } - const activeChain = CHAINS_BY_ID[this.activeChainId]; - if (!activeChain) { + const activeConfig = getSavingsChainConfig(this.activeChainId); + if (!activeConfig) { console.error(`No viem chain config for chain id ${this.activeChainId}`); return; } + const activeChain = activeConfig.chain; const publicClient = this.getPublicClient(this.activeChainId); @@ -725,6 +735,7 @@ export class GooddollarSavingsWidget extends LitElement { const globalStats = await this.sdk.getGlobalStats(); this.totalStaked = globalStats.totalStaked; this.annualAPR = globalStats.annualAPR; + this.isStreaming = globalStats.isStreaming; } catch (error) { console.error('Error loading global stats:', error); } @@ -736,8 +747,9 @@ export class GooddollarSavingsWidget extends LitElement { const userStats = await this.sdk.getUserStats() this.walletBalance = userStats.walletBalance; this.currentStake = userStats.currentStake; - this.unclaimedRewards = userStats.unclaimedRewards; - this.userWeeklyRewards = userStats.userWeeklyRewards; + this.unclaimedRewards = userStats.unclaimedRewards ?? 0n; + this.streamedRewards = userStats.streamedRewards ?? 0n; + this.userDailyRewards = userStats.userDailyRewards; } catch (error) { console.error('Error loading user stats:', error); } @@ -746,7 +758,8 @@ export class GooddollarSavingsWidget extends LitElement { this.walletBalance = 0n; this.currentStake = 0n; this.unclaimedRewards = 0n; - this.userWeeklyRewards = 0n; + this.streamedRewards = 0n; + this.userDailyRewards = 0n; } private attachProviderListeners() {