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/.storybook/test-runner.ts b/examples/storybook/.storybook/test-runner.ts new file mode 100644 index 0000000..a16e989 --- /dev/null +++ b/examples/storybook/.storybook/test-runner.ts @@ -0,0 +1,11 @@ +import type { TestRunnerConfig } from '@storybook/test-runner' + +const config: TestRunnerConfig = { + setup() { + // Increase Jest timeout from the default 15000ms to 60000ms + // to prevent cold-start compilation timeouts in CI/test runner. + jest.setTimeout(60000) + }, +} + +export default config 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/goodReserveSdkFake.ts b/examples/storybook/src/fixtures/goodReserveSdkFake.ts new file mode 100644 index 0000000..e24be1e --- /dev/null +++ b/examples/storybook/src/fixtures/goodReserveSdkFake.ts @@ -0,0 +1,147 @@ +import type { + GoodReserveSDKConstructor, + GoodReserveSDKLike, + ReserveStats, + ReserveTransactionResult, +} from '@goodwidget/goodreserve-widget' +import type { EIP1193Provider } from '@goodwidget/core' + +// --------------------------------------------------------------------------- +// Deterministic, CI-safe test doubles for the GoodReserve flow. +// +// These exercise the REAL adapter (quote → confirm → buy/sell → success) via +// the SDK injection seam, with no published @goodsdks/good-reserve package and +// no live RPC. The fake encodes the verified PR #35 contract: getBuyQuote/ +// getSellQuote arg order, the buy(tokenIn, amountIn, minReturn, onHash) / +// sell(sellTo, gdAmount, minReturn, onHash) signatures, and the +// { hash, receipt } result shape. +// --------------------------------------------------------------------------- + +const STABLE_TOKEN = '0x765DE816845861e75A25fCA122bb6898B8B1282a' as const +const GD_TOKEN = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' as const + +// erc20 balanceOf(account) selector + a fixed balance, so the adapter's +// balance reads resolve deterministically through eth_call. +const BALANCE_OF_SELECTOR = '0x70a08231' +// 120 * 1e18 (stable, 18 decimals) — comfortably above the 25 test input. +const STABLE_BALANCE_WEI = (120n * 10n ** 18n).toString(16).padStart(64, '0') + +const SUBMITTED_HASH = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as const + +/** Fake GoodReserveSDK implementing the PR #35 public surface deterministically. */ +export const FakeGoodReserveSDK: GoodReserveSDKConstructor = class FakeGoodReserveSDK + implements GoodReserveSDKLike +{ + // Matches the real `new GoodReserveSDK(publicClient, walletClient, env)`. + constructor( + _publicClient: unknown, + _walletClient: unknown, + _env: 'production' | 'development', + ) {} + + getStableTokenAddress(): `0x${string}` { + return STABLE_TOKEN + } + + getGoodDollarAddress(): `0x${string}` { + return GD_TOKEN + } + + async getReserveStats(): Promise { + return { + goodDollarTotalSupply: 0n, + stableTokenDecimals: 18, + goodDollarDecimals: 2, + poolReserveBalance: null, + poolTokenSupply: null, + reserveRatio: 500_000, // PPM → 50% + exitContribution: 5_000, // PPM → 0.50% (verifies the C1 scaling fix) + } + } + + // getBuyQuote(tokenIn, amountIn) → G$ out. ~4.33 G$ per stable for the demo. + async getBuyQuote(_tokenIn: `0x${string}`, amountIn: bigint): Promise { + // amountIn is 18-dec stable; output is 2-dec G$. 25e18 → 10825 (108.25 G$). + return (amountIn * 433n) / 10n ** 18n + } + + // getSellQuote(gdAmount, sellTo) → stable out. + async getSellQuote(gdAmount: bigint, _sellTo: `0x${string}`): Promise { + // gdAmount is 2-dec G$; output is 18-dec stable. + return (gdAmount * 10n ** 18n) / 433n + } + + async buy( + _tokenIn: `0x${string}`, + _amountIn: bigint, + _minReturn: bigint, + onHash?: (hash: `0x${string}`) => void, + ): Promise { + onHash?.(SUBMITTED_HASH) + return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } } + } + + async sell( + _sellTo: `0x${string}`, + _gdAmount: bigint, + _minReturn: bigint, + onHash?: (hash: `0x${string}`) => void, + ): Promise { + onHash?.(SUBMITTED_HASH) + return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } } + } +} + +type EventCallback = (...args: unknown[]) => void + +/** + * Minimal deterministic EIP-1193 provider for the live-adapter story. Reports a + * connected account on Celo and answers eth_call balanceOf with a fixed balance. + * No network access — every method resolves locally. + */ +export function createReserveTestProvider( + account = '0x1111111111111111111111111111111111111111', + chainId = 42220, +): EIP1193Provider { + let activeChainId = chainId + const listeners: Record = {} + + return { + on(event: string, fn: EventCallback) { + ;(listeners[event] ??= []).push(fn) + }, + removeListener(event: string, fn: EventCallback) { + listeners[event] = (listeners[event] ?? []).filter((cb) => cb !== fn) + }, + async request({ method, params }: { method: string; params?: unknown[] }): Promise { + switch (method) { + case 'eth_accounts': + case 'eth_requestAccounts': + return [account] + case 'eth_chainId': + return `0x${activeChainId.toString(16)}` + case 'net_version': + return String(activeChainId) + case 'wallet_switchEthereumChain': { + const requested = (params?.[0] as { chainId?: string } | undefined)?.chainId + if (requested) { + activeChainId = Number.parseInt(requested, 16) + for (const cb of listeners['chainChanged'] ?? []) cb(requested) + } + return null + } + case 'eth_call': { + const call = (params?.[0] as { data?: string } | undefined) ?? {} + // balanceOf(account) → fixed balance; anything else → 0. + if (call.data?.startsWith(BALANCE_OF_SELECTOR)) { + return `0x${STABLE_BALANCE_WEI}` + } + return `0x${''.padStart(64, '0')}` + } + default: + return null + } + }, + } as EIP1193Provider +} diff --git a/examples/storybook/src/fixtures/goodReserveWidgetMock.ts b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts new file mode 100644 index 0000000..b16ade1 --- /dev/null +++ b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts @@ -0,0 +1,168 @@ +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', + }, + sdkInitializing: { + status: 'sdk_initializing', + hasProvider: true, + chainId: 42220, + address: '0x1111111111111111111111111111111111111111', + }, + idleBuy: { + status: 'idle', + chainId: 42220, + address: '0x1111111111111111111111111111111111111111', + hasProvider: true, + tokenInSymbol: 'USDm', + tokenOutSymbol: 'G$', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + inputAmount: '', + direction: 'buy', + }, + amountEditing: { + status: 'amount_editing', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + }, + quoteLoading: { + status: 'quote_loading', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + }, + quoteReady: { + status: 'quote_ready', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: 'N/A', + 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: 'N/A', + exitContributionPercent: '0%', + }, + }, + swapPending: { + status: 'swap_pending', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, + swapSuccess: { + status: 'swap_success', + chainId: 42220, + hasProvider: true, + tokenOutSymbol: 'G$', + tokenOutBalance: '12,500', + // Post-swap reality: quote is cleared and the received amount is preserved + // in lastSwapOutput (distinct from the wallet balance). + lastSwapOutput: '10,230', + quote: null, + txHash: '0xabc1230000000000000000000000000000000000000000000000000000000000', + }, + 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: 'N/A', + exitContributionPercent: '0%', + }, + }, + // Buy-ready state on XDC (chain 50) — exercises the dynamic network label and + // the USDC stable-token symbol used on XDC. + xdcQuoteReady: { + status: 'quote_ready', + chainId: 50, + hasProvider: true, + direction: 'buy', + tokenInSymbol: 'USDC', + tokenOutSymbol: 'G$', + tokenInBalance: '500.00', + tokenOutBalance: '0.00', + inputAmount: '50', + quote: { + outputAmount: '216.5000', + price: '0.2310', + minimumReceived: '216.2835', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, +} diff --git a/examples/storybook/src/stories/design-system/Drawer.stories.tsx b/examples/storybook/src/stories/design-system/Drawer.stories.tsx index 660bbf8..719be66 100644 --- a/examples/storybook/src/stories/design-system/Drawer.stories.tsx +++ b/examples/storybook/src/stories/design-system/Drawer.stories.tsx @@ -6,7 +6,7 @@ */ import React, { useState } from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { expect, within, userEvent } from '@storybook/test' +import { expect, within, userEvent, waitFor } from '@storybook/test' import { Drawer, Card, Heading, Text, Button, ButtonText, YStack } from '@goodwidget/ui' const meta: Meta = { @@ -45,7 +45,14 @@ export const Default: Story = { const canvas = within(canvasElement) const trigger = canvas.getByRole('button', { name: /open drawer/i }) await userEvent.click(trigger) - // After clicking, the Close button should appear inside the Drawer - await expect(canvas.getByRole('button', { name: /close/i })).toBeDefined() + // The Sheet renders into a portal at the document root, so query the whole + // document (not just canvasElement) and wait for the open animation. + const screen = within(canvasElement.ownerDocument.body) + await waitFor( + async () => { + await expect(screen.getByRole('button', { name: /close/i })).toBeVisible() + }, + { timeout: 5000 }, + ) }, } 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..54dd4c7 --- /dev/null +++ b/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { + GoodReserveWidget, + __setGoodReserveSdkConstructorForTesting, +} from '@goodwidget/goodreserve-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { reserveWidgetMockStates } from '../../fixtures/goodReserveWidgetMock' +import { + FakeGoodReserveSDK, + createReserveTestProvider, +} from '../../fixtures/goodReserveSdkFake' + +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 SdkInitializing: Story = { + render: () => + renderStory(reserveWidgetMockStates.sdkInitializing, 'GoodReserveWidget-sdk-initializing'), +} + +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 QuoteReadyXdc: Story = { + render: () => + renderStory(reserveWidgetMockStates.xdcQuoteReady, 'GoodReserveWidget-quote-ready-xdc'), +} + +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'), +} + +// Live adapter (no mockState) so the real amount-input wiring is exercised. +// Used by the Playwright "types into the input" coverage; the SDK is not +// available in CI so this lands in a quote/error state, but the controlled +// input must still accept typed characters. +export const Interactive: Story = { + render: () => ( +
+ +
+ ), +} + +// --------------------------------------------------------------------------- +// LiveFakeSdk — drives the FULL real adapter against a deterministic fake SDK +// (injected via the test seam) and a local EIP-1193 provider. No mockState, no +// published SDK, no live RPC. Playwright uses this to verify the real +// quote → confirm → buy → success transition (including the submitted tx hash +// from the onHash callback and the PPM exit-contribution scaling). +// --------------------------------------------------------------------------- +const liveProvider = createReserveTestProvider() + +// Sets the injected fake synchronously (before the child widget's effects run, +// so bootstrapSdk picks it up) and clears it on unmount, so the fake can never +// leak into other stories rendered later in the same Storybook session. +function LiveFakeSdkHarness() { + const injected = React.useRef(false) + if (!injected.current) { + __setGoodReserveSdkConstructorForTesting(FakeGoodReserveSDK) + injected.current = true + } + React.useEffect(() => { + return () => __setGoodReserveSdkConstructorForTesting(null) + }, []) + return ( +
+ +
+ ) +} + +export const LiveFakeSdk: Story = { + render: () => , +} 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..01772ec 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..b51c915 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..2b88e14 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..4b9721d 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..403bf08 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..1e29545 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..8c77e20 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..2ff687a 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..3bace56 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/quote-ready-xdc.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-xdc.png new file mode 100644 index 0000000..fec6857 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-xdc.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.png new file mode 100644 index 0000000..16c8876 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.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..9f89e15 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..3088efe 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..d07d0e1 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..205f3cb 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..0c78b0e Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png differ diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index c03c78e..76de6de 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -172,7 +172,7 @@ function Countdown({ nextClaim }: { nextClaim: Date }) { useEffect(() => { const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000) return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps + // getTimeLeft reads `nextClaim` only; intentionally excluded from deps. }, [nextClaim]) const h = Math.floor(timeLeft / 3600) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 4a0b7c3..19d6276 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -420,7 +420,7 @@ export function useCitizenClaimAdapter( // Auto-refresh claim status whenever wallet connection or chain changes useEffect(() => { void loadClaimStatus() - // eslint-disable-next-line react-hooks/exhaustive-deps + // Re-run only on wallet identity changes; loadClaimStatus is stable per render. }, [isConnected, address, chainId]) // --------------------------------------------------------------------------- diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index e1958bc..bf42e2d 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -24,7 +24,7 @@ export interface WalletContextValue extends WalletState { connect: () => Promise } -export interface HostContextValue extends HostState {} +export type HostContextValue = HostState export interface GoodWidgetContextValue extends GoodWidgetState { connect: () => Promise diff --git a/packages/goodreserve-widget/package.json b/packages/goodreserve-widget/package.json new file mode 100644 index 0000000..15c4bd1 --- /dev/null +++ b/packages/goodreserve-widget/package.json @@ -0,0 +1,53 @@ +{ + "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" + }, + "optionalDependencies": { + "@goodsdks/good-reserve": "*" + }, + "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..5932cdd --- /dev/null +++ b/packages/goodreserve-widget/src/GoodReserveWidget.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useRef } 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, + preferredChainId, +}: Pick< + ReserveSwapWidgetProps, + 'onSwapSuccess' | 'onSwapError' | 'mockState' | 'preferredChainId' +>) { + const adapter = useGoodReserveAdapter(mockState) + const { status, txHash, error, address, chainId } = adapter.state + + // Hold the host callbacks in refs so inline arrow functions (a new reference + // each parent render) do not re-run the lifecycle effect and re-fire the + // callbacks on an unchanged swap_success / swap_error state. + const onSwapSuccessRef = useRef(onSwapSuccess) + const onSwapErrorRef = useRef(onSwapError) + useEffect(() => { + onSwapSuccessRef.current = onSwapSuccess + }, [onSwapSuccess]) + useEffect(() => { + onSwapErrorRef.current = onSwapError + }, [onSwapError]) + + // Emits swap lifecycle callbacks for host integrations, keyed only on the + // discrete lifecycle fields so it fires once per real status transition. + useEffect(() => { + if (status === 'swap_success' && txHash) { + onSwapSuccessRef.current?.({ address, chainId, transactionHash: txHash }) + return + } + + if (status === 'swap_error' && error) { + onSwapErrorRef.current?.({ address, chainId, message: error }) + } + }, [status, txHash, error, address, chainId]) + + return +} + +// Public widget entry wired to GoodWidget runtime context + theming contract. +export function GoodReserveWidget({ + provider, + config, + themeOverrides, + defaultTheme = 'dark', + onSwapSuccess, + onSwapError, + mockState, + preferredChainId, +}: ReserveSwapWidgetProps) { + return ( + + + + ) +} diff --git a/packages/goodreserve-widget/src/ReserveSwapView.tsx b/packages/goodreserve-widget/src/ReserveSwapView.tsx new file mode 100644 index 0000000..f052248 --- /dev/null +++ b/packages/goodreserve-widget/src/ReserveSwapView.tsx @@ -0,0 +1,689 @@ +import React, { useState } from 'react' +import { + Anchor, + Button, + ButtonText, + Card, + Drawer, + Heading, + Icon, + Input, + Separator, + Spinner, + Text, + XStack, + YStack, + createComponent, +} from '@goodwidget/ui' +import type { ReserveSwapWidgetAdapterResult } from './widgetRuntimeContract' +import { CELO_CHAIN_ID, SUPPORTED_RESERVE_CHAINS, XDC_CHAIN_ID } from './constants' +import { sanitizeAmount } from './amount' + +// --------------------------------------------------------------------------- +// Named styled components participate in the component sub-theme system: each +// resolves its surface ($background) and its primary foreground ($color) from a +// registered dark_Reserve* theme (defined in the preset), so a host override of +// a sub-theme's surface/primary-text moves them together. +// +// Secondary text shades (muted labels, the blue heading, the accent link) are +// driven by dedicated $reserve* tokens rather than per-component sub-theme keys, +// so they are overridable at the token layer (not per sub-theme). No raw hex is +// used in the view; all colors are tokens or sub-theme keys. +// +// The widget is dark-only (GoodWalletV2 has no light design); see +// widgetRuntimeContract.ts — defaultTheme is fixed to 'dark'. +// --------------------------------------------------------------------------- + +/** Outer swap card matching the dark reserve panel in the Figma reference. */ +const SwapShell = createComponent(Card, { + name: 'ReserveSwapShell', + extends: 'Card', + backgroundColor: '$background', + color: '$color', + borderColor: '$borderColor', + padding: '$4', + gap: '$3', + borderRadius: '$6', +}) + +/** Swap-from / swap-to amount panels (Figma rounded input cards). */ +const AmountCard = createComponent(Card, { + name: 'ReserveAmountCard', + extends: 'Card', + backgroundColor: '$background', + color: '$color', + borderWidth: 0, + shadowOpacity: 0, + padding: '$4', + gap: '$1', + borderRadius: '$4', +}) + +/** Generic raised surface (success summary card, FAQ card). */ +const ReserveSurface = createComponent(Card, { + name: 'ReserveSurface', + extends: 'Card', + backgroundColor: '$background', + color: '$color', + borderWidth: 0, + borderRadius: '$3', +}) + +/** Inner highlight surface (confirm "minimum received"). */ +const ReserveSurfaceInner = createComponent(YStack, { + name: 'ReserveSurfaceInner', + backgroundColor: '$background', + borderRadius: '$3', +}) + +/** Confirm details table surface. */ +const ReserveDetailsTable = createComponent(YStack, { + name: 'ReserveDetailsTable', + backgroundColor: '$background', + borderRadius: '$2', +}) + +/** Circular token badge that fronts each amount panel. */ +const TokenBadge = createComponent(XStack, { + name: 'ReserveTokenBadge', + width: 40, + height: 40, + borderRadius: '$full', + backgroundColor: '$background', + color: '$color', + alignItems: 'center' as const, + justifyContent: 'center' as const, +}) + +/** Circular swap-direction (flip) button between the amount cards. */ +const SwapDirectionButton = createComponent(XStack, { + name: 'ReserveSwapDirectionButton', + width: 40, + height: 40, + borderRadius: '$full', + backgroundColor: '$background', + color: '$color', + alignItems: 'center' as const, + justifyContent: 'center' as const, + cursor: 'pointer', + alignSelf: 'center' as const, +}) + +/** Circular settings/slippage button at the bottom of the swap card. */ +const SettingsButton = createComponent(XStack, { + name: 'ReserveSettingsButton', + width: 40, + height: 40, + borderRadius: '$full', + backgroundColor: '$background', + color: '$color', + alignItems: 'center' as const, + justifyContent: 'center' as const, + cursor: 'pointer', + alignSelf: 'center' as const, +}) + +/** Glowing circular success badge (Figma success hero icon). */ +const SuccessIcon = createComponent(XStack, { + name: 'ReserveSuccessIcon', + width: 96, + height: 96, + borderRadius: '$full', + backgroundColor: '$background', + color: '$color', + alignItems: 'center' as const, + justifyContent: 'center' as const, + shadowColor: '$shadowColor', + shadowRadius: 24, + shadowOpacity: 1, + shadowOffset: { width: 0, height: 0 }, +}) + +/** Small flat "to" token badge in the confirm-drawer hero (distinct from the + * glowing success hero — its own sub-theme so overrides don't collide). */ +const ConfirmToBadge = createComponent(XStack, { + name: 'ReserveConfirmToBadge', + width: 40, + height: 40, + borderRadius: '$full', + backgroundColor: '$background', + color: '$color', + alignItems: 'center' as const, + justifyContent: 'center' as const, +}) + +const NETWORK_LABELS: Record = { + [CELO_CHAIN_ID]: 'CELO', + [XDC_CHAIN_ID]: 'XDC', +} + +// Resolves the readable network name for the supported reserve chains. +function networkLabel(chainId: number | null): string { + return chainId !== null && NETWORK_LABELS[chainId] ? NETWORK_LABELS[chainId] : 'Unsupported' +} + +// Block-explorer transaction URL for the supported reserve chains. +function explorerTxUrl(chainId: number | null, txHash: string): string { + return chainId === XDC_CHAIN_ID + ? `https://xdcscan.com/tx/${txHash}` + : `https://celoscan.io/tx/${txHash}` +} + +interface ReserveSwapViewProps { + adapter: ReserveSwapWidgetAdapterResult + /** Chain proposed by the unsupported-chain CTA. Defaults to Celo. */ + preferredChainId?: number +} + +// A single right-aligned key/value row inside the transaction details block. +// Figma: label 12/600 #8B91A0, value 16/500 #E2E2EC (price impact uses green). +function DetailRow({ + label, + value, + valueColor, +}: { + label: string + value: string + valueColor?: string +}) { + return ( + + + {label} + + + {value} + + + ) +} + +// Collapsible disclosure with a chevron toggle (Figma: Transaction Details + FAQ +// both expand/collapse). Default-open so detail is visible without interaction. +function CollapsibleSection({ + title, + testID, + defaultOpen = true, + children, +}: { + title: string + testID?: string + defaultOpen?: boolean + children: React.ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + return ( + + setOpen((v) => !v)} + > + + {title} + + + + {open && children} + + ) +} + +// Renders the reserve swap states with the GoodWalletV2 / Figma structure: +// header → from/to amount cards → transaction details → primary CTA → FAQ. +// Confirmation, pending, success and error states overlay the same shell. +export function ReserveSwapView({ adapter, preferredChainId }: ReserveSwapViewProps) { + const { state, actions } = adapter + const network = networkLabel(state.chainId) + + // Clamp the unsupported-chain switch target to a supported reserve chain so a + // bad preferredChainId can't route the user to e.g. Ethereum and bounce back. + const switchTarget = + preferredChainId != null && SUPPORTED_RESERVE_CHAINS.includes(preferredChainId as never) + ? preferredChainId + : CELO_CHAIN_ID + + const hasAmount = Boolean(state.inputAmount) && Number(state.inputAmount) > 0 + const isBlocked = + state.status === 'no_provider' || + state.status === 'unsupported_chain' || + state.status === 'swap_pending' + + const ctaDisabled = + !isBlocked && + (state.status === 'quote_loading' || + state.status === 'insufficient_balance' || + !hasAmount || + !state.quote) + + const ctaLabel = + state.status === 'swap_pending' + ? 'Swapping…' + : state.status === 'no_provider' + ? 'Connect Wallet' + : state.status === 'unsupported_chain' + ? 'Switch Network' + : state.status === 'insufficient_balance' + ? 'Insufficient Balance' + : !hasAmount + ? 'Enter Amount' + : state.status === 'quote_loading' + ? 'Fetching Quote…' + : 'Review Swap' + + // While the SDK/runtime mounts, show a centered loading state rather than a + // half-populated swap card. + if (state.status === 'sdk_initializing') { + return ( + + + + Connecting to the reserve… + + + ) + } + + // Success state renders a dedicated celebration screen (Figma success frame): + // glowing check hero → title → summary card → explorer link → primary CTA. + if (state.status === 'swap_success') { + return ( + + + {/* Figma success order: title → summary card → explorer link → icon. */} + + Swap Successful + + + + + Estimated received + + + {state.lastSwapOutput ?? state.quote?.outputAmount ?? '—'} {state.tokenOutSymbol} + + + + {state.txHash && ( + + + + View on Explorer + + + + + )} + + + + + + + + + ) + } + + const stableSymbol = state.tokenInSymbol === 'G$' ? state.tokenOutSymbol : state.tokenInSymbol + + return ( + + {/* Header sits ABOVE the dark card (Figma): network pill, blue title, copy. */} + + + + {network} + + + + Swap on {network} + + + Buy or sell GoodDollars on {network} using the GoodDollar Reserve. + + + + + {/* Swap from */} + + + + Swap from + + + + Balance: {state.tokenInBalance} + + + MAX + + + + + + + + $ + + + + {state.tokenInSymbol} + + + ) => + actions.setInputAmount(sanitizeAmount(event.target.value)) + } + /> + + + + {/* Single circular swap-direction (flip) button between the cards. */} + actions.setDirection(state.direction === 'buy' ? 'sell' : 'buy')} + > + + + + {/* Swap to */} + + + + Swap to + + + Balance: {state.tokenOutBalance} + + + + + + + $ + + + + {state.tokenOutSymbol} + + + {state.status === 'quote_loading' ? ( + + ) : ( + + {state.quote?.outputAmount ?? '0.00'} + + )} + + + + {/* Transaction details — collapsible (Figma chevron disclosure) */} + + + + + + + + + + {state.warning && ( + + {state.warning} + + )} + + {(state.status === 'swap_error' || state.status === 'quote_error') && state.error && ( + + + {state.error} + + + + )} + + {/* Primary CTA — connect / switch / review / pending */} + + + {/* Settings / slippage icon button at the bottom of the card (Figma). */} + + + + + + {/* FAQ block — collapsible, two items (Figma). */} + + + + + + What is {stableSymbol}? + + + A stablecoin used as reserve collateral on {network}. + + + + + How does the reserve work? + + + The GoodDollar Reserve is an automated market maker that prices G$ against the + reserve token, so you can buy or sell at any time. + + + + + + + {/* Slippage selection as a bottom-sheet Drawer. */} + + + + + Slippage Tolerance + + + + + + + {[0.1, 0.5, 1].map((option) => ( + + ))} + + + + + + {/* Confirmation as an anchored bottom-sheet Drawer (Figma). Uses full + height so the hero + 50px highlight + details table are not clipped. */} + + + + + Confirm Swap + + + + + + + {/* Token hero: from badge → arrow → to badge */} + + + + $ + + + + + + $ + + + + + {/* Minimum received highlight */} + + + Minimum Received + + + {state.quote?.minimumReceived ?? '0.00'} + + + {state.tokenOutSymbol} + + + + {/* Details table */} + + + + + + + + + + + + + + + + ) +} diff --git a/packages/goodreserve-widget/src/amount.ts b/packages/goodreserve-widget/src/amount.ts new file mode 100644 index 0000000..9280ace --- /dev/null +++ b/packages/goodreserve-widget/src/amount.ts @@ -0,0 +1,10 @@ +// Keeps only digits and a single decimal point so the value is always safe to +// pass to viem's parseUnits (which throws on "1.2.3", "1e6", separators, etc.). +// Shared by the view (input onChange) and the adapter (setMaxAmount) so both +// entry points produce parseUnits-safe values. +export function sanitizeAmount(raw: string): string { + const cleaned = raw.replace(/[^0-9.]/g, '') + const firstDot = cleaned.indexOf('.') + if (firstDot === -1) return cleaned + return cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '') +} diff --git a/packages/goodreserve-widget/src/constants.ts b/packages/goodreserve-widget/src/constants.ts new file mode 100644 index 0000000..6f76289 --- /dev/null +++ b/packages/goodreserve-widget/src/constants.ts @@ -0,0 +1,20 @@ +// 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 + +// How long a fetched quote is considered fresh enough to submit on-chain. +// Reserve prices move; a stale quote's minReturn could no longer be safe. +export const QUOTE_TTL_MS = 60_000 + +// 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..1ae6df2 --- /dev/null +++ b/packages/goodreserve-widget/src/errors.ts @@ -0,0 +1,68 @@ +// Converts low-level reserve/viem errors into concise user-facing messages. +// Unmatched errors return a generic fallback (and are logged) so raw viem +// output — which can leak RPC URLs, contract addresses, or revert hex — is +// never surfaced directly in the UI. +// +// Coverage mirrors citizen-claim-widget's humanReadableError (network/timeout/ +// rejection/revert-reason handling) so both widgets behave consistently. +export function mapReserveError(err: unknown, fallback: string): string { + // Always log the raw error for diagnostics, regardless of how it maps. + console.error('[GoodReserveWidget]', err) + + const message = err instanceof Error ? err.message : String(err ?? fallback) + const lower = message.toLowerCase() + + // User rejected / canceled in wallet (EIP-1193 4001 / ethers ACTION_REJECTED). + if ( + lower.includes('user rejected') || + lower.includes('user denied') || + lower.includes('4001') || + lower.includes('action_rejected') + ) { + return 'Transaction canceled in wallet.' + } + + // Network-level failures (fetch/connection issues). + if ( + lower.includes('failed to fetch') || + lower.includes('http request failed') || + lower.includes('fetch failed') || + lower.includes('networkerror') || + lower.includes('net::err_') || + lower.includes('econnrefused') || + lower.includes('econnreset') || + lower.includes('etimedout') + ) { + return 'Unable to reach the network. Check your connection and try again.' + } + + // Timeout. + if (lower.includes('timeout') || lower.includes('timed out')) { + return 'The request timed out. Please try again.' + } + + 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.' + + // Contract revert — try to extract a clean, sanitized reason before falling + // back to a generic message (never surface raw revert hex / addresses). + if (lower.includes('revert')) { + const reasonMatch = message.match(/reason:\s*(.+?)(?:\n|$)/i) + if (reasonMatch) { + const reason = reasonMatch[1] + .replace(/[^\x20-\x7E]/g, '') + .trim() + .slice(0, 80) + if (reason) return `Swap reverted: ${reason}` + } + 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 fallback +} diff --git a/packages/goodreserve-widget/src/index.ts b/packages/goodreserve-widget/src/index.ts new file mode 100644 index 0000000..f26f898 --- /dev/null +++ b/packages/goodreserve-widget/src/index.ts @@ -0,0 +1,28 @@ +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' + +// Test/demo-only SDK injection seam (see sdk.ts). Lets stories/Playwright drive +// the real adapter against a deterministic fake SDK without the published +// package or live RPCs. +export { + __setGoodReserveSdkConstructorForTesting, + type GoodReserveSDKLike, + type GoodReserveSDKConstructor, + type ReserveStats, + type ReserveTransactionResult, +} from './sdk' diff --git a/packages/goodreserve-widget/src/integration.ts b/packages/goodreserve-widget/src/integration.ts new file mode 100644 index 0000000..223ba50 --- /dev/null +++ b/packages/goodreserve-widget/src/integration.ts @@ -0,0 +1,25 @@ +export const goodReserveWidgetIntegration = { + id: 'goodreserve-swap', + sdk: '@goodsdks/good-reserve', + uses: ['getBuyQuote', 'getSellQuote', 'buy', 'sell', 'getReserveStats'], + chains: [42220, 50], + states: [ + 'no_provider', + 'unsupported_chain', + 'sdk_initializing', + 'idle', + '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/sdk.ts b/packages/goodreserve-widget/src/sdk.ts new file mode 100644 index 0000000..8fc2694 --- /dev/null +++ b/packages/goodreserve-widget/src/sdk.ts @@ -0,0 +1,117 @@ +// --------------------------------------------------------------------------- +// Typed seam for @goodsdks/good-reserve. +// +// The package is an optionalDependency: it is not yet published to npm (and so +// is not present in this monorepo's lockfile). A static `import` would break +// `pnpm install`/build until it ships, so we use a typed lazy `import()` guarded +// by a try/catch. Critically, the types below are NOT a loose `any`/`unknown` +// shadow — they mirror the real public surface from GoodSDKs PR #35 +// (packages/good-reserve/src/viem-reserve-sdk.ts), so every call site in the +// adapter is type-checked against the actual SDK contract. +// +// When @goodsdks/good-reserve is published, the dynamic specifier resolves to +// the real module and these declarations can be replaced by a direct +// `import type { GoodReserveSDK, ReserveStats } from '@goodsdks/good-reserve'`. +// +// Publish checklist (once the SDK ships to npm): +// 1. Pin the version in package.json optionalDependencies (e.g. "^0.1.0") +// instead of the current "*" placeholder, matching how citizen-claim-widget +// pins "@goodsdks/citizen-sdk". +// 2. Replace the mirrored types below with `import type` from the package. +// --------------------------------------------------------------------------- + +/** Mirrors `ReserveStats` from PR #35 (`viem-reserve-sdk.ts`). */ +export interface ReserveStats { + goodDollarTotalSupply: bigint + stableTokenDecimals: number + goodDollarDecimals: number + poolReserveBalance: bigint | null + poolTokenSupply: bigint | null + /** Mento reserve ratio in parts-per-million (0..1_000_000). */ + reserveRatio: number | null + /** Mento exit contribution in parts-per-million (0..1_000_000). */ + exitContribution: number | null +} + +/** Mirrors `ReserveTransactionResult` from PR #35. `receipt` is viem's TransactionReceipt. */ +export interface ReserveTransactionResult { + hash: `0x${string}` + receipt: { transactionHash: `0x${string}` } & Record +} + +/** Optional submitted-hash callback, matching the SDK's 4th `onHash` argument. */ +export type OnHash = (hash: `0x${string}`) => void + +/** + * Public instance surface of `GoodReserveSDK` (PR #35), narrowed to the methods + * this widget uses. Argument order matches the SDK exactly: + * - getBuyQuote(tokenIn, amountIn) + * - getSellQuote(gdAmount, sellTo) ← note: amount-first, token-second + * - buy(tokenIn, amountIn, minReturn, onHash?) + * - sell(sellTo, gdAmount, minReturn, onHash?) + */ +export interface GoodReserveSDKLike { + getStableTokenAddress: () => `0x${string}` + getGoodDollarAddress: () => `0x${string}` + getReserveStats: () => Promise + getBuyQuote: (tokenIn: `0x${string}`, amountIn: bigint) => Promise + getSellQuote: (gdAmount: bigint, sellTo: `0x${string}`) => Promise + buy: ( + tokenIn: `0x${string}`, + amountIn: bigint, + minReturn: bigint, + onHash?: OnHash, + ) => Promise + sell: ( + sellTo: `0x${string}`, + gdAmount: bigint, + minReturn: bigint, + onHash?: OnHash, + ) => Promise +} + +/** Mirrors the SDK constructor `new GoodReserveSDK(publicClient, walletClient, env)`. */ +export type GoodReserveSDKConstructor = new ( + publicClient: unknown, + walletClient: unknown, + env: 'production' | 'development', +) => GoodReserveSDKLike + +interface GoodReserveModule { + GoodReserveSDK: GoodReserveSDKConstructor +} + +// The specifier is held in a variable so bundlers treat it as a dynamic, code-split +// import rather than a hard dependency to resolve at build time. This keeps the +// build green while the package is an unpublished optionalDependency. +const SDK_SPECIFIER = '@goodsdks/good-reserve' + +// Test/demo injection seam. When set, takes precedence over the real dynamic +// import so deterministic, CI-safe flows can exercise the full adapter +// (quote → confirm → buy/sell → success) without the published SDK or live +// RPCs. Production code never sets this. +let injectedConstructor: GoodReserveSDKConstructor | null = null + +/** Inject a fake `GoodReserveSDK` constructor (tests/demos only). */ +export function __setGoodReserveSdkConstructorForTesting( + ctor: GoodReserveSDKConstructor | null, +): void { + injectedConstructor = ctor +} + +/** + * Lazily loads the real `GoodReserveSDK` constructor. Returns `null` when the + * package is not installed/published yet (the optionalDependency case), so the + * adapter can surface an actionable message instead of crashing. + */ +export async function loadGoodReserveSdkConstructor(): Promise { + if (injectedConstructor) return injectedConstructor + try { + const mod = (await import(/* @vite-ignore */ SDK_SPECIFIER)) as Partial + const ctor = mod.GoodReserveSDK + return typeof ctor === 'function' ? ctor : null + } catch { + // Not installed/published yet, or failed to load — treated as "unavailable". + return null + } +} diff --git a/packages/goodreserve-widget/src/useGoodReserveAdapter.ts b/packages/goodreserve-widget/src/useGoodReserveAdapter.ts new file mode 100644 index 0000000..1545d64 --- /dev/null +++ b/packages/goodreserve-widget/src/useGoodReserveAdapter.ts @@ -0,0 +1,615 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import { + createPublicClient, + createWalletClient, + custom, + formatUnits, + parseUnits, + type Chain, +} from 'viem' +import type { + ReserveSwapDirection, + ReserveSwapWidgetAdapterResult, + ReserveSwapWidgetAdapterState, +} from './widgetRuntimeContract' +import { + CELO_CHAIN_ID, + DEFAULT_GD_DECIMALS, + DEFAULT_SLIPPAGE_PERCENT, + DEFAULT_STABLE_DECIMALS, + QUOTE_DEBOUNCE_MS, + QUOTE_TTL_MS, + SUPPORTED_RESERVE_CHAINS, + XDC_CHAIN_ID, +} from './constants' +import { mapReserveError } from './errors' +import { sanitizeAmount } from './amount' +import { loadGoodReserveSdkConstructor, type GoodReserveSDKLike } from './sdk' + +// Minimal viem Chain definitions for the supported reserve chains. The +// GoodReserve SDK constructor reads publicClient.chain.id and throws when it is +// missing, so the public client must be chain-aware (mirrors the pattern in +// citizen-claim-widget's adapter). +const RESERVE_CHAINS: Record = { + [CELO_CHAIN_ID]: { + id: CELO_CHAIN_ID, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { default: { http: ['https://forno.celo.org'] } }, + } as Chain, + [XDC_CHAIN_ID]: { + id: XDC_CHAIN_ID, + name: 'XDC Network', + nativeCurrency: { name: 'XDC', symbol: 'XDC', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.ankr.com/xdc'] } }, + } as Chain, +} + +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 + +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, + lastSwapOutput: null, + quoteExpiresAt: null, +} + +function getStableSymbol(chainId: number | null): string { + return chainId === XDC_CHAIN_ID ? 'USDC' : 'USDm' +} + +// Maps the raw stable/G$ balances onto the in/out slots for the active direction. +// Buy spends the stable token for G$; sell spends G$ for the stable token. +function balancesForDirection( + direction: ReserveSwapDirection, + stableBalance: string, + gdBalance: string, +): { tokenInBalance: string; tokenOutBalance: string } { + return direction === 'buy' + ? { tokenInBalance: stableBalance, tokenOutBalance: gdBalance } + : { tokenInBalance: gdBalance, tokenOutBalance: stableBalance } +} + +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 }) + // Raw on-chain balances kept independent of direction so the in/out slots can + // be remapped instantly when the user toggles buy/sell. + const balancesRef = useRef({ stable: '0.00', gd: '0.00' }) + // Latest "from" balance, read inside the quote effect without adding it to the + // effect deps (otherwise a post-swap balance refresh would restart the quote + // debounce even though the amount was just cleared). + const tokenInBalanceRef = useRef(state.tokenInBalance) + // Latest direction, read inside bootstrapSdk without adding state.direction to + // its deps (which would re-initialize the SDK on every buy/sell toggle). + const directionRef = useRef(state.direction) + // Real exit contribution from the reserve stats, surfaced in the quote. + const exitContributionRef = useRef('0%') + // Status to restore when an overlay (slippage sheet / confirm dialog) is + // dismissed, so cancelling does not lie about the underlying quote state + // (e.g. returning to quote_ready when the user was at insufficient_balance). + const previousStatusRef = useRef('idle') + // Latest status, read inside the quote effect's empty-input guard without + // adding state.status to its deps (which would re-arm the debounce on every + // transition). Lets the guard avoid clobbering terminal swap states. + const statusRef = useRef(state.status) + const mountedRef = useRef(true) + + useEffect(() => { + statusRef.current = state.status + }, [state.status]) + + useEffect(() => { + tokenInBalanceRef.current = state.tokenInBalance + }, [state.tokenInBalance]) + + useEffect(() => { + directionRef.current = state.direction + }, [state.direction]) + + 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}`], + }), + ]) + + const stableBalance = formatUnits(stable, decimalsRef.current.stable) + const gdBalance = formatUnits(gd, decimalsRef.current.gd) + balancesRef.current = { stable: stableBalance, gd: gdBalance } + + if (!mountedRef.current) return + setState((current) => ({ + ...current, + ...balancesForDirection(current.direction, stableBalance, gdBalance), + })) + }, [address]) + + // Reads the wallet's CURRENT chain id directly via eth_chainId, rather than + // trusting React-derived `chainId` state which may lag a mid-dialog wallet + // network switch. Returns null when no provider/request is available so the + // caller can fall back to the memoized flag. + const readActiveChainId = useCallback(async (): Promise => { + const walletProvider = provider as + | { request?: (args: { method: string; params?: unknown[] }) => Promise } + | undefined + if (!walletProvider?.request) return null + try { + const hex = (await walletProvider.request({ method: 'eth_chainId' })) as string + const parsed = Number.parseInt(hex, 16) + return Number.isNaN(parsed) ? null : parsed + } catch { + return null + } + }, [provider]) + + const bootstrapSdk = useCallback(async () => { + if (!provider || !address || !chainId || !chainSupported) return + + // Drop any SDK/client bound to a previous chain so a Celo<->XDC switch + // re-initializes against the new chain instead of reusing stale clients. + sdkRef.current = null + readClientRef.current = null + + 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 chain = RESERVE_CHAINS[chainId] + const transport = custom(provider as Parameters[0]) + // Pass the chain so publicClient.chain.id is populated; the SDK + // constructor validates it and throws on a chainless client. + const publicClient = createPublicClient({ chain, transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + 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 = { + // Chain-aware fallback: XDC's stable token (USDC) is 6 decimals, Celo's + // (USDm) is 18. Only used if the SDK stats omit the value. + stable: + stats.stableTokenDecimals ?? (chainId === XDC_CHAIN_ID ? 6 : DEFAULT_STABLE_DECIMALS), + gd: stats.goodDollarDecimals ?? DEFAULT_GD_DECIMALS, + } + // exitContribution comes from the same Mento pool struct as reserveRatio + // and is read unscaled by the SDK (extractPoolStats → toNumber(pool[5])). + // The GoodSDKs demo renders these pool fields as a percent with `/ 10000` + // (apps/demo-reserve-swap ReserveSwap.tsx: reserveRatio / 10000), so we + // follow the same convention as the source of truth: e.g. 5000 → "0.50%". + exitContributionRef.current = + stats.exitContribution != null + ? `${(stats.exitContribution / 10_000).toFixed(2)}%` + : '0%' + + await refreshBalances() + // Read direction via ref so toggling buy/sell does not re-bootstrap the SDK. + const stableSymbol = getStableSymbol(chainId) + const dir = directionRef.current + applyStatePatch({ + status: 'idle', + tokenInSymbol: dir === 'buy' ? stableSymbol : 'G$', + tokenOutSymbol: dir === 'buy' ? 'G$' : stableSymbol, + 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) { + // A successful swap clears inputAmount as part of its success patch, which + // re-triggers this effect. Don't clobber terminal swap states (success/ + // error/pending) back to idle — only reset when we're in a quote/editing + // context. Otherwise the success screen would flash and vanish. + const current = statusRef.current + if ( + current === 'swap_success' || + current === 'swap_error' || + current === 'swap_pending' + ) { + return + } + applyStatePatch({ quote: null, warning: null, error: null, status: 'idle' }) + return + } + + const inDecimals = + state.direction === 'buy' ? decimalsRef.current.stable : decimalsRef.current.gd + const outDecimals = + state.direction === 'buy' ? decimalsRef.current.gd : decimalsRef.current.stable + + // Parse the amount in BigInt base units once; reject anything parseUnits + // can't handle (avoids float precision in the gate and balance comparison). + let input: bigint + try { + input = parseUnits(state.inputAmount, inDecimals) + } catch { + applyStatePatch({ quote: null, status: 'amount_editing' }) + return + } + if (input <= 0n) { + applyStatePatch({ quote: null, status: 'amount_editing' }) + return + } + + const timeoutId = setTimeout(async () => { + try { + // BigInt balance comparison — no float rounding at the last decimal. + let balanceBigInt: bigint + try { + balanceBigInt = parseUnits(tokenInBalanceRef.current, inDecimals) + } catch { + balanceBigInt = 0n + } + if (input > balanceBigInt) { + 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 output = + state.direction === 'buy' + ? await sdkRef.current!.getBuyQuote(stableToken, input) + : await sdkRef.current!.getSellQuote(input, stableToken) + + // Slippage and minimum-received are derived in BigInt so the value shown + // to the user is exactly the minReturn submitted on-chain (no float drift). + const slippageBps = BigInt(Math.round(state.slippagePercent * 100)) + const minReturn = (output * (10_000n - slippageBps)) / 10_000n + + const outputFormatted = formatUnits(output, outDecimals) + const minReceivedFormatted = formatUnits(minReturn, outDecimals) + // Display-only unit price expressed as OUTPUT per INPUT, i.e. the rate + // for "1 tokenIn = tokenOut". On-chain math stays BigInt-pure. + const inputNum = Number(formatUnits(input, inDecimals)) + const outputNum = Number(outputFormatted) + const price = inputNum === 0 ? '0.00000' : (outputNum / inputNum).toFixed(5) + + applyStatePatch({ + status: 'quote_ready', + quote: { + outputAmount: outputFormatted, + price, + minimumReceived: minReceivedFormatted, + minReturnRaw: minReturn.toString(), + // Price impact is not exposed by the SDK quote; show N/A rather than + // a misleading constant. Exit contribution comes from reserve stats. + priceImpactPercent: 'N/A', + exitContributionPercent: exitContributionRef.current, + }, + quoteExpiresAt: Date.now() + QUOTE_TTL_MS, + error: null, + }) + } catch (err: unknown) { + applyStatePatch({ + status: 'quote_error', + quote: null, + error: mapReserveError(err, 'Failed to fetch reserve quote.'), + }) + } + }, QUOTE_DEBOUNCE_MS) + + return () => clearTimeout(timeoutId) + // Note: tokenInBalance is intentionally read via ref (not a dep) so a + // post-swap/direction-toggle balance update does not restart the debounce. + }, [applyStatePatch, mockState, state.direction, state.inputAmount, state.slippagePercent]) + + 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) { + applyStatePatch({ error: 'No wallet available to switch networks.' }) + return + } + try { + await walletProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + } catch (err: unknown) { + // 4902 = chain not added to the wallet; surface a clear message rather + // than letting the rejection bubble unhandled out of the CTA handler. + applyStatePatch({ + error: mapReserveError(err, 'Could not switch network. Add the network in your wallet and retry.'), + }) + } + }, + setDirection: (direction: ReserveSwapDirection) => { + const stableSymbol = getStableSymbol(chainId) + applyStatePatch({ + direction, + tokenInSymbol: direction === 'buy' ? stableSymbol : 'G$', + tokenOutSymbol: direction === 'buy' ? 'G$' : stableSymbol, + // Remap the cached on-chain balances to the new in/out slots so the + // "from" balance and MAX always reflect the spent token. + ...balancesForDirection(direction, balancesRef.current.stable, balancesRef.current.gd), + inputAmount: '', + quote: null, + status: 'idle', + error: null, + warning: null, + // Clear any prior swap result so it cannot leak into the next swap. + txHash: null, + lastSwapOutput: null, + }) + }, + setInputAmount: (value: string) => { + const clean = sanitizeAmount(value) + applyStatePatch({ inputAmount: clean, status: clean ? 'amount_editing' : 'idle' }) + }, + setMaxAmount: () => { + // Balance is formatUnits output; sanitize so it is always parseUnits-safe. + applyStatePatch({ + inputAmount: sanitizeAmount(state.tokenInBalance), + status: 'amount_editing', + }) + }, + setSlippagePercent: (value: number) => { + // Keep the underlying quote/idle context instead of forcing idle. + applyStatePatch({ slippagePercent: value, status: previousStatusRef.current }) + }, + openSlippage: () => { + if (state.status !== 'slippage_selection' && state.status !== 'confirm_dialog') { + previousStatusRef.current = state.status + } + applyStatePatch({ status: 'slippage_selection' }) + }, + closeSlippage: () => { + applyStatePatch({ status: previousStatusRef.current }) + }, + openConfirm: () => { + if (state.status !== 'confirm_dialog' && state.status !== 'slippage_selection') { + previousStatusRef.current = state.status + } + applyStatePatch({ status: 'confirm_dialog' }) + }, + closeConfirm: () => { + applyStatePatch({ status: previousStatusRef.current }) + }, + executeSwap: async () => { + if (!sdkRef.current || !state.quote || !state.inputAmount) return + // Guard against double submission while a swap is already in flight. + if (state.status === 'swap_pending') return + // Reject a stale quote: reserve prices move, so a minReturn derived from + // an old quote may no longer be safe. Force a refresh instead of signing. + if (state.quoteExpiresAt !== null && Date.now() > state.quoteExpiresAt) { + // Keep the entered amount and drop back to editing so the debounced + // quote effect re-fetches a fresh quote automatically (one-tap re-quote). + applyStatePatch({ + status: 'amount_editing', + quote: null, + quoteExpiresAt: null, + warning: 'Quote refreshed — review the new amount before confirming.', + }) + return + } + // Re-validate chain support against the wallet's CURRENT chain, read + // live rather than trusting the memoized chainId: the user may have + // switched networks in their wallet while the confirm dialog was open. + const activeChainId = await readActiveChainId() + if (activeChainId !== null && !SUPPORTED_RESERVE_CHAINS.includes(activeChainId as never)) { + applyStatePatch({ status: 'unsupported_chain', error: null }) + return + } + // Fall back to the memoized flag if the live read failed (no provider.request). + if (activeChainId === null && !chainSupported) { + applyStatePatch({ status: 'unsupported_chain', error: null }) + return + } + try { + // Clear any prior txHash so a stale hash can't leak into this attempt. + applyStatePatch({ status: 'swap_pending', error: null, txHash: null }) + const stableToken = sdkRef.current.getStableTokenAddress() + const amountIn = parseUnits( + state.inputAmount, + state.direction === 'buy' ? decimalsRef.current.stable : decimalsRef.current.gd, + ) + // Reuse the exact minReturn that produced the displayed minimumReceived + // so the on-chain floor matches what the user reviewed. + const minReturn = state.quote.minReturnRaw + ? BigInt(state.quote.minReturnRaw) + : (() => { + const quoteOut = parseUnits( + state.quote!.outputAmount, + state.direction === 'buy' ? decimalsRef.current.gd : decimalsRef.current.stable, + ) + const slippageBps = BigInt(Math.round(state.slippagePercent * 100)) + return (quoteOut * (10_000n - slippageBps)) / 10_000n + })() + + // onHash fires as soon as the tx is submitted (before it is mined), so + // swap_pending can already surface the submitted hash / explorer link + // instead of waiting for the receipt. buy/sell still resolve on receipt. + const onHash = (hash: `0x${string}`) => { + applyStatePatch({ txHash: hash }) + } + + const result = + state.direction === 'buy' + ? await sdkRef.current.buy(stableToken, amountIn, minReturn, onHash) + : await sdkRef.current.sell(stableToken, amountIn, minReturn, onHash) + + // Surface success first; balance refresh is best-effort so an RPC blip + // cannot turn a confirmed swap into a swap_error. Preserve the quoted + // output as lastSwapOutput before clearing the quote, so the success + // screen shows the amount received (not the wallet balance). + applyStatePatch({ + status: 'swap_success', + txHash: result.hash, + lastSwapOutput: state.quote.outputAmount, + inputAmount: '', + quote: null, + }) + refreshBalances().catch((refreshErr) => { + console.error('post-swap balance refresh failed', refreshErr) + }) + } catch (err: unknown) { + applyStatePatch({ + status: 'swap_error', + error: mapReserveError(err, 'Swap failed.'), + }) + } + }, + refresh: async () => { + if (mockState) return + await bootstrapSdk() + }, + }), + [ + applyStatePatch, + bootstrapSdk, + connect, + mockState, + provider, + readActiveChainId, + refreshBalances, + state.direction, + state.inputAmount, + state.quote, + state.quoteExpiresAt, + state.slippagePercent, + state.status, + state.tokenInBalance, + chainId, + chainSupported, + ], + ) + + 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..2d32fdc --- /dev/null +++ b/packages/goodreserve-widget/src/widgetRuntimeContract.ts @@ -0,0 +1,99 @@ +import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' + +export type ReserveSwapDirection = 'buy' | 'sell' + +export type ReserveSwapWidgetStatus = + | 'no_provider' + | 'unsupported_chain' + | 'sdk_initializing' + | 'idle' + | '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 + /** Raw minReturn (base units, BigInt-as-string) submitted on-chain. Matches `minimumReceived`. */ + minReturnRaw?: 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 + /** Output amount of the most recent successful swap (preserved after quote is cleared). */ + lastSwapOutput: string | null + /** Epoch ms after which the current quote is considered stale (null when no quote). */ + quoteExpiresAt: number | 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 + /** + * The GoodReserve widget is dark-only (the GoodWalletV2 design system has no + * light variant for it), so only 'dark' is supported. + */ + defaultTheme?: 'dark' + /** Chain proposed by the unsupported-chain CTA. Defaults to Celo (42220). */ + preferredChainId?: number + 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/packages/ui/src/components/Drawer.tsx b/packages/ui/src/components/Drawer.tsx index 2cd39c7..4f0bc62 100644 --- a/packages/ui/src/components/Drawer.tsx +++ b/packages/ui/src/components/Drawer.tsx @@ -1,22 +1,26 @@ import React from 'react' import type { ReactNode } from 'react' import { Sheet, Stack, useTheme } from 'tamagui' -import { createComponent } from '../createComponent' +import { registerComponent } from '../manifest' -// Sheet owns drawer behavior. We wrap its themed sub-parts so they remain -// targetable through GoodWidget's manifest and host override chain. -const DrawerOverlay = createComponent(Sheet.Overlay as any, { - name: 'DrawerOverlay', +// Sheet owns drawer behavior. Its compound parts (Overlay/Frame/Handle) are +// special `extractable`/`styleable` components that Sheet clones internally and +// attaches refs to. Re-wrapping them with styled()/createComponent breaks that +// ref forwarding (React: "Function components cannot be given refs ... DrawerOverlay") +// and the sheet fails to mount. We therefore use the Sheet parts directly and +// apply GoodWidget theme tokens through inline style props, which still resolve +// against the active theme. The shared style objects below keep the visual +// contract in one place. +const overlayStyle = { backgroundColor: '$backgroundOverlay', zIndex: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any animation: ['medium', { opacity: 'exit' }] as any, enterStyle: { opacity: 0 }, exitStyle: { opacity: 0 }, -}) +} as const -const DrawerFrame = createComponent(Sheet.Frame as any, { - name: 'Drawer', - extends: 'Card', +const frameStyle = { backgroundColor: '$backgroundHover', width: '100%', maxWidth: '$maxContentWidth', @@ -39,10 +43,9 @@ const DrawerFrame = createComponent(Sheet.Frame as any, { position: 'relative', zIndex: 1, animation: 'medium', -}) +} as const -const DrawerHandle = createComponent(Sheet.Handle as any, { - name: 'DrawerHandle', +const handleStyle = { backgroundColor: '$borderColor', width: 48, height: 4, @@ -50,7 +53,12 @@ const DrawerHandle = createComponent(Sheet.Handle as any, { borderRadius: 9999, opacity: 1, marginBottom: '$2', -}) +} as const + +// Keep the drawer surfaces discoverable through the GoodWidget manifest / host +// override chain even though the parts are styled inline rather than via +// createComponent. +registerComponent({ name: 'Drawer', extends: 'Card', themeKeys: ['background', 'borderColor', 'shadowColor'], variants: [] }) interface DrawerProps { open: boolean @@ -68,7 +76,6 @@ export function Drawer({ open, onClose, children, height = 'half' }: DrawerProps open={open} defaultPosition={0} onOpenChange={(openLocal: boolean) => { - console.log('open', openLocal, open) if (!openLocal) { onClose() } @@ -80,13 +87,13 @@ export function Drawer({ open, onClose, children, height = 'half' }: DrawerProps snapPointsMode="percent" zIndex={Number(theme.zIndex?.val ?? 200)} > - - - + + + {children} - + ) } diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx index fd63649..e8c44ba 100644 --- a/packages/ui/src/components/Icon.tsx +++ b/packages/ui/src/components/Icon.tsx @@ -51,6 +51,8 @@ const SVG_PATHS: Record = { 'chevron-right': 'M9 18l6-6-6-6', 'arrow-left': 'M19 12H5M12 19l-7-7 7-7', 'arrow-right': 'M5 12h14M12 5l7 7-7 7', + 'arrow-down': 'M12 5v14M19 12l-7 7-7-7', + 'arrow-up': 'M12 19V5M5 12l7-7 7 7', copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z', wallet: 'M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 14a1 1 0 110-2 1 1 0 010 2zM4 7V5a2 2 0 012-2h12a2 2 0 012 2v2', 'external-link': 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', diff --git a/packages/ui/src/presets.ts b/packages/ui/src/presets.ts index 1284e8c..5dd017f 100644 --- a/packages/ui/src/presets.ts +++ b/packages/ui/src/presets.ts @@ -64,6 +64,20 @@ const tokenPreset = { glowError: 'rgba(240, 5, 5, 0.72)', shimmer: 'rgba(245, 249, 255, 0.22)', + // GoodReserve swap widget palette (Figma file xsk5EiF6CvStA9mtdbA9OR). + // Mirror of defaultTokenValues.color.reserve* in theme.ts (the canonical + // token source); these must stay in sync. Namespaced so they back the + // dark_Reserve* sub-themes without colliding with the shared tokens above. + reserveCard: '#0C0E15', + reserveInputCard: '#252730', + reserveBadge: '#33343C', + reserveSurfaceInner: '#191B22', + reserveHeading: '#4090FF', + reserveText: '#E2E2EC', + reserveTextMuted: '#8B91A0', + reserveTextSecondary: '#C1C6D6', + reserveAccentSoft: '#AAC7FF', + // Governance light-mode tokens. governancePrimary: '#00B0FF', governancePrimaryDark: '#006493', @@ -431,6 +445,101 @@ export const goodWalletV2Preset: WidgetDesignPreset = { borderColor: color.border, shadowColor: 'rgba(3, 7, 18, 0.8)', }, + + // GoodReserve swap widget — public, host-themable component sub-themes. + // The widget is dark-only (GoodWalletV2 has no light design), so the + // light_/dark_ pairs are intentionally identical and map to the Figma + // palette. Integrators override these keys to reskin the widget without + // touching the shared preset tokens. + light_ReserveSwapShell: { + background: color.reserveCard, + color: color.reserveText, + borderColor: color.border, + }, + dark_ReserveSwapShell: { + background: color.reserveCard, + color: color.reserveText, + borderColor: color.borderDark, + }, + light_ReserveAmountCard: { + background: color.reserveInputCard, + color: color.reserveText, + }, + dark_ReserveAmountCard: { + background: color.reserveInputCard, + color: color.reserveText, + }, + // Raised surfaces: success summary card + FAQ card. + light_ReserveSurface: { + background: color.surface, + color: color.reserveText, + }, + dark_ReserveSurface: { + background: color.surface, + color: color.reserveText, + }, + // Inner highlight surface inside the confirm sheet. + light_ReserveSurfaceInner: { + background: color.reserveSurfaceInner, + color: color.reserveText, + }, + dark_ReserveSurfaceInner: { + background: color.reserveSurfaceInner, + color: color.reserveText, + }, + // Confirm-sheet details table surface. + light_ReserveDetailsTable: { + background: color.reserveCard, + color: color.reserveText, + }, + dark_ReserveDetailsTable: { + background: color.reserveCard, + color: color.reserveText, + }, + light_ReserveTokenBadge: { + background: color.reserveBadge, + color: color.reserveText, + }, + dark_ReserveTokenBadge: { + background: color.reserveBadge, + color: color.reserveText, + }, + light_ReserveSwapDirectionButton: { + background: color.reserveBadge, + color: color.reserveHeading, + }, + dark_ReserveSwapDirectionButton: { + background: color.reserveBadge, + color: color.reserveHeading, + }, + light_ReserveSettingsButton: { + background: color.reserveBadge, + color: color.reserveHeading, + }, + dark_ReserveSettingsButton: { + background: color.reserveBadge, + color: color.reserveHeading, + }, + // Glowing success hero (96x96). + light_ReserveSuccessIcon: { + background: color.primary, + color: color.white, + shadowColor: color.glowPrimary, + }, + dark_ReserveSuccessIcon: { + background: color.primary, + color: color.white, + shadowColor: color.glowPrimary, + }, + // Flat "to" badge in the confirm hero (distinct from the glowing hero). + light_ReserveConfirmToBadge: { + background: color.primary, + color: color.white, + }, + dark_ReserveConfirmToBadge: { + background: color.primary, + color: color.white, + }, }, typography: { // Typography is still preset-scoped rather than theme-scoped. diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index f6fa9bf..66bd5bd 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -45,6 +45,19 @@ export const defaultTokenValues = { borderDark: '#333333', overlay: 'rgba(0,0,0,0.5)', transparent: 'transparent', + + // GoodReserve swap widget palette. This is the canonical token source for + // the $reserve* tokens (the preset's color map mirrors these exact values so + // its component sub-themes can reference them; keep the two in sync). + reserveCard: '#0C0E15', + reserveInputCard: '#252730', + reserveBadge: '#33343C', + reserveSurfaceInner: '#191B22', + reserveHeading: '#4090FF', + reserveText: '#E2E2EC', + reserveTextMuted: '#8B91A0', + reserveTextSecondary: '#C1C6D6', + reserveAccentSoft: '#AAC7FF', }, size: { 0: 0, 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/design-system/smoke.spec.ts b/tests/design-system/smoke.spec.ts index bf51c4d..4696f3b 100644 --- a/tests/design-system/smoke.spec.ts +++ b/tests/design-system/smoke.spec.ts @@ -77,7 +77,7 @@ test('TokenAmount/Default story renders', async ({ page }) => { }) test('ClaimWidget/Default story renders in mock-connected state', async ({ page }) => { - await gotoStory(page, 'widgets-claimwidget--default') + await gotoStory(page, 'theme-claimwidgetthemedemo-light--default') const frame = getStoryFrame(page) await expect(frame.getByTestId('ClaimWidget-default')).toBeVisible() await page.screenshot({ @@ -86,26 +86,6 @@ test('ClaimWidget/Default story renders in mock-connected state', async ({ page }) }) -test('ClaimWidget/CobaltBrand story renders', async ({ page }) => { - await gotoStory(page, 'widgets-claimwidget--cobalt-brand') - const frame = getStoryFrame(page) - await expect(frame.getByTestId('ClaimWidget-cobalt')).toBeVisible() - await page.screenshot({ - path: 'tests/design-system/test-results/story-claimwidget-cobalt.png', - fullPage: true, - }) -}) - -test('ClaimWidget/TealBrand story renders', async ({ page }) => { - await gotoStory(page, 'widgets-claimwidget--teal-brand') - const frame = getStoryFrame(page) - await expect(frame.getByTestId('ClaimWidget-teal')).toBeVisible() - await page.screenshot({ - path: 'tests/design-system/test-results/story-claimwidget-teal.png', - fullPage: true, - }) -}) - test('ThemePlayground/DefaultPreset story renders', async ({ page }) => { await gotoStory(page, 'theme-themeplayground--default-preset') const frame = getStoryFrame(page) diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png index 6cef8e4..dcd7c0c 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-01-loading.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png index 84d581c..42b0d9d 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-02-not-whitelisted.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png index 85f6e3d..17afc5b 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-03-error.png differ diff --git a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png index 85f6e3d..17afc5b 100644 Binary files a/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png and b/tests/widgets/citizen-claim-widget/test-results/ccw-04-retry-clicked.png differ diff --git a/tests/widgets/goodreserve-widget/states.spec.ts b/tests/widgets/goodreserve-widget/states.spec.ts new file mode 100644 index 0000000..0664c05 --- /dev/null +++ b/tests/widgets/goodreserve-widget/states.spec.ts @@ -0,0 +1,197 @@ +/** + * states.spec.ts — Playwright coverage for the GoodReserveWidget Storybook states. + * + * The widget uses deterministic mockState fixtures, so these checks are CI-safe and + * never require live reserve RPC behavior. Assertions target rendered text (Tamagui + * does not reliably forward component testIDs to the DOM in the Storybook source + * transform), which mirrors the citizen-claim-widget test approach. + * + * Each test also writes a committed screenshot under test-results/ as UI evidence. + * + * Running: + * pnpm storybook (or let Playwright start it via webServer) + * pnpm test:demo tests/widgets/goodreserve-widget + */ +import { expect, test, type Page } from '@playwright/test' + +const SCREENSHOT_DIR = 'tests/widgets/goodreserve-widget/test-results' + +// Navigate directly to the story iframe (bypasses the Storybook shell for speed +// and avoids first-load flakiness from the manager UI). Retries the initial +// navigation so a cold-starting Storybook dev server (vite compiling its first +// request) does not fail the run with ERR_CONNECTION_REFUSED. +async function gotoStory(page: Page, storyId: string): Promise { + const url = `/iframe.html?id=${storyId}&viewMode=story` + let lastError: unknown + for (let attempt = 0; attempt < 5; attempt++) { + try { + await page.goto(url, { waitUntil: 'load', timeout: 30_000 }) + lastError = undefined + break + } catch (err) { + lastError = err + await page.waitForTimeout(3000) + } + } + if (lastError) throw lastError + await page.waitForLoadState('networkidle') + await page.getByTestId('GoodReserveWidget-root').first().waitFor({ timeout: 30_000 }) +} + +test('no-provider state renders the connect CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--no-provider') + await expect(page.getByText('Connect Wallet')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-01-no-provider.png` }) +}) + +test('unsupported-chain state renders the switch-network CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--unsupported-chain') + await expect(page.getByText('Switch Network')).toBeVisible() + await expect(page.getByText('Unsupported').first()).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-02-unsupported-chain.png` }) +}) + +test('sdk-initializing state shows a connecting loader', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--sdk-initializing') + await expect(page.getByText('Connecting to the reserve…')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-13-sdk-initializing.png` }) +}) + +test('idle-buy state shows the Enter Amount CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--idle-buy') + await expect(page.getByText('Enter Amount')).toBeVisible() + await expect(page.getByText('Swap on CELO').first()).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-03-idle-buy.png` }) +}) + +test('amount-editing state reflects the typed amount', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--amount-editing') + await expect(page.locator('input').first()).toHaveValue('25') + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-14-amount-editing.png` }) +}) + +test('quote-loading state shows the fetching-quote CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--quote-loading') + await expect(page.getByText('Fetching Quote…')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-15-quote-loading.png` }) +}) + +test('quote-ready buy renders the quoted G$ output', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--quote-ready-buy') + await expect(page.getByText('108.2500')).toBeVisible() + await expect(page.getByText('Review Swap')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-04-quote-ready-buy.png` }) +}) + +test('quote-ready sell maps G$ into the from slot', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--quote-ready-sell') + await expect(page.getByText('8.9231')).toBeVisible() + // Sell direction: the "from" balance is the G$ balance (300.00), not the stable balance. + await expect(page.getByText('Balance: 300.00')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-05-quote-ready-sell.png` }) +}) + +test('quote-ready on XDC renders the dynamic network label', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--quote-ready-xdc') + await expect(page.getByText('Swap on XDC').first()).toBeVisible() + await expect(page.getByText('216.5000')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-06-quote-ready-xdc.png` }) +}) + +test('insufficient-balance state warns and disables the CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--insufficient-balance') + await expect(page.getByText(/exceeds your available/i)).toBeVisible() + await expect(page.getByText('Insufficient Balance')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-07-insufficient-balance.png` }) +}) + +test('slippage selection sheet exposes tolerance options', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--slippage-selection') + await expect(page.getByText('0.5%').first()).toBeVisible() + await expect(page.getByText('Done')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-08-slippage-selection.png` }) +}) + +test('confirm dialog renders as a bottom-sheet with a press-to-confirm button', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--confirm-dialog') + await expect(page.getByText('Confirm Swap').first()).toBeVisible() + await expect(page.getByText('Minimum Received', { exact: true })).toBeVisible() + await expect(page.getByText('Max Slippage')).toBeVisible() + // Confirmation is a simple button (slide-to-confirm in Figma is simplified). + await expect(page.getByText('Confirm Swap').last()).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-09-confirm-dialog.png` }) +}) + +test('swap-pending state shows the swapping CTA', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--swap-pending') + await expect(page.getByText('Swapping…')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-10-swap-pending.png` }) +}) + +test('swap-success state shows the received amount, not the wallet balance', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--swap-success') + await expect(page.getByText('Swap Successful')).toBeVisible() + await expect(page.getByText('Estimated received')).toBeVisible() + // The fixture's lastSwapOutput is 10,230 while the wallet balance is 12,500; + // the success card must show the amount received from the swap. + await expect(page.getByText('10,230 G$')).toBeVisible() + await expect(page.getByText('Do another swap')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-11-swap-success.png` }) +}) + +test('swap-error state surfaces the mapped reserve error', async ({ page }) => { + await gotoStory(page, 'widgets-goodreservewidget--swap-error') + await expect(page.getByText(/reverted/i)).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-12-swap-error.png` }) +}) + +// Regression guard for the web amount input: the live adapter (no mockState) +// must accept typed characters. Tamagui's tag:'input' Stack does not forward +// RN onChangeText on web, so the view wires a native onChange instead. +test('amount input accepts typed characters (live adapter)', async ({ page }) => { + await page.goto('/iframe.html?id=widgets-goodreservewidget--interactive&viewMode=story') + await page.waitForLoadState('networkidle') + await page.getByTestId('GoodReserveWidget-interactive').first().waitFor({ timeout: 30_000 }) + + const input = page.locator('input').first() + await input.click() + await input.pressSequentially('25', { delay: 30 }) + await expect(input).toHaveValue('25') + + // Sanitization: invalid characters and extra dots are stripped. + await input.fill('') + await input.pressSequentially('1.2.3x', { delay: 30 }) + await expect(input).toHaveValue('1.23') +}) + +// Full real-adapter flow against the injected fake SDK (LiveFakeSdk story): +// type amount → live quote → review → confirm → buy → success with tx hash. +// This is the regression net for the SDK seam — it exercises getBuyQuote arg +// order, the onHash callback, result.hash, and the PPM exit-contribution +// scaling, none of which the mockState stories touch. +test('live adapter completes a buy: quote → confirm → success with tx hash', async ({ page }) => { + await page.goto('/iframe.html?id=widgets-goodreservewidget--live-fake-sdk&viewMode=story') + await page.waitForLoadState('networkidle') + await page.getByTestId('GoodReserveWidget-live').first().waitFor({ timeout: 30_000 }) + + // Enter an amount and wait for the real debounced quote to resolve. + const input = page.locator('input').first() + await input.click() + await input.pressSequentially('25', { delay: 30 }) + + // Fake getBuyQuote(25e18) → 10825 base units (2-dec G$) = "108.25". + await expect(page.getByText('108.25')).toBeVisible({ timeout: 15_000 }) + // Exit contribution must render as 0.50% (5000 PPM / 10000), proving C1's fix. + await expect(page.getByText('0.50%')).toBeVisible() + + // Review → confirm sheet → confirm the swap. + await expect(page.getByText('Review Swap')).toBeVisible() + await page.getByText('Review Swap').click() + await expect(page.getByText('Confirm Swap').first()).toBeVisible() + await page.getByTestId('GoodReserveWidget-confirm-cta').click() + + // Success screen with the explorer link backed by the submitted tx hash. + await expect(page.getByText('Swap Successful')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('View on Explorer')).toBeVisible() + await page.screenshot({ path: `${SCREENSHOT_DIR}/grw-16-live-buy-success.png` }) +}) diff --git a/tests/widgets/goodreserve-widget/test-results/grw-01-no-provider.png b/tests/widgets/goodreserve-widget/test-results/grw-01-no-provider.png new file mode 100644 index 0000000..68f3dd9 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-01-no-provider.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-02-unsupported-chain.png b/tests/widgets/goodreserve-widget/test-results/grw-02-unsupported-chain.png new file mode 100644 index 0000000..68f3dd9 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-02-unsupported-chain.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-03-idle-buy.png b/tests/widgets/goodreserve-widget/test-results/grw-03-idle-buy.png new file mode 100644 index 0000000..a0b39db Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-03-idle-buy.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-04-quote-ready-buy.png b/tests/widgets/goodreserve-widget/test-results/grw-04-quote-ready-buy.png new file mode 100644 index 0000000..2e59fbe Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-04-quote-ready-buy.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-05-quote-ready-sell.png b/tests/widgets/goodreserve-widget/test-results/grw-05-quote-ready-sell.png new file mode 100644 index 0000000..499da15 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-05-quote-ready-sell.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-06-quote-ready-xdc.png b/tests/widgets/goodreserve-widget/test-results/grw-06-quote-ready-xdc.png new file mode 100644 index 0000000..55bf388 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-06-quote-ready-xdc.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-07-insufficient-balance.png b/tests/widgets/goodreserve-widget/test-results/grw-07-insufficient-balance.png new file mode 100644 index 0000000..f228213 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-07-insufficient-balance.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-08-slippage-selection.png b/tests/widgets/goodreserve-widget/test-results/grw-08-slippage-selection.png new file mode 100644 index 0000000..d96a682 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-08-slippage-selection.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-09-confirm-dialog.png b/tests/widgets/goodreserve-widget/test-results/grw-09-confirm-dialog.png new file mode 100644 index 0000000..a012825 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-09-confirm-dialog.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-10-swap-pending.png b/tests/widgets/goodreserve-widget/test-results/grw-10-swap-pending.png new file mode 100644 index 0000000..42450fa Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-10-swap-pending.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-11-swap-success.png b/tests/widgets/goodreserve-widget/test-results/grw-11-swap-success.png new file mode 100644 index 0000000..23b2842 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-11-swap-success.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-12-swap-error.png b/tests/widgets/goodreserve-widget/test-results/grw-12-swap-error.png new file mode 100644 index 0000000..2ba416b Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-12-swap-error.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-13-sdk-initializing.png b/tests/widgets/goodreserve-widget/test-results/grw-13-sdk-initializing.png new file mode 100644 index 0000000..ad759a6 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-13-sdk-initializing.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-14-amount-editing.png b/tests/widgets/goodreserve-widget/test-results/grw-14-amount-editing.png new file mode 100644 index 0000000..a5f0a39 Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-14-amount-editing.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-15-quote-loading.png b/tests/widgets/goodreserve-widget/test-results/grw-15-quote-loading.png new file mode 100644 index 0000000..0cf93da Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-15-quote-loading.png differ diff --git a/tests/widgets/goodreserve-widget/test-results/grw-16-live-buy-success.png b/tests/widgets/goodreserve-widget/test-results/grw-16-live-buy-success.png new file mode 100644 index 0000000..3b8f31d Binary files /dev/null and b/tests/widgets/goodreserve-widget/test-results/grw-16-live-buy-success.png differ