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..4e25a5c --- /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 a browser wallet like MetaMask)" + 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 a browser wallet like MetaMask)" + 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/constants.ts b/packages/savings-sdk/src/constants.ts new file mode 100644 index 0000000..22fd062 --- /dev/null +++ b/packages/savings-sdk/src/constants.ts @@ -0,0 +1,100 @@ +import { celo, xdc, type Chain } from "viem/chains" + +/** + * Chains where the GoodDollar Savings (G$ Staking) flow is supported. + * + * The widget and SDK validate that both the public and wallet clients are + * connected to one of these chains. Anything else is treated as a "wrong + * network" error so callers can prompt the user to switch. + */ +export enum SupportedChainId { + CELO = 42220, + XDC = 50, +} + +export const SUPPORTED_CHAIN_IDS: SupportedChainId[] = [ + SupportedChainId.CELO, + SupportedChainId.XDC, +] + +export const isSupportedChainId = ( + chainId: number | undefined, +): chainId is SupportedChainId => + typeof chainId === "number" && + SUPPORTED_CHAIN_IDS.includes(chainId as SupportedChainId) + +export interface SavingsContracts { + /** GoodDollarStaking proxy used to stake G$ and claim rewards. */ + staking: `0x${string}` + /** G$ ERC-20 (or SuperGoodDollar) token contract. */ + gdollar: `0x${string}` + /** + * Superfluid Host. Required on streaming chains; the SDK bundles + * approve / pool connect / stake calls through `host.batchCall`. + */ + superfluidHost?: `0x${string}` + /** + * Superfluid GDA Forwarder. Required on streaming chains to connect + * the user to the GDA pool that distributes streaming rewards. + */ + gdaForwarder?: `0x${string}` +} + +export interface SavingsChainConfig { + id: SupportedChainId + label: string + chain: Chain + isStreaming: boolean // whether this chain uses Superfluid streaming rewards + contracts: SavingsContracts +} + +// Default contract addresses per supported chain. +export const SAVINGS_CHAIN_CONFIG: Record< + SupportedChainId, + SavingsChainConfig +> = { + [SupportedChainId.CELO]: { + id: SupportedChainId.CELO, + label: "Celo", + chain: celo, + isStreaming: true, + contracts: { + staking: "0x059ee811414230d1Fb157878D2b491240F4D8d3B", + gdollar: "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A", + superfluidHost: "0xA4Ff07cF81C02CFD356184879D953970cA957585", + gdaForwarder: "0x308b7405272d11494716e30C6E972DbF6fb89555", + }, + }, + [SupportedChainId.XDC]: { + id: SupportedChainId.XDC, + label: "XDC", + chain: xdc, + isStreaming: false, + contracts: { + staking: "0x61a1Da2a81FbaE6b1B3A45D94355A6A5c5973A52", + gdollar: "0xEC2136843a983885AebF2feB3931F73A8eBEe50c", + }, + }, +} + +export function getSavingsChainConfig(chainId: number): SavingsChainConfig | undefined { + if (!isSupportedChainId(chainId)) { + return undefined + } + return SAVINGS_CHAIN_CONFIG[chainId] +} + +/** + * Error thrown when the SDK is initialised with a client connected to a chain + * that is not in {@link SUPPORTED_CHAIN_IDS}. + */ +export class UnsupportedChainError extends Error { + readonly chainId: number | undefined + constructor(chainId: number | undefined) { + super( + `Unsupported chain ${chainId ?? ""}.`, + ) + this.name = "UnsupportedChainError" + this.chainId = chainId + } +} diff --git a/packages/savings-sdk/src/index.ts b/packages/savings-sdk/src/index.ts index 7e46c5b..319a734 100644 --- a/packages/savings-sdk/src/index.ts +++ b/packages/savings-sdk/src/index.ts @@ -1,3 +1,3 @@ -export * from "./viem-sdk"; -export { useGooddollarSavings } from "./wagmi-sdk"; -export type { GlobalStats, UserStats } from "./viem-sdk"; +export * from "./viem-sdk" +export * from "./constants" +export { useGooddollarSavings } from "./wagmi-sdk" diff --git a/packages/savings-sdk/src/viem-sdk.ts b/packages/savings-sdk/src/viem-sdk.ts index a26cfef..1c04e58 100644 --- a/packages/savings-sdk/src/viem-sdk.ts +++ b/packages/savings-sdk/src/viem-sdk.ts @@ -3,10 +3,23 @@ import { WalletClient, parseAbi, formatEther, + encodeAbiParameters, + encodeFunctionData, type SimulateContractParameters, } from "viem" -const STAKING_CONTRACT_ABI = parseAbi([ +import { + SAVINGS_CHAIN_CONFIG, + SavingsChainConfig, + SavingsContracts, + SupportedChainId, + UnsupportedChainError, + isSupportedChainId, +} from "./constants" + +// Classic staking contract: rewards accrue inside the contract and the user +// claims them with `getReward()` (XDC). +const CLASSIC_STAKING_ABI = parseAbi([ "function balanceOf(address account) view returns (uint256)", "function earned(address account) view returns (uint256)", "function totalSupply() view returns (uint256)", @@ -17,39 +30,62 @@ const STAKING_CONTRACT_ABI = parseAbi([ "function getReward()", ]) +// Streaming staking contract: rewards are streamed via a Superfluid GDA pool +// (Celo). There is no `getReward`; the pool delivers tokens directly to the +// staker's wallet. +const STREAMING_STAKING_ABI = parseAbi([ + "function balanceOf(address account) view returns (uint256)", + "function totalSupply() view returns (uint256)", + "function getEffectiveFlowRate() view returns (int96)", + "function pool() view returns (address)", + "function superToken() view returns (address)", + "function stake(uint256 amount)", + "function withdraw(uint256 amount)", +]) + +const POOL_ABI = parseAbi([ + "function getMemberFlowRate(address account) view returns (int96)", + "function getTotalAmountReceivedByMember(address account) view returns (uint256)", +]) + +const GDA_FORWARDER_ABI = parseAbi([ + "function connectPool(address pool, bytes userData) returns (bool)", + "function isMemberConnected(address pool, address member) view returns (bool)", +]) + +const SUPERFLUID_HOST_ABI = parseAbi([ + "struct Operation { uint32 operationType; address target; bytes data; }", + "function batchCall(Operation[] operations)", +]) + const G$__ABI = parseAbi([ "function balanceOf(address account) view returns (uint256)", - "function transferAndCall(address to, uint256 amount, bytes data) returns (bool)", "function approve(address spender, uint256 amount) returns (bool)", "function allowance(address owner, address spender) view returns (uint256)", ]) -const STAKING_CONTRACT_ADDRESS = - "0x799a23dA264A157Db6F9c02BE62F82CE8d602A45" as const -const GDOLLAR_CONTRACT_ADDRESS = - "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A" as const - - -const stakingContract = { - address: STAKING_CONTRACT_ADDRESS, - abi: STAKING_CONTRACT_ABI, -} as const +// Superfluid host batch operation types. +const OP_TYPE_ERC20_APPROVE = 1 +const OP_TYPE_SUPERFLUID_CALL_AGREEMENT = 201 +const OP_TYPE_ERC2771_FORWARD_CALL = 302 -const gdollarContract = { - address: GDOLLAR_CONTRACT_ADDRESS, - abi: G$__ABI, -} as const +const MAX_UINT256 = (1n << 256n) - 1n +const SECONDS_PER_YEAR = BigInt(365 * 24 * 60 * 60) +const SECONDS_PER_DAY = BigInt(24 * 60 * 60) export interface GlobalStats { totalStaked: bigint // in GDollars wei annualAPR: number // in percentage + isStreaming: boolean // whether this chain uses Superfluid streaming rewards } export interface UserStats { walletBalance: bigint // in GDollars wei currentStake: bigint // in GDollars wei - unclaimedRewards: bigint // in GDollars wei - userWeeklyRewards: bigint // in GDollars wei + userDailyRewards: bigint // in GDollars wei + unclaimedRewards?: bigint // in GDollars wei (only for non-streaming contracts) + flowRate?: bigint // in GDollars wei per second (only for streaming contracts) + streamedRewards?: bigint // in GDollars wei (only for streaming contracts) } export class GooddollarSavingsSDK { @@ -57,15 +93,26 @@ export class GooddollarSavingsSDK { private walletClient: WalletClient | null = null private totalStaked: bigint = BigInt(0) private cachedRewardRate: bigint = BigInt(0) + private cachedPoolAddress: `0x${string}` | null = null + private readonly _chainId: SupportedChainId + private readonly chainConfig: SavingsChainConfig + private readonly contracts: SavingsContracts constructor( publicClient: PublicClient, walletClient?: WalletClient, ) { if (!publicClient) throw new Error("Public client is required") - if (!(publicClient.chain?.id === 42220)) { - throw new Error("Public client must be connected to Celo mainnet") + + const publicChainId = publicClient.chain?.id + if (!isSupportedChainId(publicChainId)) { + throw new UnsupportedChainError(publicChainId) } + + this._chainId = publicChainId + this.chainConfig = SAVINGS_CHAIN_CONFIG[publicChainId] + this.contracts = this.chainConfig.contracts + this.publicClient = publicClient this.walletClient = null if (walletClient) { @@ -73,14 +120,104 @@ export class GooddollarSavingsSDK { } } + get chainId(): SupportedChainId { + return this._chainId + } + + get chainName(): string { + return this.chainConfig.label + } + + /** Resolved contract addresses for the active chain. */ + getContracts(): SavingsContracts { + return this.contracts + } + + /** + * Whether the active chain uses Superfluid streaming rewards. Consumers + * should hide the "claim" UI on streaming chains since rewards are + * delivered continuously to the wallet. + */ + isStreaming(): boolean { + return this.chainConfig.isStreaming + } + setWalletClient(walletClient: WalletClient) { - if (!(walletClient.chain?.id === 42220)) { - throw new Error("Wallet client must be connected to Celo mainnet") + const walletChainId = walletClient.chain?.id + if (!isSupportedChainId(walletChainId)) { + throw new UnsupportedChainError(walletChainId) + } + if (walletChainId !== this._chainId) { + throw new Error( + `Wallet client chain ${walletChainId} does not match public client chain ${this._chainId}.`, + ) } this.walletClient = walletClient } async getGlobalStats(): Promise { + return this.chainConfig.isStreaming + ? this.getStreamingGlobalStats() + : this.getClassicGlobalStats() + } + + async getUserStats(): Promise { + return this.chainConfig.isStreaming + ? this.getStreamingUserStats() + : this.getClassicUserStats() + } + + async stake(amount: bigint, onHash?: (hash: `0x${string}`) => void) { + if (amount <= BigInt(0)) throw new Error("Amount must be greater than zero") + + const account = await this.getAccount() + const balance = await this.publicClient.readContract({ + ...this.gdollarContract(), + functionName: "balanceOf", + args: [account], + }) + + if (balance < amount) { + throw new Error("Insufficient G$ balance for staking") + } + + return this.chainConfig.isStreaming + ? this.stakeStreaming(amount, onHash) + : this.stakeClassic(amount, onHash) + } + + async unstake(amount: bigint, onHash?: (hash: `0x${string}`) => void) { + if (amount <= BigInt(0)) throw new Error("Amount must be greater than zero") + + return this.submitAndWait( + { + ...this.stakingContract(), + functionName: "withdraw", + args: [amount], + }, + onHash, + ) + } + + async claimReward(onHash?: (hash: `0x${string}`) => void) { + if (this.chainConfig.isStreaming) { + throw new Error( + `Chain ${this.chainConfig.label} distributes rewards via Superfluid streaming; there is nothing to claim.`, + ) + } + + return this.submitAndWait( + { + ...this.stakingContract(), + functionName: "getReward", + args: [], + }, + onHash, + ) + } + + private async getClassicGlobalStats(): Promise { + const stakingContract = this.stakingContract() const [totalSupply, periodFinish, effectiveRewardRate] = await Promise.all([ this.publicClient.readContract({ ...stakingContract, @@ -101,22 +238,42 @@ export class GooddollarSavingsSDK { this.totalStaked = totalSupply this.cachedRewardRate = isFinished ? BigInt(0) : effectiveRewardRate - let annualAPR = 0 - if (isFinished == false && totalSupply > BigInt(0)) { - const secondsInYear = BigInt(365 * 24 * 60 * 60) - annualAPR = - (this.toEtherNumber(this.cachedRewardRate * secondsInYear) * 100) / - this.toEtherNumber(totalSupply) + return { + totalStaked: totalSupply, + annualAPR: this.computeAnnualAPR(this.cachedRewardRate, totalSupply), + isStreaming: false, } + } + + private async getStreamingGlobalStats(): Promise { + const stakingContract = this.stakingContract() + const [totalSupply, flowRateRaw] = await Promise.all([ + this.publicClient.readContract({ + ...stakingContract, + functionName: "totalSupply", + }), + this.publicClient.readContract({ + ...stakingContract, + functionName: "getEffectiveFlowRate", + }), + ]) + + // `getEffectiveFlowRate` returns int96; clamp negatives defensively. + const flowRate = flowRateRaw > 0n ? flowRateRaw : 0n + this.totalStaked = totalSupply + this.cachedRewardRate = flowRate return { totalStaked: totalSupply, - annualAPR: annualAPR, + annualAPR: this.computeAnnualAPR(flowRate, totalSupply), + isStreaming: true, } } - async getUserStats(): Promise { + private async getClassicUserStats(): Promise { const account = await this.getAccount() + const stakingContract = this.stakingContract() + const gdollarContract = this.gdollarContract() const [balance, staked, earned] = await Promise.all([ this.publicClient.readContract({ @@ -136,42 +293,76 @@ export class GooddollarSavingsSDK { }), ]) - let userWeeklyRewards = BigInt(0) - if (staked > BigInt(0) && this.totalStaked == BigInt(0)) { + if (staked > BigInt(0) && this.totalStaked === BigInt(0)) { await this.getGlobalStats() - const oneWeekSeconds = BigInt(7 * 24 * 60 * 60) - userWeeklyRewards = - (this.cachedRewardRate * oneWeekSeconds * staked) / this.totalStaked + } + + let userDailyRewards = BigInt(0) + if (staked > BigInt(0) && this.totalStaked > BigInt(0)) { + userDailyRewards = + (this.cachedRewardRate * SECONDS_PER_DAY * staked) / this.totalStaked } return { walletBalance: balance, currentStake: staked, unclaimedRewards: earned, - userWeeklyRewards: userWeeklyRewards, + userDailyRewards, } } - async stake(amount: bigint, onHash?: (hash: `0x${string}`) => void) { - if (amount <= BigInt(0)) throw new Error("Amount must be greater than zero") - + private async getStreamingUserStats(): Promise { const account = await this.getAccount() + const stakingContract = this.stakingContract() + const gdollarContract = this.gdollarContract() + const pool = await this.getPoolAddress() - const balance = await this.publicClient.readContract({ - ...gdollarContract, - functionName: "balanceOf", - args: [account], - }) + const [balance, staked, flowRateRaw, totalReceived] = await Promise.all([ + this.publicClient.readContract({ + ...gdollarContract, + functionName: "balanceOf", + args: [account], + }), + this.publicClient.readContract({ + ...stakingContract, + functionName: "balanceOf", + args: [account], + }), + this.publicClient.readContract({ + address: pool, + abi: POOL_ABI, + functionName: "getMemberFlowRate", + args: [account], + }), + this.publicClient.readContract({ + address: pool, + abi: POOL_ABI, + functionName: "getTotalAmountReceivedByMember", + args: [account], + }), + ]) - if (balance < amount) { - throw new Error("Insufficient G$ balance for staking") + const flowRate = flowRateRaw > 0n ? flowRateRaw : 0n + const userDailyRewards = flowRate * SECONDS_PER_DAY + + return { + walletBalance: balance, + currentStake: staked, + userDailyRewards, + flowRate, + streamedRewards: totalReceived, } + } + private async stakeClassic( + amount: bigint, + onHash?: (hash: `0x${string}`) => void, + ) { await this.ensureAllowance(amount, onHash) return this.submitAndWait( { - ...stakingContract, + ...this.stakingContract(), functionName: "stake", args: [amount], }, @@ -179,25 +370,78 @@ export class GooddollarSavingsSDK { ) } - async unstake(amount: bigint, onHash?: (hash: `0x${string}`) => void) { - if (amount <= BigInt(0)) throw new Error("Amount must be greater than zero") + private async stakeStreaming( + amount: bigint, + onHash?: (hash: `0x${string}`) => void, + ) { + const account = await this.getAccount() + const host = this.contracts.superfluidHost! + const gdaForwarder = this.contracts.gdaForwarder! + const pool = await this.getPoolAddress() - return this.submitAndWait( - { - ...stakingContract, - functionName: "withdraw", + const [allowance, isConnected] = await Promise.all([ + this.publicClient.readContract({ + ...this.gdollarContract(), + functionName: "allowance", + args: [account, this.contracts.staking], + }), + this.publicClient.readContract({ + address: gdaForwarder, + abi: GDA_FORWARDER_ABI, + functionName: "isMemberConnected", + args: [pool, account], + }), + ]) + + const operations: Array<{ + operationType: number + target: `0x${string}` + data: `0x${string}` + }> = [] + + if (allowance < amount) { + operations.push({ + operationType: OP_TYPE_ERC20_APPROVE, + target: this.contracts.gdollar, + data: encodeAbiParameters( + [{ type: "address" }, { type: "uint256" }], + [this.contracts.staking, MAX_UINT256], + ), + }) + } + + if (!isConnected) { + const connectPoolCall = encodeFunctionData({ + abi: GDA_FORWARDER_ABI, + functionName: "connectPool", + args: [pool, "0x"], + }) + operations.push({ + operationType: OP_TYPE_SUPERFLUID_CALL_AGREEMENT, + target: gdaForwarder, + data: encodeAbiParameters( + [{ type: "bytes" }, { type: "bytes" }], + [connectPoolCall, "0x"], + ), + }) + } + + operations.push({ + operationType: OP_TYPE_ERC2771_FORWARD_CALL, + target: this.contracts.staking, + data: encodeFunctionData({ + abi: STREAMING_STAKING_ABI, + functionName: "stake", args: [amount], - }, - onHash, - ) - } + }), + }) - async claimReward(onHash?: (hash: `0x${string}`) => void) { return this.submitAndWait( { - ...stakingContract, - functionName: "getReward", - args: [], + address: host, + abi: SUPERFLUID_HOST_ABI, + functionName: "batchCall", + args: [operations], }, onHash, ) @@ -208,6 +452,7 @@ export class GooddollarSavingsSDK { onHash?: (hash: `0x${string}`) => void, ) { if (!this.walletClient) throw new Error("Wallet client not initialized") + await this.assertWalletOnActiveChain() const account = await this.getAccount() @@ -219,14 +464,14 @@ 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 } - /** - * 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() @@ -234,33 +479,90 @@ 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 gdollarContract = this.gdollarContract() const allowance = await this.publicClient.readContract({ ...gdollarContract, functionName: "allowance", - args: [account, STAKING_CONTRACT_ADDRESS], + args: [account, this.contracts.staking], }) if (allowance < amount) { - await this.submitAndWait( + const approvalReceipt = await this.submitAndWait( { ...gdollarContract, functionName: "approve", - args: [STAKING_CONTRACT_ADDRESS, amount], + args: [this.contracts.staking, amount], }, onHash, ) + + if (approvalReceipt.status !== "success") { + throw new Error("Approval transaction failed") + } + + const updatedAllowance = await this.publicClient.readContract({ + ...gdollarContract, + functionName: "allowance", + args: [account, this.contracts.staking], + }) + + if (updatedAllowance < amount) { + throw new Error( + "Approval is still insufficient. Please wait for confirmation and try staking again.", + ) + } + } + } + + private async assertWalletOnActiveChain() { + if (!this.walletClient) return + const walletChainId = await this.walletClient.getChainId() + if (walletChainId !== this._chainId) { + throw new Error( + `Wrong network. Please switch your wallet to ${this.chainConfig.label}.`, + ) } } + private async getPoolAddress(): Promise<`0x${string}`> { + if (this.cachedPoolAddress) return this.cachedPoolAddress + const pool = await this.publicClient.readContract({ + ...this.stakingContract(), + functionName: "pool", + }) + this.cachedPoolAddress = pool as `0x${string}` + return this.cachedPoolAddress + } + + private stakingContract() { + return { + address: this.contracts.staking, + abi: this.chainConfig.isStreaming + ? STREAMING_STAKING_ABI + : CLASSIC_STAKING_ABI, + } as const + } + + private gdollarContract() { + return { + address: this.contracts.gdollar, + abi: G$__ABI, + } as const + } + + private computeAnnualAPR(ratePerSecond: bigint, totalSupply: bigint): number { + if (ratePerSecond <= 0n || totalSupply <= 0n) return 0 + return ( + (this.toEtherNumber(ratePerSecond * SECONDS_PER_YEAR) * 100) / + this.toEtherNumber(totalSupply) + ) + } + private toEtherNumber(num: bigint) { return Number(formatEther(num)) } diff --git a/packages/savings-sdk/tsconfig.json b/packages/savings-sdk/tsconfig.json index c6daff1..d5a6541 100644 --- a/packages/savings-sdk/tsconfig.json +++ b/packages/savings-sdk/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "target": "ES2020" }, "include": ["src"], "exclude": ["node_modules", "dist"] 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 152de82..9147e82 100644 --- a/packages/savings-widget/src/GooddollarSavingsWidget.ts +++ b/packages/savings-widget/src/GooddollarSavingsWidget.ts @@ -1,8 +1,25 @@ 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'; +import { + createWalletClient, + createPublicClient, + custom, + type PublicClient, + type WalletClient, + http, + formatEther, + parseEther, +} from 'viem' +import { + GooddollarSavingsSDK, + SUPPORTED_CHAIN_IDS, + SupportedChainId, + getSavingsChainConfig, + isSupportedChainId, +} from '@goodsdks/savings-sdk'; + +const DEFAULT_SUPPORTED_CHAIN_IDS = SUPPORTED_CHAIN_IDS.slice(); +const DEFAULT_CHAIN_ID: number = DEFAULT_SUPPORTED_CHAIN_IDS[0] ?? SupportedChainId.CELO; @customElement('gooddollar-savings-widget') export class GooddollarSavingsWidget extends LitElement { @@ -30,6 +47,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; @@ -53,6 +78,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; @@ -258,6 +342,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'; @@ -273,17 +363,23 @@ export class GooddollarSavingsWidget extends LitElement { @state() unclaimedRewards: bigint = BigInt(0); + @state() + streamedRewards: bigint = BigInt(0); + @state() totalStaked: bigint = BigInt(0); @state() - userWeeklyRewards: bigint = BigInt(0); + userDailyRewards: bigint = BigInt(0); @state() annualAPR: number = 0; @state() - interval: NodeJS.Timeout | null = null; + isStreaming: boolean = false; + + @state() + interval: ReturnType | null = null @state() isLoading: boolean = false; @@ -300,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 @@ -317,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}
` : ''} @@ -405,7 +554,7 @@ export class GooddollarSavingsWidget extends LitElement { }
-

Staking Statistics

+

Staking Statistics (${activeChainName})

Total G$ Staked @@ -420,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)}
` : "" @@ -437,32 +590,142 @@ export class GooddollarSavingsWidget extends LitElement { `; } + private resolveActiveChainId(): number { + const supported = this.getSupportedChainsSafe(); + if ( + this.walletChainId !== null && + supported.includes(this.walletChainId) + ) { + return this.walletChainId; + } + if (supported.includes(this.defaultChainId)) { + return this.defaultChainId; + } + return supported[0] ?? DEFAULT_CHAIN_ID; + } + + private getSupportedChainsSafe(): number[] { + const list = (this.supportedChains ?? []).filter((id) => + isSupportedChainId(Number(id)), + ); + return list.length > 0 ? list.map(Number) : DEFAULT_SUPPORTED_CHAIN_IDS; + } + + private getPublicClient(chainId: number): PublicClient { + const cached = this.publicClients.get(chainId); + if (cached) return cached; + const config = getSavingsChainConfig(chainId); + if (!config) { + throw new Error(`Unsupported chain id ${chainId}`); + } + const client = createPublicClient({ + chain: config.chain, + transport: http(), + }) as unknown as PublicClient; + this.publicClients.set(chainId, client); + return client; + } + + private getChainName(chainId: number): string { + return getSavingsChainConfig(chainId)?.label ?? `Chain ${chainId}`; + } + + private buildWrongNetworkMessage(): string { + const supported = this.getSupportedChainsSafe(); + if ( + this.walletChainId !== null && + !supported.includes(this.walletChainId) + ) { + const supportedNames = supported.map((id) => this.getChainName(id)).join(' or '); + return `Your wallet is on an unsupported network. Please switch to ${supportedNames}.`; + } + return `Your wallet network does not match the selected network (${this.getChainName(this.activeChainId)}).`; + } + private async refreshData() { - if (!this.publicClient) { - this.publicClient = createPublicClient({ - chain: celo, - transport: http() - }) as unknown as PublicClient; + const supported = this.getSupportedChainsSafe(); + let walletChainId: number | null = null; + if (this.web3Provider?.request) { + try { + const chainIdHex = await this.web3Provider.request({ method: 'eth_chainId' }); + walletChainId = parseInt(chainIdHex, 16); + } catch (error) { + console.error('Failed to read wallet chain id:', error); + walletChainId = null; + } } + this.walletChainId = walletChainId; + + const previousActive = this.activeChainId; + let nextActive: number; + if (walletChainId !== null && supported.includes(walletChainId)) { + nextActive = walletChainId; + } else if (supported.includes(this.defaultChainId)) { + nextActive = this.defaultChainId; + } else { + nextActive = supported[0] ?? DEFAULT_CHAIN_ID; + } + + if (nextActive !== previousActive) { + this.activeChainId = nextActive; + this.sdk = null; + this.resetUserStats(); + } + + 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); + + const walletOnActiveChain = + !!this.web3Provider && + this.web3Provider.isConnected && + walletChainId === this.activeChainId; - if (this.web3Provider && this.web3Provider.isConnected) { + if (walletOnActiveChain) { this.walletClient = createWalletClient({ - chain: celo, - transport: custom(this.web3Provider) + chain: activeChain, + 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(); + try { + this.sdk = new GooddollarSavingsSDK(publicClient, this.walletClient); + } catch (error) { + console.error('Failed to initialize SDK with wallet:', error); + this.sdk = new GooddollarSavingsSDK(publicClient); } + + try { + const accounts = await this.web3Provider.request({ method: 'eth_accounts' }); + if (accounts.length > 0) { + this.userAddress = accounts[0]; + } else { + this.userAddress = null; + } + } catch (error) { + console.error('Failed to read accounts:', error); + this.userAddress = null; + } + } else { + this.walletClient = null; + this.userAddress = null; + try { + this.sdk = new GooddollarSavingsSDK(publicClient); + } catch (error) { + console.error('Failed to initialize SDK:', error); + this.sdk = null; + return; + } + } + + await this.loadStats(); + if (this.userAddress) { + await this.loadUserStats(); } else { - this.sdk = new GooddollarSavingsSDK(this.publicClient); - await this.loadStats(); + this.resetUserStats(); } } @@ -472,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); } @@ -483,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); } @@ -493,7 +758,46 @@ 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() { + if (!this.web3Provider?.on) return; + this.chainChangedHandler = (chainIdHex: string) => { + try { + this.walletChainId = parseInt(chainIdHex, 16); + } catch { + this.walletChainId = null; + } + this.refreshData().catch(console.error); + }; + this.accountsChangedHandler = (accounts: string[]) => { + this.userAddress = accounts && accounts[0] ? accounts[0] : null; + this.refreshData().catch(console.error); + }; + this.web3Provider.on('chainChanged', this.chainChangedHandler); + this.web3Provider.on('accountsChanged', this.accountsChangedHandler); + this.providerListenersAttachedTo = this.web3Provider; + } + + private detachProviderListeners() { + const target = this.providerListenersAttachedTo; + if (!target?.removeListener) { + this.providerListenersAttachedTo = null; + this.chainChangedHandler = null; + this.accountsChangedHandler = null; + return; + } + if (this.chainChangedHandler) { + target.removeListener('chainChanged', this.chainChangedHandler); + } + if (this.accountsChangedHandler) { + target.removeListener('accountsChanged', this.accountsChangedHandler); + } + this.providerListenersAttachedTo = null; + this.chainChangedHandler = null; + this.accountsChangedHandler = null; } formatBigInt(num: bigint) { @@ -533,7 +837,6 @@ export class GooddollarSavingsWidget extends LitElement { return; } - // Check for invalid characters (only numbers and decimal point allowed) const validInputRegex = /^[0-9]*\.?[0-9]*$/; if (!validInputRegex.test(this.inputAmount)) { this.inputError = 'Invalid value'; @@ -581,6 +884,9 @@ export class GooddollarSavingsWidget extends LitElement { } try { + const onActive = await this.ensureActiveNetwork(); + if (!onActive) return; + this.txLoading = true; this.transactionError = ''; const amount = parseEther(this.inputAmount); @@ -593,7 +899,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 +913,9 @@ export class GooddollarSavingsWidget extends LitElement { } try { + const onActive = await this.ensureActiveNetwork(); + if (!onActive) return; + this.txLoading = true; this.transactionError = ''; const amount = parseEther(this.inputAmount); @@ -619,7 +928,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 +938,9 @@ export class GooddollarSavingsWidget extends LitElement { if (!this.sdk || !this.userAddress) return; try { + const onActive = await this.ensureActiveNetwork(); + if (!onActive) return; + this.isClaiming = true; this.transactionError = ''; const receipt = await this.sdk.claimReward(); @@ -638,9 +950,81 @@ 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 handleSwitchNetwork() { + const switched = await this.ensureActiveNetwork(); + if (switched) { + await this.refreshData(); + } + } + + private async ensureActiveNetwork(): Promise { + if (!this.web3Provider?.request) return true; + + const chainIdHex = await this.web3Provider.request({ method: 'eth_chainId' }); + const currentChainId = parseInt(chainIdHex, 16); + if (currentChainId === this.activeChainId) return true; + + const targetHex = `0x${this.activeChainId.toString(16)}`; + try { + await this.web3Provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: targetHex }], + }); + this.transactionError = ''; + this.walletChainId = this.activeChainId; + return true; + } catch (error: any) { + this.transactionError = this.toUserErrorMessage( + error, + `Please switch your wallet to ${this.getChainName(this.activeChainId)}.`, + ); + return false; + } + } + + private toUserErrorMessage(error: unknown, fallback: string = 'Transaction failed') { + if (!error) return fallback; + + 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()) + .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') || + lower.includes('unsupported chain') + ) { + return `Please switch to ${this.getChainName(this.activeChainId)} 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"