diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a764d66..70f4bdd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,6 +41,7 @@ GoodWidget/ ui/ # Tamagui tokens, preset, themes, config assembly, manifest, primitives embed/ # Web Component wrapper + CSS custom property bridge claim-widget/ # Example widget package using core + ui + embed + goodreserve-widget/ # Reserve swap widget package using core + ui + embed examples/ react-web/ # React web override and theming demo @@ -69,6 +70,7 @@ GoodWidget/ @goodwidget/embed @goodwidget/claim-widget -> depends on core + ui + embed +@goodwidget/goodreserve-widget -> depends on core + ui + embed ``` `@goodwidget/ui` is the leaf design-system package and must not depend on `@goodwidget/core`. diff --git a/README.md b/README.md index 1df7da8..ace9479 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A cross-platform mini app framework for building web3 widgets that run inside wa | `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) | | `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page | | `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component | +| `@goodwidget/goodreserve-widget` | Reserve swap widget package (buy/sell flow on Celo/XDC) | ## Quick Start @@ -170,6 +171,7 @@ GoodWidget/ ui/ → @goodwidget/ui (component library, theme system) embed/ → @goodwidget/embed (Web Component wrapper) claim-widget/ → @goodwidget/claim-widget (sample publishable widget) + goodreserve-widget/ → @goodwidget/goodreserve-widget (reserve swap widget) examples/ react-web/ → React demo with style override showcase html/ → Plain HTML consuming a web component widget diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 25373c9..a3d0d37 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -13,6 +13,7 @@ "@goodwidget/ui": "workspace:*", "@goodwidget/claim-widget-theme-demo": "workspace:*", "@goodwidget/citizen-claim-widget": "workspace:*", + "@goodwidget/goodreserve-widget": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13", diff --git a/examples/storybook/src/fixtures/goodReserveWidgetMock.ts b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts new file mode 100644 index 0000000..3bcdf47 --- /dev/null +++ b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts @@ -0,0 +1,132 @@ +import type { ReserveSwapWidgetAdapterState } from '@goodwidget/goodreserve-widget' + +// Deterministic reserve widget state fixtures used by Storybook and CI tests. +export const reserveWidgetMockStates: Record> = { + noProvider: { + status: 'no_provider', + hasProvider: false, + chainId: null, + address: null, + }, + unsupportedChain: { + status: 'unsupported_chain', + hasProvider: true, + chainId: 8453, + address: '0x1111111111111111111111111111111111111111', + }, + idleBuy: { + status: 'idle_buy', + chainId: 42220, + address: '0x1111111111111111111111111111111111111111', + hasProvider: true, + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + inputAmount: '', + direction: 'buy', + }, + amountEditing: { + status: 'amount_editing', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + }, + quoteLoading: { + status: 'quote_loading', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + }, + quoteReady: { + status: 'quote_ready', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: '~0.01%', + exitContributionPercent: '0%', + }, + }, + quoteError: { + status: 'quote_error', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + error: 'Reserve quote failed. Try again in a moment.', + }, + insufficientBalance: { + status: 'insufficient_balance', + chainId: 42220, + hasProvider: true, + inputAmount: '9999', + tokenInBalance: '120.00', + warning: 'Input exceeds your available token balance.', + }, + slippageSelection: { + status: 'slippage_selection', + chainId: 42220, + hasProvider: true, + slippagePercent: 0.5, + }, + confirmDialog: { + status: 'confirm_dialog', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: '~0.01%', + exitContributionPercent: '0%', + }, + }, + swapPending: { + status: 'swap_pending', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: '~0.01%', + exitContributionPercent: '0%', + }, + }, + swapSuccess: { + status: 'swap_success', + chainId: 42220, + hasProvider: true, + txHash: '0xabc123', + }, + swapError: { + status: 'swap_error', + chainId: 42220, + hasProvider: true, + error: 'Swap reverted due to reserve limits.', + }, + sellQuoteReady: { + status: 'quote_ready', + chainId: 42220, + hasProvider: true, + direction: 'sell', + tokenInSymbol: 'G$', + tokenOutSymbol: 'USDm', + tokenInBalance: '300.00', + tokenOutBalance: '84.00', + inputAmount: '40', + quote: { + outputAmount: '8.9231', + price: '4.4820', + minimumReceived: '8.9142', + priceImpactPercent: '~0.02%', + exitContributionPercent: '0%', + }, + }, +} diff --git a/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx b/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx new file mode 100644 index 0000000..79558c0 --- /dev/null +++ b/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { GoodReserveWidget } from '@goodwidget/goodreserve-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { reserveWidgetMockStates } from '../../fixtures/goodReserveWidgetMock' + +const provider = createCustodialEip1193Provider() + +const meta: Meta = { + title: 'Widgets/GoodReserveWidget', + component: GoodReserveWidget, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +// Renders one deterministic reserve state per story for CI-safe widget coverage. +const renderStory = (mockState: Story['args']['mockState'], dataTestId: string) => ( +
+ +
+) + +export const NoProvider: Story = { + render: () => renderStory(reserveWidgetMockStates.noProvider, 'GoodReserveWidget-no-provider'), +} + +export const UnsupportedChain: Story = { + render: () => + renderStory(reserveWidgetMockStates.unsupportedChain, 'GoodReserveWidget-unsupported-chain'), +} + +export const IdleBuy: Story = { + render: () => renderStory(reserveWidgetMockStates.idleBuy, 'GoodReserveWidget-idle-buy'), +} + +export const AmountEditing: Story = { + render: () => renderStory(reserveWidgetMockStates.amountEditing, 'GoodReserveWidget-amount-editing'), +} + +export const QuoteLoading: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteLoading, 'GoodReserveWidget-quote-loading'), +} + +export const QuoteReadyBuy: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteReady, 'GoodReserveWidget-quote-ready-buy'), +} + +export const QuoteReadySell: Story = { + render: () => + renderStory(reserveWidgetMockStates.sellQuoteReady, 'GoodReserveWidget-quote-ready-sell'), +} + +export const QuoteError: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteError, 'GoodReserveWidget-quote-error'), +} + +export const InsufficientBalance: Story = { + render: () => + renderStory(reserveWidgetMockStates.insufficientBalance, 'GoodReserveWidget-insufficient-balance'), +} + +export const SlippageSelection: Story = { + render: () => + renderStory(reserveWidgetMockStates.slippageSelection, 'GoodReserveWidget-slippage-selection'), +} + +export const ConfirmDialog: Story = { + render: () => renderStory(reserveWidgetMockStates.confirmDialog, 'GoodReserveWidget-confirm-dialog'), +} + +export const SwapPending: Story = { + render: () => renderStory(reserveWidgetMockStates.swapPending, 'GoodReserveWidget-swap-pending'), +} + +export const SwapSuccess: Story = { + render: () => renderStory(reserveWidgetMockStates.swapSuccess, 'GoodReserveWidget-swap-success'), +} + +export const SwapError: Story = { + render: () => renderStory(reserveWidgetMockStates.swapError, 'GoodReserveWidget-swap-error'), +} diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png new file mode 100644 index 0000000..8b70475 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png new file mode 100644 index 0000000..e13699e Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png new file mode 100644 index 0000000..684b557 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png new file mode 100644 index 0000000..73df209 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png new file mode 100644 index 0000000..4dc4fdb Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png new file mode 100644 index 0000000..4edc23a Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png new file mode 100644 index 0000000..8b70475 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png new file mode 100644 index 0000000..44ff0c9 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png new file mode 100644 index 0000000..787629f Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png new file mode 100644 index 0000000..60c7bd4 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png new file mode 100644 index 0000000..89a18db Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png new file mode 100644 index 0000000..c9a66c6 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png new file mode 100644 index 0000000..1faa729 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png new file mode 100644 index 0000000..cd3fd55 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png differ diff --git a/packages/goodreserve-widget/package.json b/packages/goodreserve-widget/package.json new file mode 100644 index 0000000..87ebc21 --- /dev/null +++ b/packages/goodreserve-widget/package.json @@ -0,0 +1,50 @@ +{ + "name": "@goodwidget/goodreserve-widget", + "version": "0.1.0", + "description": "GoodReserve swap widget for GoodWidget", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodwidget/core": "workspace:*", + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/goodreserve-widget/src/GoodReserveWidget.tsx b/packages/goodreserve-widget/src/GoodReserveWidget.tsx new file mode 100644 index 0000000..a0473e8 --- /dev/null +++ b/packages/goodreserve-widget/src/GoodReserveWidget.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { ReserveSwapView } from './ReserveSwapView' +import { useGoodReserveAdapter } from './useGoodReserveAdapter' +import type { ReserveSwapWidgetProps } from './widgetRuntimeContract' + +function GoodReserveWidgetInner({ + onSwapSuccess, + onSwapError, + mockState, +}: Pick) { + const adapter = useGoodReserveAdapter(mockState) + + // Emits swap lifecycle callbacks for host integrations. + useEffect(() => { + if (adapter.state.status === 'swap_success' && adapter.state.txHash) { + onSwapSuccess?.({ + address: adapter.state.address, + chainId: adapter.state.chainId, + transactionHash: adapter.state.txHash, + }) + return + } + + if (adapter.state.status === 'swap_error' && adapter.state.error) { + onSwapError?.({ + address: adapter.state.address, + chainId: adapter.state.chainId, + message: adapter.state.error, + }) + } + }, [adapter.state, onSwapError, onSwapSuccess]) + + return +} + +// Public widget entry wired to GoodWidget runtime context + theming contract. +export function GoodReserveWidget({ + provider, + config, + themeOverrides, + defaultTheme = 'dark', + onSwapSuccess, + onSwapError, + mockState, +}: ReserveSwapWidgetProps) { + return ( + + + + ) +} diff --git a/packages/goodreserve-widget/src/ReserveSwapView.tsx b/packages/goodreserve-widget/src/ReserveSwapView.tsx new file mode 100644 index 0000000..d19ec0e --- /dev/null +++ b/packages/goodreserve-widget/src/ReserveSwapView.tsx @@ -0,0 +1,231 @@ +import React from 'react' +import { + Button, + ButtonText, + Card, + Heading, + Input, + Text, + XStack, + YStack, + createComponent, +} from '@goodwidget/ui' +import type { ReserveSwapWidgetAdapterResult } from './widgetRuntimeContract' +import { CELO_CHAIN_ID } from './constants' + +const SwapShell = createComponent(Card, { + name: 'ReserveSwapShell', + extends: 'Card', + padding: '$4', + gap: '$3', + borderRadius: '$4', +}) + +const AmountCard = createComponent(Card, { + name: 'ReserveAmountCard', + extends: 'Card', + padding: '$3', + gap: '$2', + borderRadius: '$3', +}) + +interface ReserveSwapViewProps { + adapter: ReserveSwapWidgetAdapterResult +} + +// Renders the reserve swap states with GoodWalletV2-like structure for amount cards and CTA. +export function ReserveSwapView({ adapter }: ReserveSwapViewProps) { + const { state, actions } = adapter + + const ctaDisabled = + state.status === 'quote_loading' || + state.status === 'swap_pending' || + state.status === 'insufficient_balance' || + !state.inputAmount || + !state.quote + + const ctaLabel = + state.status === 'swap_pending' + ? 'Swapping...' + : state.status === 'unsupported_chain' + ? 'Switch Network' + : state.status === 'no_provider' + ? 'Connect Wallet' + : 'Review Swap' + + return ( + + + + GoodReserve + + Swap on {state.chainId === CELO_CHAIN_ID ? 'CELO' : 'XDC'} + + Swap on CELO + + Buy or sell GoodDollars using the reserve. Review quote, slippage, and liquidity before + confirming. + + + + + + + + + + + + Swap from + + + + {state.tokenInSymbol} + Balance: {state.tokenInBalance} + + + + + + Swap to + + {state.tokenOutSymbol} + {state.quote?.outputAmount ?? '0.00'} + + + + + + + Slippage tolerance + + {state.slippagePercent}% + + + + Price + + {state.quote?.price ?? '0.00000'} G$ per {state.tokenInSymbol} + + + + Price impact + + {state.quote?.priceImpactPercent ?? '~0.00%'} + + + + Exit contribution + + {state.quote?.exitContributionPercent ?? '0%'} + + + + Minimum received + + {state.quote?.minimumReceived ?? '0.00'} {state.tokenOutSymbol} + + + + {state.warning && ( + + {state.warning} + + )} + + {state.error && ( + + {state.error} + + )} + + {state.status === 'swap_success' && state.txHash && ( + + Swap succeeded. Tx: {state.txHash} + + )} + + {state.status === 'slippage_selection' && ( + + {[0.1, 0.5, 1].map((option) => ( + + ))} + + + )} + + {state.status === 'confirm_dialog' && ( + + + Confirm Swap + + {state.inputAmount} {state.tokenInSymbol} → {state.quote?.outputAmount ?? '0.00'}{' '} + {state.tokenOutSymbol} + + + + + + + + )} + + + + + + + FAQ + What is USDm? A stable token used as reserve collateral on Celo. + + + + ) +} diff --git a/packages/goodreserve-widget/src/constants.ts b/packages/goodreserve-widget/src/constants.ts new file mode 100644 index 0000000..bf56c18 --- /dev/null +++ b/packages/goodreserve-widget/src/constants.ts @@ -0,0 +1,16 @@ +// Supported reserve chains for this widget. +export const CELO_CHAIN_ID = 42220 +export const XDC_CHAIN_ID = 50 + +// Stable token decimals and G$ decimals used by reserve quotes. +export const DEFAULT_STABLE_DECIMALS = 18 +export const DEFAULT_GD_DECIMALS = 2 + +// Debounce used for quote requests while user edits amount. +export const QUOTE_DEBOUNCE_MS = 400 + +// Default slippage persisted in widget-local state. +export const DEFAULT_SLIPPAGE_PERCENT = 0.1 + +// Reserve chain guard list. +export const SUPPORTED_RESERVE_CHAINS = [CELO_CHAIN_ID, XDC_CHAIN_ID] as const diff --git a/packages/goodreserve-widget/src/element.ts b/packages/goodreserve-widget/src/element.ts new file mode 100644 index 0000000..0c00469 --- /dev/null +++ b/packages/goodreserve-widget/src/element.ts @@ -0,0 +1,13 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import type React from 'react' +import { GoodReserveWidget } from './GoodReserveWidget' + +// Custom element wrapper for HTML hosts embedding the reserve widget. +export const GoodReserveWidgetElement = createMiniAppElement( + GoodReserveWidget as React.ComponentType>, + { + shadow: true, + defaultTheme: 'dark', + events: ['swap-success', 'swap-error'], + }, +) diff --git a/packages/goodreserve-widget/src/errors.ts b/packages/goodreserve-widget/src/errors.ts new file mode 100644 index 0000000..79c2325 --- /dev/null +++ b/packages/goodreserve-widget/src/errors.ts @@ -0,0 +1,17 @@ +// Converts low-level reserve/viem errors into concise user-facing messages. +export function mapReserveError(err: unknown, fallback: string): string { + const message = err instanceof Error ? err.message : String(err ?? fallback) + const lower = message.toLowerCase() + + if (lower.includes('user rejected')) return 'Transaction canceled in wallet.' + if (lower.includes('insufficient funds')) return 'Insufficient funds for token amount or gas.' + if (lower.includes('allowance')) return 'Insufficient allowance. Approve and try again.' + if (lower.includes('slippage')) return 'Slippage too high. Increase tolerance or reduce trade size.' + if (lower.includes('revert')) return 'Quote or swap reverted on-chain. Try a smaller amount.' + if (lower.includes('unsupported chain')) return 'Switch to Celo or XDC to continue.' + if (lower.includes('cannot find package') || lower.includes('module not found')) { + return 'GoodReserve SDK package is unavailable in this environment.' + } + + return message || fallback +} diff --git a/packages/goodreserve-widget/src/index.ts b/packages/goodreserve-widget/src/index.ts new file mode 100644 index 0000000..a84a38d --- /dev/null +++ b/packages/goodreserve-widget/src/index.ts @@ -0,0 +1,17 @@ +export { goodReserveWidgetIntegration } from './integration' +export type { GoodReserveWidgetIntegration } from './integration' + +export type { + ReserveSwapDirection, + ReserveSwapQuoteView, + ReserveSwapWidgetStatus, + ReserveSwapWidgetAdapterState, + ReserveSwapWidgetAdapterActions, + ReserveSwapWidgetAdapterResult, + ReserveSwapWidgetProps, + ReserveSwapSuccessDetail, + ReserveSwapErrorDetail, +} from './widgetRuntimeContract' + +export { useGoodReserveAdapter } from './useGoodReserveAdapter' +export { GoodReserveWidget } from './GoodReserveWidget' diff --git a/packages/goodreserve-widget/src/integration.ts b/packages/goodreserve-widget/src/integration.ts new file mode 100644 index 0000000..4e07225 --- /dev/null +++ b/packages/goodreserve-widget/src/integration.ts @@ -0,0 +1,26 @@ +export const goodReserveWidgetIntegration = { + id: 'goodreserve-swap', + sdk: '@goodsdks/good-reserve', + capabilitySource: 'goodReserveSdkCapabilities', + uses: ['getBuyQuote', 'getSellQuote', 'buy', 'sell', 'getReserveStats'], + chains: [42220, 50], + states: [ + 'no_provider', + 'unsupported_chain', + 'sdk_initializing', + 'idle_buy', + 'amount_editing', + 'quote_loading', + 'quote_ready', + 'quote_error', + 'insufficient_balance', + 'slippage_selection', + 'confirm_dialog', + 'swap_pending', + 'swap_success', + 'swap_error', + ], + events: ['swap-success', 'swap-error'], +} as const + +export type GoodReserveWidgetIntegration = typeof goodReserveWidgetIntegration diff --git a/packages/goodreserve-widget/src/register.ts b/packages/goodreserve-widget/src/register.ts new file mode 100644 index 0000000..8cc54fc --- /dev/null +++ b/packages/goodreserve-widget/src/register.ts @@ -0,0 +1,13 @@ +import { GoodReserveWidgetElement } from './element' + +const DEFAULT_TAG_NAME = 'gw-goodreserve-widget' + +// Registers the reserve widget custom element for non-React hosts. +export function register(tagName: string = DEFAULT_TAG_NAME): string { + if (!customElements.get(tagName)) { + customElements.define(tagName, GoodReserveWidgetElement) + } + return tagName +} + +register() diff --git a/packages/goodreserve-widget/src/useGoodReserveAdapter.ts b/packages/goodreserve-widget/src/useGoodReserveAdapter.ts new file mode 100644 index 0000000..3b09e70 --- /dev/null +++ b/packages/goodreserve-widget/src/useGoodReserveAdapter.ts @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import { createPublicClient, createWalletClient, custom, formatUnits, parseUnits } from 'viem' +import type { + ReserveSwapDirection, + ReserveSwapWidgetAdapterResult, + ReserveSwapWidgetAdapterState, +} from './widgetRuntimeContract' +import { + DEFAULT_GD_DECIMALS, + DEFAULT_SLIPPAGE_PERCENT, + DEFAULT_STABLE_DECIMALS, + QUOTE_DEBOUNCE_MS, + SUPPORTED_RESERVE_CHAINS, + XDC_CHAIN_ID, +} from './constants' +import { mapReserveError } from './errors' + +type GoodReserveSDKLike = { + getStableTokenAddress: () => `0x${string}` + getGoodDollarAddress: () => `0x${string}` + getReserveStats: () => Promise<{ + stableTokenDecimals?: number + goodDollarDecimals?: number + exitContribution?: number | null + }> + getBuyQuote: (stableToken: `0x${string}`, amountIn: bigint) => Promise + getSellQuote: (gdAmount: bigint, stableToken: `0x${string}`) => Promise + buy: ( + stableToken: `0x${string}`, + amountIn: bigint, + minReturn: bigint, + ) => Promise<{ receipt: { transactionHash: string } }> + sell: ( + stableToken: `0x${string}`, + amountIn: bigint, + minReturn: bigint, + ) => Promise<{ receipt: { transactionHash: string } }> +} + +type GoodReserveSDKConstructor = new ( + publicClient: unknown, + walletClient: unknown, + env: 'production' | 'development', +) => GoodReserveSDKLike + +type Erc20ReadClient = { + readContract: (params: { + address: `0x${string}` + abi: readonly unknown[] + functionName: 'balanceOf' + args: [`0x${string}`] + }) => Promise +} + +const erc20BalanceOfAbi = [ + { + type: 'function', + stateMutability: 'view', + name: 'balanceOf', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const + +// Loads the SDK module dynamically so workspace builds still run if the SDK package is missing. +async function loadGoodReserveSdkConstructor(): Promise< + GoodReserveSDKConstructor | null +> { + try { + const importer = new Function('moduleName', 'return import(moduleName)') as ( + moduleName: string, + ) => Promise> + const module = await importer('@goodsdks/good-reserve') + const ctor = module.GoodReserveSDK + if (typeof ctor !== 'function') return null + return ctor as GoodReserveSDKConstructor + } catch { + return null + } +} + +const initialState: ReserveSwapWidgetAdapterState = { + status: 'no_provider', + chainId: null, + address: null, + hasProvider: false, + direction: 'buy', + inputAmount: '', + slippagePercent: DEFAULT_SLIPPAGE_PERCENT, + tokenInSymbol: 'USDm', + tokenOutSymbol: 'G$', + tokenInBalance: '0.00', + tokenOutBalance: '0.00', + quote: null, + warning: null, + error: null, + txHash: null, +} + +function getStableSymbol(chainId: number | null): string { + return chainId === XDC_CHAIN_ID ? 'USDC' : 'USDm' +} + +export function useGoodReserveAdapter( + mockState?: Partial, +): ReserveSwapWidgetAdapterResult { + const { address, chainId, isConnected, provider, connect } = useWallet() + + const [state, setState] = useState({ + ...initialState, + ...mockState, + }) + + const sdkRef = useRef(null) + const readClientRef = useRef(null) + const decimalsRef = useRef({ stable: DEFAULT_STABLE_DECIMALS, gd: DEFAULT_GD_DECIMALS }) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const applyStatePatch = useCallback((patch: Partial) => { + if (!mountedRef.current) return + setState((current) => ({ ...current, ...patch })) + }, []) + + const chainSupported = chainId !== null && SUPPORTED_RESERVE_CHAINS.includes(chainId as never) + + const reserveEnvironment = chainId === XDC_CHAIN_ID ? 'development' : 'production' + + const refreshBalances = useCallback(async () => { + if (!address || !sdkRef.current || !readClientRef.current) return + + const stableToken = sdkRef.current.getStableTokenAddress() + const gdToken = sdkRef.current.getGoodDollarAddress() + const [stable, gd] = await Promise.all([ + readClientRef.current.readContract({ + address: stableToken, + abi: erc20BalanceOfAbi, + functionName: 'balanceOf', + args: [address as `0x${string}`], + }), + readClientRef.current.readContract({ + address: gdToken, + abi: erc20BalanceOfAbi, + functionName: 'balanceOf', + args: [address as `0x${string}`], + }), + ]) + + applyStatePatch({ + tokenInBalance: formatUnits(stable, decimalsRef.current.stable), + tokenOutBalance: formatUnits(gd, decimalsRef.current.gd), + }) + }, [address, applyStatePatch]) + + const bootstrapSdk = useCallback(async () => { + if (!provider || !address || !chainId || !chainSupported) return + + applyStatePatch({ status: 'sdk_initializing', hasProvider: true, error: null }) + + const constructor = await loadGoodReserveSdkConstructor() + if (!constructor) { + applyStatePatch({ + status: 'quote_error', + error: + 'GoodReserve SDK is not available in this environment. Install @goodsdks/good-reserve to enable live swaps.', + }) + return + } + + try { + const transport = custom(provider as Parameters[0]) + const publicClient = createPublicClient({ transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + transport, + }) + + const sdk = new constructor(publicClient, walletClient, reserveEnvironment) + const stats = await sdk.getReserveStats() + + sdkRef.current = sdk + readClientRef.current = publicClient as unknown as Erc20ReadClient + decimalsRef.current = { + stable: stats.stableTokenDecimals ?? DEFAULT_STABLE_DECIMALS, + gd: stats.goodDollarDecimals ?? DEFAULT_GD_DECIMALS, + } + + await refreshBalances() + applyStatePatch({ + status: 'idle_buy', + tokenInSymbol: getStableSymbol(chainId), + tokenOutSymbol: 'G$', + warning: null, + error: null, + }) + } catch (err: unknown) { + applyStatePatch({ + status: 'quote_error', + error: mapReserveError(err, 'Failed to initialize GoodReserve SDK.'), + }) + } + }, [address, applyStatePatch, chainId, chainSupported, provider, refreshBalances, reserveEnvironment]) + + useEffect(() => { + if (mockState) return + + if (!provider || !isConnected || !address) { + sdkRef.current = null + readClientRef.current = null + applyStatePatch({ + ...initialState, + status: 'no_provider', + hasProvider: Boolean(provider), + }) + return + } + + applyStatePatch({ address, chainId, hasProvider: true }) + + if (!chainSupported) { + applyStatePatch({ status: 'unsupported_chain', error: null }) + return + } + + void bootstrapSdk() + }, [address, applyStatePatch, bootstrapSdk, chainId, chainSupported, isConnected, mockState, provider]) + + useEffect(() => { + if (mockState || !sdkRef.current) return + if (!state.inputAmount) { + applyStatePatch({ quote: null, warning: null, error: null, status: 'idle_buy' }) + return + } + + const amount = Number(state.inputAmount) + if (!Number.isFinite(amount) || amount <= 0) { + applyStatePatch({ quote: null, status: 'amount_editing' }) + return + } + + const timeoutId = window.setTimeout(async () => { + try { + const inputBalance = Number(state.tokenInBalance) + if (Number.isFinite(inputBalance) && amount > inputBalance) { + applyStatePatch({ + status: 'insufficient_balance', + warning: 'Input exceeds your available token balance.', + quote: null, + error: null, + }) + return + } + + applyStatePatch({ status: 'quote_loading', warning: null, error: null }) + const stableToken = sdkRef.current!.getStableTokenAddress() + const input = parseUnits( + state.inputAmount, + state.direction === 'buy' ? decimalsRef.current.stable : decimalsRef.current.gd, + ) + const output = + state.direction === 'buy' + ? await sdkRef.current!.getBuyQuote(stableToken, input) + : await sdkRef.current!.getSellQuote(input, stableToken) + + const outputFormatted = formatUnits( + output, + state.direction === 'buy' ? decimalsRef.current.gd : decimalsRef.current.stable, + ) + + applyStatePatch({ + status: 'quote_ready', + quote: { + outputAmount: outputFormatted, + price: output === 0n ? '0.00000' : (amount / Number(outputFormatted || '1')).toFixed(5), + minimumReceived: (Number(outputFormatted) * (1 - state.slippagePercent / 100)).toFixed(4), + priceImpactPercent: '~0.01%', + exitContributionPercent: '0%', + }, + error: null, + }) + } catch (err: unknown) { + applyStatePatch({ + status: 'quote_error', + quote: null, + error: mapReserveError(err, 'Failed to fetch reserve quote.'), + }) + } + }, QUOTE_DEBOUNCE_MS) + + return () => window.clearTimeout(timeoutId) + }, [applyStatePatch, mockState, state.direction, state.inputAmount, state.slippagePercent, state.tokenInBalance]) + + const actions = useMemo( + () => ({ + connect: async () => { + await connect() + }, + switchChain: async (targetChainId: number) => { + const walletProvider = provider as + | { request?: (args: { method: string; params?: unknown[] }) => Promise } + | undefined + if (!walletProvider?.request) return + await walletProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + }, + setDirection: (direction: ReserveSwapDirection) => { + const stableSymbol = getStableSymbol(chainId) + applyStatePatch({ + direction, + tokenInSymbol: direction === 'buy' ? stableSymbol : 'G$', + tokenOutSymbol: direction === 'buy' ? 'G$' : stableSymbol, + inputAmount: '', + quote: null, + status: direction === 'buy' ? 'idle_buy' : 'amount_editing', + error: null, + warning: null, + }) + }, + setInputAmount: (value: string) => { + applyStatePatch({ inputAmount: value, status: value ? 'amount_editing' : 'idle_buy' }) + }, + setMaxAmount: () => { + applyStatePatch({ inputAmount: state.tokenInBalance, status: 'amount_editing' }) + }, + setSlippagePercent: (value: number) => { + applyStatePatch({ slippagePercent: value, status: 'idle_buy' }) + }, + openSlippage: () => { + applyStatePatch({ status: 'slippage_selection' }) + }, + closeSlippage: () => { + applyStatePatch({ status: state.quote ? 'quote_ready' : 'idle_buy' }) + }, + openConfirm: () => { + applyStatePatch({ status: 'confirm_dialog' }) + }, + closeConfirm: () => { + applyStatePatch({ status: state.quote ? 'quote_ready' : 'idle_buy' }) + }, + executeSwap: async () => { + if (!sdkRef.current || !state.quote || !state.inputAmount) return + try { + applyStatePatch({ status: 'swap_pending', error: null }) + const stableToken = sdkRef.current.getStableTokenAddress() + const amountIn = parseUnits( + state.inputAmount, + state.direction === 'buy' ? decimalsRef.current.stable : decimalsRef.current.gd, + ) + const quoteOut = parseUnits( + state.quote.outputAmount, + state.direction === 'buy' ? decimalsRef.current.gd : decimalsRef.current.stable, + ) + const slippageBps = BigInt(Math.round(state.slippagePercent * 100)) + const minReturn = (quoteOut * (10_000n - slippageBps)) / 10_000n + + const result = + state.direction === 'buy' + ? await sdkRef.current.buy(stableToken, amountIn, minReturn) + : await sdkRef.current.sell(stableToken, amountIn, minReturn) + + await refreshBalances() + applyStatePatch({ + status: 'swap_success', + txHash: result.receipt.transactionHash, + inputAmount: '', + quote: null, + }) + } catch (err: unknown) { + applyStatePatch({ + status: 'swap_error', + error: mapReserveError(err, 'Swap failed.'), + }) + } + }, + refresh: async () => { + if (mockState) return + await bootstrapSdk() + }, + }), + [ + applyStatePatch, + bootstrapSdk, + connect, + mockState, + provider, + refreshBalances, + state.direction, + state.inputAmount, + state.quote, + state.slippagePercent, + state.tokenInBalance, + chainId, + ], + ) + + return { state: { ...state, ...(mockState ?? {}) }, actions } +} diff --git a/packages/goodreserve-widget/src/widgetRuntimeContract.ts b/packages/goodreserve-widget/src/widgetRuntimeContract.ts new file mode 100644 index 0000000..4abe3f4 --- /dev/null +++ b/packages/goodreserve-widget/src/widgetRuntimeContract.ts @@ -0,0 +1,87 @@ +import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' + +export type ReserveSwapDirection = 'buy' | 'sell' + +export type ReserveSwapWidgetStatus = + | 'no_provider' + | 'unsupported_chain' + | 'sdk_initializing' + | 'idle_buy' + | 'amount_editing' + | 'quote_loading' + | 'quote_ready' + | 'quote_error' + | 'insufficient_balance' + | 'slippage_selection' + | 'confirm_dialog' + | 'swap_pending' + | 'swap_success' + | 'swap_error' + +export interface ReserveSwapQuoteView { + outputAmount: string + price: string + minimumReceived: string + priceImpactPercent: string + exitContributionPercent: string +} + +export interface ReserveSwapWidgetAdapterState { + status: ReserveSwapWidgetStatus + chainId: number | null + address: string | null + hasProvider: boolean + direction: ReserveSwapDirection + inputAmount: string + slippagePercent: number + tokenInSymbol: string + tokenOutSymbol: string + tokenInBalance: string + tokenOutBalance: string + quote: ReserveSwapQuoteView | null + warning: string | null + error: string | null + txHash: string | null +} + +export interface ReserveSwapWidgetAdapterActions { + connect: () => Promise + switchChain: (chainId: number) => Promise + setDirection: (direction: ReserveSwapDirection) => void + setInputAmount: (value: string) => void + setMaxAmount: () => void + setSlippagePercent: (value: number) => void + openSlippage: () => void + closeSlippage: () => void + openConfirm: () => void + closeConfirm: () => void + executeSwap: () => Promise + refresh: () => Promise +} + +export interface ReserveSwapWidgetAdapterResult { + state: ReserveSwapWidgetAdapterState + actions: ReserveSwapWidgetAdapterActions +} + +export interface ReserveSwapSuccessDetail { + address: string | null + chainId: number | null + transactionHash: string +} + +export interface ReserveSwapErrorDetail { + address: string | null + chainId: number | null + message: string +} + +export interface ReserveSwapWidgetProps { + provider?: unknown + config?: GoodWidgetConfig + themeOverrides?: GoodWidgetThemeOverrides + defaultTheme?: 'light' | 'dark' + onSwapSuccess?: (detail: ReserveSwapSuccessDetail) => void + onSwapError?: (detail: ReserveSwapErrorDetail) => void + mockState?: Partial +} diff --git a/packages/goodreserve-widget/tsconfig.build.json b/packages/goodreserve-widget/tsconfig.build.json new file mode 100644 index 0000000..54871c4 --- /dev/null +++ b/packages/goodreserve-widget/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src"] +} diff --git a/packages/goodreserve-widget/tsconfig.json b/packages/goodreserve-widget/tsconfig.json new file mode 100644 index 0000000..66f66cf --- /dev/null +++ b/packages/goodreserve-widget/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "@goodwidget/core": ["../core/src/index.ts"], + "@goodwidget/core/*": ["../core/src/*"], + "@goodwidget/ui": ["../ui/src/index.ts"], + "@goodwidget/embed": ["../embed/src/index.ts"], + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src"] +} diff --git a/packages/goodreserve-widget/tsup.config.ts b/packages/goodreserve-widget/tsup.config.ts new file mode 100644 index 0000000..95c2de0 --- /dev/null +++ b/packages/goodreserve-widget/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + element: 'src/element.ts', + register: 'src/register.ts', + }, + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + tsconfig: 'tsconfig.build.json', + external: ['react', 'react-dom', 'react-native', 'react-native-web'], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04afa8b..faff95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@goodwidget/core': specifier: workspace:* version: link:../../packages/core + '@goodwidget/goodreserve-widget': + specifier: workspace:* + version: link:../../packages/goodreserve-widget '@goodwidget/ui': specifier: workspace:* version: link:../../packages/ui @@ -344,6 +347,40 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/goodreserve-widget: + dependencies: + '@goodwidget/core': + specifier: workspace:* + version: link:../core + '@goodwidget/embed': + specifier: workspace:* + version: link:../embed + '@goodwidget/ui': + specifier: workspace:* + version: link:../ui + viem: + specifier: ^2.0.0 + version: 2.48.4(typescript@5.9.3) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.4.0 + version: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/ui: dependencies: '@tamagui/animations-react-native': diff --git a/tests/widgets/goodreserve-widget/states.spec.ts b/tests/widgets/goodreserve-widget/states.spec.ts new file mode 100644 index 0000000..95b03ef --- /dev/null +++ b/tests/widgets/goodreserve-widget/states.spec.ts @@ -0,0 +1,49 @@ +import { expect, test, type Page } from '@playwright/test' + +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(`/?path=/story/${storyId}`) + await page.waitForSelector('#storybook-preview-iframe', { timeout: 30_000 }) + await page.waitForLoadState('networkidle') +} + +async function frame(page: Page) { + return page.frameLocator('#storybook-preview-iframe') +} + +// Covers deterministic reserve states so CI does not require live reserve RPC calls. +test('GoodReserveWidget no provider state renders connect CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--no-provider') + const storyFrame = await frame(page) + await expect(storyFrame.getByTestId('GoodReserveWidget-no-provider')).toBeVisible() + await expect(storyFrame.getByText('Connect Wallet')).toBeVisible() +}) + +test('GoodReserveWidget unsupported chain state renders switch CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--unsupported-chain') + const storyFrame = await frame(page) + await expect(storyFrame.getByText('Switch Network')).toBeVisible() +}) + +test('GoodReserveWidget quote-ready buy/sell stories render quoted output', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--quote-ready-buy') + let storyFrame = await frame(page) + await expect(storyFrame.getByText('108.2500')).toBeVisible() + + await gotoStory(page, 'widgets-goodreservewidget--quote-ready-sell') + storyFrame = await frame(page) + await expect(storyFrame.getByText('8.9231')).toBeVisible() +}) + +test('GoodReserveWidget transaction states render pending/success/error', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--swap-pending') + let storyFrame = await frame(page) + await expect(storyFrame.getByText('Swapping...')).toBeVisible() + + await gotoStory(page, 'widgets-goodreservewidget--swap-success') + storyFrame = await frame(page) + await expect(storyFrame.getByTestId('GoodReserveWidget-success')).toContainText('Swap succeeded') + + await gotoStory(page, 'widgets-goodreservewidget--swap-error') + storyFrame = await frame(page) + await expect(storyFrame.getByTestId('GoodReserveWidget-error')).toContainText('Swap reverted') +})