From 2cf0c9e5a5e8e868a2a20204251a07fcb3156148 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 09:49:38 +0000
Subject: [PATCH 1/3] Initial plan
From 3605de868bcce84b77a30b38dff3fce005826b31 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 19 May 2026 10:00:52 +0000
Subject: [PATCH 2/3] feat: add streaming-widget package with Superfluid
streaming flows, GDA pools, and balances tabs
Agent-Logs-Url: https://github.com/GoodDollar/GoodWidget/sessions/a9145703-d466-4dd5-92ed-e73fa017af47
Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com>
---
examples/storybook/package.json | 1 +
.../StreamingWidget.stories.tsx | 125 +++
packages/streaming-widget/package.json | 51 ++
.../streaming-widget/src/StreamingWidget.tsx | 720 ++++++++++++++++++
packages/streaming-widget/src/adapter.ts | 553 ++++++++++++++
packages/streaming-widget/src/element.ts | 25 +
packages/streaming-widget/src/index.ts | 23 +
packages/streaming-widget/src/register.ts | 27 +
.../src/widgetRuntimeContract.ts | 165 ++++
packages/streaming-widget/tsconfig.build.json | 11 +
packages/streaming-widget/tsconfig.json | 14 +
packages/streaming-widget/tsup.config.ts | 15 +
pnpm-lock.yaml | 181 ++++-
tests/widgets/streaming-widget/states.spec.ts | 284 +++++++
.../streaming-widget/test-results/.gitkeep | 2 +
15 files changed, 2161 insertions(+), 36 deletions(-)
create mode 100644 examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx
create mode 100644 packages/streaming-widget/package.json
create mode 100644 packages/streaming-widget/src/StreamingWidget.tsx
create mode 100644 packages/streaming-widget/src/adapter.ts
create mode 100644 packages/streaming-widget/src/element.ts
create mode 100644 packages/streaming-widget/src/index.ts
create mode 100644 packages/streaming-widget/src/register.ts
create mode 100644 packages/streaming-widget/src/widgetRuntimeContract.ts
create mode 100644 packages/streaming-widget/tsconfig.build.json
create mode 100644 packages/streaming-widget/tsconfig.json
create mode 100644 packages/streaming-widget/tsup.config.ts
create mode 100644 tests/widgets/streaming-widget/states.spec.ts
create mode 100644 tests/widgets/streaming-widget/test-results/.gitkeep
diff --git a/examples/storybook/package.json b/examples/storybook/package.json
index 25373c9..75a3ae5 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/streaming-widget": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13",
diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx
new file mode 100644
index 0000000..12520e8
--- /dev/null
+++ b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx
@@ -0,0 +1,125 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import { StreamingWidget } from '@goodwidget/streaming-widget'
+import { YStack } from '@goodwidget/ui'
+import {
+ getInjectedEip1193Provider,
+ isInjectedProviderUsable,
+} from '../../fixtures/injectedEip1193'
+import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
+
+// ---------------------------------------------------------------------------
+// Story shell — renders the widget inside a fixed-width container that mirrors
+// the GoodWalletV2 sidebar / bottom-sheet form factor.
+// ---------------------------------------------------------------------------
+function StreamingWidgetStoryShell({
+ provider,
+ dataTestId,
+}: {
+ provider: unknown
+ dataTestId: string
+}) {
+ return (
+
+
+
+ )
+}
+
+const meta: Meta = {
+ title: 'Widgets/StreamingWidget',
+ component: StreamingWidget,
+ tags: ['autodocs'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+// ---------------------------------------------------------------------------
+// Injected wallet story — uses window.ethereum if present in the browser
+// ---------------------------------------------------------------------------
+function InjectedWalletStory() {
+ const injectedProvider = getInjectedEip1193Provider()
+ const usableProvider = isInjectedProviderUsable(injectedProvider)
+
+ if (!usableProvider) {
+ return (
+
+ No injected wallet found
+
+ Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh
+ Storybook. The widget supports Celo (G$) and Base (SUP).
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Custodial fixture story — uses the pre-configured test wallet from the fixture
+// ---------------------------------------------------------------------------
+function CustodialLocalFixtureStory() {
+ try {
+ const provider = createCustodialEip1193Provider()
+ return (
+
+ )
+ } catch (error: unknown) {
+ return (
+
+ Custodial fixture not configured
+
+ {error instanceof Error
+ ? error.message
+ : 'Set a local private key in custodialEip1193.ts'}
+
+
+ )
+ }
+}
+
+// ---------------------------------------------------------------------------
+// No-wallet story — demonstrates the connect-prompt state
+// ---------------------------------------------------------------------------
+function NoWalletStory() {
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Story exports
+// ---------------------------------------------------------------------------
+
+/** Uses window.ethereum if available — shows the full connected experience. */
+export const InjectedWallet: Story = {
+ render: () => ,
+}
+
+/**
+ * Uses a pre-configured custodial test wallet backed by a local private key.
+ * Starts on Celo (chain 42220) and uses the development environment.
+ * The test key has no on-chain streaming history, so streams/pools lists will be empty.
+ */
+export const CustodialLocalFixture: Story = {
+ render: () => ,
+}
+
+/** No provider — shows the wallet-connection prompt for both Streams and Pools tabs. */
+export const NoWallet: Story = {
+ render: () => ,
+}
diff --git a/packages/streaming-widget/package.json b/packages/streaming-widget/package.json
new file mode 100644
index 0000000..57eaf0c
--- /dev/null
+++ b/packages/streaming-widget/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@goodwidget/streaming-widget",
+ "version": "0.1.0-beta",
+ "description": "GoodWidget for Superfluid streaming flows — streams, GDA pool memberships, and Super Token balances",
+ "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": {
+ "@goodsdks/streaming-sdk": "1.0.0",
+ "@goodwidget/core": "workspace:*",
+ "@goodwidget/embed": "workspace:*",
+ "@goodwidget/ui": "workspace:*",
+ "viem": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "tsup": "^8.4.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/packages/streaming-widget/src/StreamingWidget.tsx b/packages/streaming-widget/src/StreamingWidget.tsx
new file mode 100644
index 0000000..1e65fa3
--- /dev/null
+++ b/packages/streaming-widget/src/StreamingWidget.tsx
@@ -0,0 +1,720 @@
+import React, { useState } from 'react'
+import { GoodWidgetProvider } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import {
+ createComponent,
+ Card,
+ Heading,
+ Text,
+ Button,
+ ButtonText,
+ Spinner,
+ Separator,
+ ToastContainer,
+ XStack,
+ YStack,
+ Input,
+ Select,
+ Badge,
+ BadgeText,
+ AddressDisplay,
+ TokenAmount,
+ WidgetTabs,
+} from '@goodwidget/ui'
+import type { Address } from 'viem'
+import { formatUnits } from 'viem'
+import { useStreamingAdapter } from './adapter'
+import type {
+ StreamingWidgetProps,
+ StreamingWidgetTab,
+ StreamDirection,
+ StreamListItem,
+ PoolMembershipItem,
+ StreamTimeUnit,
+ WriteStatus,
+} from './widgetRuntimeContract'
+import { STREAMING_CHAINS } from './widgetRuntimeContract'
+
+// ---------------------------------------------------------------------------
+// Named styled sub-components — participate in the component sub-theme system.
+// Integrators can override via themeOverrides.
+// ---------------------------------------------------------------------------
+
+/** Outer shell for each tab's content area */
+const StreamingTabContent = createComponent(YStack, {
+ name: 'StreamingTabContent',
+ flex: 1,
+ gap: '$3',
+ paddingVertical: '$3',
+})
+
+/** Row card for a single stream entry */
+const StreamRow = createComponent(Card, {
+ name: 'StreamRow',
+ padding: '$3',
+ gap: '$2',
+})
+
+/** Row card for a single pool membership entry */
+const PoolRow = createComponent(Card, {
+ name: 'PoolRow',
+ padding: '$3',
+ gap: '$2',
+})
+
+/** Card displayed when a list is empty */
+const EmptyStateCard = createComponent(Card, {
+ name: 'EmptyStateCard',
+ padding: '$6',
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ gap: '$3',
+})
+
+/** Card displayed for inline error states */
+const ErrorStateCard = createComponent(Card, {
+ name: 'ErrorStateCard',
+ padding: '$4',
+ gap: '$2',
+})
+
+/** Card for the create/update stream form */
+const SetStreamFormCard = createComponent(Card, {
+ name: 'SetStreamFormCard',
+ padding: '$4',
+ gap: '$3',
+})
+
+/** Card for balance display */
+const BalanceCard = createComponent(Card, {
+ name: 'BalanceCard',
+ padding: '$4',
+ gap: '$2',
+})
+
+// ---------------------------------------------------------------------------
+// Utility helpers
+// ---------------------------------------------------------------------------
+
+const TIME_UNIT_OPTIONS: Array<{ value: StreamTimeUnit; label: string }> = [
+ { value: 'second', label: 'per second' },
+ { value: 'minute', label: 'per minute' },
+ { value: 'hour', label: 'per hour' },
+ { value: 'day', label: 'per day' },
+ { value: 'week', label: 'per week' },
+ { value: 'month', label: 'per month' },
+ { value: 'year', label: 'per year' },
+]
+
+/** Formats a flow rate bigint (wei/s) into a human-readable per-period amount */
+function formatFlowRateDisplay(flowRate: bigint, decimals = 18): string {
+ if (flowRate === 0n) return '0'
+ // Convert wei/s → per-month amount for display
+ const perMonth = flowRate * BigInt(30 * 24 * 60 * 60)
+ return formatUnits(perMonth, decimals)
+}
+
+/** Formats a unix timestamp (seconds) to a short locale date string */
+function formatTimestamp(unixSeconds: number): string {
+ if (!unixSeconds) return '—'
+ return new Date(unixSeconds * 1000).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+}
+
+/** Short-form chain name display */
+function chainName(chainId: number): string {
+ if (chainId === STREAMING_CHAINS.CELO) return 'Celo'
+ if (chainId === STREAMING_CHAINS.BASE) return 'Base'
+ return `Chain ${chainId}`
+}
+
+// ---------------------------------------------------------------------------
+// Write-status badge helper
+// ---------------------------------------------------------------------------
+function WriteStatusBadge({ status }: { status: WriteStatus }) {
+ if (status === 'idle') return null
+ if (status === 'pending') return
+ if (status === 'success')
+ return (
+
+ Done
+
+ )
+ return (
+
+ Failed
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Not-connected / wrong-chain prompt
+// ---------------------------------------------------------------------------
+function WalletGate({
+ isConnected,
+ isWrongChain,
+ onConnect,
+ onSwitchChain,
+}: {
+ isConnected: boolean
+ isWrongChain: boolean
+ onConnect: () => void
+ onSwitchChain: (chainId: number) => void
+}) {
+ if (!isConnected) {
+ return (
+
+
+ Wallet not connected
+
+
+ Connect your wallet to view streams, pools, and balances.
+
+
+
+ )
+ }
+
+ if (isWrongChain) {
+ return (
+
+
+ Unsupported network
+
+
+ Switch to Celo or Base to use the streaming widget.
+
+
+
+
+
+
+ )
+ }
+
+ return null
+}
+
+// ---------------------------------------------------------------------------
+// Set-stream form — create or update an outgoing stream
+// ---------------------------------------------------------------------------
+function SetStreamForm({
+ form,
+ status,
+ error,
+ txHash,
+ onUpdate,
+ onSubmit,
+ onReset,
+}: {
+ form: ReturnType['state']['setStreamForm']
+ status: WriteStatus
+ error: string | null
+ txHash: string | null
+ onUpdate: (partial: Partial) => void
+ onSubmit: () => void
+ onReset: () => void
+}) {
+ const isSubmitting = status === 'pending'
+
+ return (
+
+ {form.receiver ? 'Update Stream' : 'Create Stream'}
+
+ {/* Recipient address */}
+
+ Recipient address
+ onUpdate({ receiver: v })}
+ editable={!isSubmitting}
+ />
+
+
+ {/* Amount + time unit */}
+
+
+ Amount
+ onUpdate({ amount: v })}
+ keyboardType="decimal-pad"
+ editable={!isSubmitting}
+ />
+
+
+ Period
+
+
+
+ {/* Computed flow rate preview */}
+ {form.flowRate !== null && form.flowRate > 0n && (
+
+
+ ≈ {formatUnits(form.flowRate, 18)} tokens/s
+
+
+ )}
+
+ {/* Validation / error feedback */}
+ {form.validationError && (
+
+ {form.validationError}
+
+ )}
+ {status === 'error' && error && (
+
+ {error}
+
+ )}
+ {status === 'success' && txHash && (
+
+ Stream set! Tx: {txHash.slice(0, 10)}…
+
+ )}
+
+ {/* Actions */}
+
+
+ {(status === 'success' || status === 'error') && (
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Single stream row
+// ---------------------------------------------------------------------------
+function StreamCard({ stream }: { stream: StreamListItem }) {
+ const counterparty =
+ stream.direction === 'outgoing' ? stream.receiver : stream.sender
+ const flowPerMonth = formatFlowRateDisplay(stream.flowRate)
+
+ return (
+
+
+
+ {stream.direction === 'incoming' ? '↓ Incoming' : '↑ Outgoing'}
+
+
+ Since {formatTimestamp(stream.createdAtTimestamp)}
+
+
+
+
+
+
+
+ Flow rate
+
+
+ {flowPerMonth}/mo
+
+
+
+ {stream.streamedSoFar > 0n && (
+
+
+ Streamed so far
+
+ {formatUnits(stream.streamedSoFar, 18)}
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Streams tab
+// ---------------------------------------------------------------------------
+function StreamsTab({
+ state,
+ actions,
+}: {
+ state: ReturnType['state']
+ actions: ReturnType['actions']
+}) {
+ const [direction, setDirection] = useState('all')
+ const [showForm, setShowForm] = useState(false)
+
+ const filteredStreams = state.streams.filter(
+ (s) => direction === 'all' || s.direction === direction,
+ )
+
+ return (
+
+ {/* Create stream form toggle */}
+
+
+
+
+ {showForm && (
+ {
+ actions.resetSetStream()
+ setShowForm(false)
+ }}
+ />
+ )}
+
+
+
+ {/* Direction filter */}
+
+ {(['all', 'incoming', 'outgoing'] as StreamDirection[]).map((d) => (
+
+ ))}
+
+
+ {/* List states */}
+ {state.streamsLoading && (
+
+
+ Loading streams…
+
+ )}
+
+ {!state.streamsLoading && state.streamsError && (
+
+ {state.streamsError}
+
+
+ )}
+
+ {!state.streamsLoading && !state.streamsError && filteredStreams.length === 0 && (
+
+
+ No {direction === 'all' ? '' : direction} streams found.
+
+
+
+ )}
+
+ {!state.streamsLoading &&
+ !state.streamsError &&
+ filteredStreams.map((stream) => )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Single pool row
+// ---------------------------------------------------------------------------
+function PoolCard({
+ pool,
+ connectStatus,
+ connectError,
+ onConnect,
+ onDisconnect,
+}: {
+ pool: PoolMembershipItem
+ connectStatus: WriteStatus
+ connectError: string | null
+ onConnect: (poolAddress: Address) => void
+ onDisconnect: (poolAddress: Address) => void
+}) {
+ const isPending = connectStatus === 'pending'
+
+ return (
+
+
+
+
+ {pool.isConnected ? 'Connected' : 'Disconnected'}
+
+
+
+
+
+ Total claimed
+
+ {formatUnits(pool.totalAmountClaimed, 18)}
+
+
+ {connectError && (
+
+ {connectError}
+
+ )}
+
+
+
+ {pool.isConnected ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Pools tab
+// ---------------------------------------------------------------------------
+function PoolsTab({
+ state,
+ actions,
+}: {
+ state: ReturnType['state']
+ actions: ReturnType['actions']
+}) {
+ return (
+
+ {state.poolsLoading && (
+
+
+ Loading pool memberships…
+
+ )}
+
+ {!state.poolsLoading && state.poolsError && (
+
+ {state.poolsError}
+
+
+ )}
+
+ {!state.poolsLoading && !state.poolsError && state.pools.length === 0 && (
+
+
+ No GDA pool memberships found for this address.
+
+
+
+ )}
+
+ {!state.poolsLoading &&
+ !state.poolsError &&
+ state.pools.map((pool) => (
+
+ ))}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Balances tab
+// ---------------------------------------------------------------------------
+function BalancesTab({
+ state,
+ actions,
+}: {
+ state: ReturnType['state']
+ actions: ReturnType['actions']
+}) {
+ const isOnBase = state.chainId === STREAMING_CHAINS.BASE
+
+ return (
+
+ {/* Super Token balance */}
+
+
+ Super Token Balance
+
+
+
+ {state.balanceLoading && }
+
+ {!state.balanceLoading && state.balanceError && (
+
+ {state.balanceError}
+
+ )}
+
+ {!state.balanceLoading && !state.balanceError && state.superTokenBalance !== null && (
+
+ )}
+
+ {state.chainId && (
+
+ {chainName(state.chainId)}
+
+ )}
+
+
+ {/* SUP reserve — Base only */}
+ {isOnBase ? (
+
+
+ SUP Reserve (Staked)
+ {state.supReserveLoading && }
+
+
+ {!state.supReserveLoading && state.supReserveError && (
+
+ {state.supReserveError}
+
+ )}
+
+ {!state.supReserveLoading && !state.supReserveError && state.supReserveBalance !== null && (
+
+ )}
+
+
+ SUP tokens locked as reserve on Base.
+
+
+ ) : (
+
+
+ SUP Reserve
+
+
+ Reserve data is only available on Base. Switch to Base to view your SUP reserve balance.
+
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Inner component — must live inside GoodWidgetProvider
+// ---------------------------------------------------------------------------
+function StreamingWidgetInner({
+ environment,
+ apiKey,
+}: {
+ environment: StreamingWidgetProps['environment']
+ apiKey?: string
+}) {
+ const { state, actions } = useStreamingAdapter({ environment, apiKey })
+ const [activeTab, setActiveTab] = useState('streams')
+
+ const walletGate = (
+
+ )
+
+ const tabContent = !state.isConnected || state.isWrongChain ? walletGate : (
+ <>
+ {activeTab === 'streams' && }
+ {activeTab === 'pools' && }
+ {activeTab === 'balances' && }
+ >
+ )
+
+ return (
+
+ setActiveTab(id as StreamingWidgetTab)}
+ chainId={state.chainId ?? undefined}
+ />
+ {tabContent}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Public component
+// ---------------------------------------------------------------------------
+
+/**
+ * StreamingWidget — Superfluid streaming flows, GDA pool memberships, and
+ * Super Token balances for GoodDollar (G$ on Celo) and SUP (on Base).
+ *
+ * Usage as a React component:
+ *
+ *
+ * Also available as a Web Component via the `element` or `register` entry points.
+ *
+ * Provider-first runtime path:
+ * host provider → GoodWidgetProvider → streaming adapter → streaming-sdk
+ */
+export function StreamingWidget({
+ provider,
+ environment = 'production',
+ themeOverrides,
+ config,
+ defaultTheme = 'light',
+ apiKey,
+}: StreamingWidgetProps) {
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/streaming-widget/src/adapter.ts b/packages/streaming-widget/src/adapter.ts
new file mode 100644
index 0000000..313ce59
--- /dev/null
+++ b/packages/streaming-widget/src/adapter.ts
@@ -0,0 +1,553 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useWallet } from '@goodwidget/core'
+import {
+ createPublicClient,
+ createWalletClient,
+ custom,
+ formatUnits,
+ http,
+ parseUnits,
+ type Chain,
+} from 'viem'
+import {
+ StreamingSDK,
+ GdaSDK,
+ SupportedChains,
+ calculateFlowRate,
+ isSupportedChain,
+ SubgraphClient,
+} from '@goodsdks/streaming-sdk'
+import type { Address } from 'viem'
+import type { GDAPool, StreamInfo } from '@goodsdks/streaming-sdk'
+import type {
+ StreamingWidgetAdapterResult,
+ StreamingWidgetAdapterState,
+ StreamingWidgetAdapterActions,
+ StreamingWidgetEnvironment,
+ StreamListItem,
+ PoolMembershipItem,
+ SetStreamFormState,
+ WriteStatus,
+} from './widgetRuntimeContract'
+
+// ---------------------------------------------------------------------------
+// Chain descriptors for Superfluid-supported chains (Celo and Base)
+// ---------------------------------------------------------------------------
+const VIEM_CHAINS: Record = {
+ [SupportedChains.CELO]: {
+ id: SupportedChains.CELO,
+ name: 'Celo',
+ nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
+ rpcUrls: { default: { http: ['https://forno.celo.org'] } },
+ } as Chain,
+ [SupportedChains.BASE]: {
+ id: SupportedChains.BASE,
+ name: 'Base',
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
+ rpcUrls: { default: { http: ['https://mainnet.base.org'] } },
+ } as Chain,
+}
+
+// ---------------------------------------------------------------------------
+// Default form state — used on first render and after reset
+// ---------------------------------------------------------------------------
+const DEFAULT_FORM_STATE: SetStreamFormState = {
+ receiver: '',
+ amount: '',
+ timeUnit: 'month',
+ flowRate: null,
+ validationError: null,
+}
+
+// ---------------------------------------------------------------------------
+// humanReadableError — maps raw SDK/viem errors to user-facing strings
+// ---------------------------------------------------------------------------
+function humanReadableError(err: unknown): string {
+ console.error('[StreamingWidget]', err)
+
+ if (!(err instanceof Error)) return 'Something went wrong. Please try again.'
+
+ const msg = err.message
+
+ if (
+ msg.includes('Failed to fetch') ||
+ msg.includes('fetch failed') ||
+ msg.includes('NetworkError') ||
+ msg.includes('net::ERR_')
+ ) {
+ return 'Unable to reach the network. Check your connection and try again.'
+ }
+
+ if (msg.includes('timeout') || msg.includes('Timeout') || msg.includes('timed out')) {
+ return 'The request timed out. Please try again.'
+ }
+
+ if (msg.includes('User rejected') || msg.includes('user rejected') || msg.includes('4001')) {
+ return 'Transaction cancelled by wallet.'
+ }
+
+ if (msg.includes('Token address not available')) {
+ return 'This token is not available on the current chain.'
+ }
+
+ return 'Something went wrong. Please try again.'
+}
+
+// ---------------------------------------------------------------------------
+// Adapter options
+// ---------------------------------------------------------------------------
+export interface UseStreamingAdapterOptions {
+ environment?: StreamingWidgetEnvironment
+ apiKey?: string
+}
+
+// ---------------------------------------------------------------------------
+// Derive StreamListItem from the SDK StreamInfo
+// ---------------------------------------------------------------------------
+function toStreamListItem(stream: StreamInfo, address: Address): StreamListItem {
+ const direction =
+ stream.sender.toLowerCase() === address.toLowerCase() ? 'outgoing' : 'incoming'
+ return {
+ id: `${stream.sender}-${stream.receiver}-${stream.token}`,
+ sender: stream.sender,
+ receiver: stream.receiver,
+ token: stream.token,
+ flowRate: stream.flowRate,
+ streamedSoFar: stream.streamedSoFar ?? 0n,
+ createdAtTimestamp: stream.timestamp ? Number(stream.timestamp) : 0,
+ updatedAtTimestamp: stream.timestamp ? Number(stream.timestamp) : 0,
+ direction,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Derive PoolMembershipItem from the SDK GDAPool
+// ---------------------------------------------------------------------------
+function toPoolMembershipItem(pool: GDAPool): PoolMembershipItem {
+ return {
+ poolId: pool.id,
+ poolToken: pool.token,
+ totalUnits: pool.totalUnits,
+ totalAmountClaimed: pool.totalAmountClaimed,
+ isConnected: pool.isConnected ?? false,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Validate the set-stream form and compute the derived flowRate
+// ---------------------------------------------------------------------------
+function validateSetStreamForm(form: SetStreamFormState): SetStreamFormState {
+ const trimmedReceiver = form.receiver.trim()
+ const trimmedAmount = form.amount.trim()
+
+ if (!trimmedReceiver) {
+ return { ...form, flowRate: null, validationError: 'Recipient address is required.' }
+ }
+
+ if (!/^0x[0-9a-fA-F]{40}$/.test(trimmedReceiver)) {
+ return {
+ ...form,
+ flowRate: null,
+ validationError: 'Recipient must be a valid Ethereum address (0x…).',
+ }
+ }
+
+ if (!trimmedAmount || Number.isNaN(Number(trimmedAmount)) || Number(trimmedAmount) <= 0) {
+ return { ...form, flowRate: null, validationError: 'Enter a positive flow amount.' }
+ }
+
+ try {
+ const amountWei = parseUnits(trimmedAmount, 18)
+ const flowRate = calculateFlowRate(amountWei, form.timeUnit)
+ return { ...form, flowRate, validationError: null }
+ } catch {
+ return { ...form, flowRate: null, validationError: 'Invalid amount.' }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Main adapter hook
+// ---------------------------------------------------------------------------
+export function useStreamingAdapter({
+ environment = 'production',
+ apiKey,
+}: UseStreamingAdapterOptions = {}): StreamingWidgetAdapterResult {
+ const { address, chainId, provider, isConnected, connect } = useWallet()
+
+ // --- streams state ---
+ const [streams, setStreams] = useState([])
+ const [streamsLoading, setStreamsLoading] = useState(false)
+ const [streamsError, setStreamsError] = useState(null)
+
+ // --- pools state ---
+ const [pools, setPools] = useState([])
+ const [poolsLoading, setPoolsLoading] = useState(false)
+ const [poolsError, setPoolsError] = useState(null)
+
+ // --- balance state ---
+ const [superTokenBalance, setSuperTokenBalance] = useState(null)
+ const [balanceLoading, setBalanceLoading] = useState(false)
+ const [balanceError, setBalanceError] = useState(null)
+
+ // --- SUP reserve state (Base only) ---
+ const [supReserveBalance, setSupReserveBalance] = useState(null)
+ const [supReserveLoading, setSupReserveLoading] = useState(false)
+ const [supReserveError, setSupReserveError] = useState(null)
+
+ // --- set-stream form state ---
+ const [setStreamForm, setSetStreamForm] = useState(DEFAULT_FORM_STATE)
+ const [setStreamStatus, setSetStreamStatus] = useState('idle')
+ const [setStreamError, setSetStreamError] = useState(null)
+ const [setStreamTxHash, setSetStreamTxHash] = useState(null)
+
+ // --- pool connect/disconnect state keyed by pool address ---
+ const [poolConnectStatus, setPoolConnectStatus] = useState>({})
+ const [poolConnectError, setPoolConnectError] = useState>({})
+
+ // Chain validity
+ const isWrongChain = !!chainId && !isSupportedChain(chainId)
+
+ // ---------------------------------------------------------------------------
+ // Build viem clients from the EIP-1193 provider
+ // ---------------------------------------------------------------------------
+ const viemClients = useMemo(() => {
+ if (!provider || !chainId || !isSupportedChain(chainId)) return null
+
+ const chain = VIEM_CHAINS[chainId]
+ if (!chain) return null
+
+ const transport = custom(provider as Parameters[0])
+ const publicClient = createPublicClient({ chain, transport: http(chain.rpcUrls.default.http[0]) })
+ const walletClient = createWalletClient({ chain, transport })
+
+ return { publicClient, walletClient }
+ }, [provider, chainId])
+
+ // SDK instances — recreated when clients change
+ const streamingSDK = useMemo(
+ () =>
+ viemClients
+ ? new StreamingSDK(viemClients.publicClient, viemClients.walletClient, {
+ environment,
+ apiKey,
+ })
+ : null,
+ [viemClients, environment, apiKey],
+ )
+
+ const gdaSDK = useMemo(
+ () =>
+ viemClients
+ ? new GdaSDK(viemClients.publicClient, viemClients.walletClient, {
+ environment,
+ })
+ : null,
+ [viemClients, environment],
+ )
+
+ // Subgraph client for reserve queries (Base only)
+ const subgraphClient = useMemo(() => {
+ if (!chainId || !isSupportedChain(chainId)) return null
+ return new SubgraphClient(chainId, { apiKey })
+ }, [chainId, apiKey])
+
+ // ---------------------------------------------------------------------------
+ // Fetch streams
+ // ---------------------------------------------------------------------------
+ const fetchStreams = useCallback(async () => {
+ if (!streamingSDK || !address) return
+
+ setStreamsLoading(true)
+ setStreamsError(null)
+ try {
+ const result = await streamingSDK.getActiveStreams({
+ account: address as Address,
+ direction: 'all',
+ })
+ setStreams(result.map((s) => toStreamListItem(s, address as Address)))
+ } catch (err) {
+ setStreamsError(humanReadableError(err))
+ } finally {
+ setStreamsLoading(false)
+ }
+ }, [streamingSDK, address])
+
+ // ---------------------------------------------------------------------------
+ // Fetch pool memberships
+ // ---------------------------------------------------------------------------
+ const fetchPools = useCallback(async () => {
+ if (!gdaSDK || !address) return
+
+ setPoolsLoading(true)
+ setPoolsError(null)
+ try {
+ const result = await gdaSDK.getDistributionPools(address as Address)
+ setPools(result.map(toPoolMembershipItem))
+ } catch (err) {
+ setPoolsError(humanReadableError(err))
+ } finally {
+ setPoolsLoading(false)
+ }
+ }, [gdaSDK, address])
+
+ // ---------------------------------------------------------------------------
+ // Fetch Super Token balance
+ // ---------------------------------------------------------------------------
+ const fetchBalance = useCallback(async () => {
+ if (!streamingSDK || !address) return
+
+ setBalanceLoading(true)
+ setBalanceError(null)
+ try {
+ const rawBalance = await streamingSDK.getSuperTokenBalance(address as Address)
+ setSuperTokenBalance(formatUnits(rawBalance, 18))
+ } catch (err) {
+ setBalanceError(humanReadableError(err))
+ } finally {
+ setBalanceLoading(false)
+ }
+ }, [streamingSDK, address])
+
+ // ---------------------------------------------------------------------------
+ // Fetch SUP reserve balance (Base only)
+ // ---------------------------------------------------------------------------
+ const fetchSupReserve = useCallback(async () => {
+ if (!subgraphClient || !address || chainId !== SupportedChains.BASE) {
+ setSupReserveBalance(null)
+ return
+ }
+
+ setSupReserveLoading(true)
+ setSupReserveError(null)
+ try {
+ const lockers = await subgraphClient.querySUPReserves(address as Address)
+ const total = lockers.reduce((sum, l) => sum + l.stakedBalance, 0n)
+ setSupReserveBalance(formatUnits(total, 18))
+ } catch (err) {
+ setSupReserveError(humanReadableError(err))
+ } finally {
+ setSupReserveLoading(false)
+ }
+ }, [subgraphClient, address, chainId])
+
+ // ---------------------------------------------------------------------------
+ // Auto-fetch on wallet/chain change
+ // ---------------------------------------------------------------------------
+ const prevAddressRef = useRef(null)
+ const prevChainRef = useRef(null)
+
+ useEffect(() => {
+ const addressChanged = address !== prevAddressRef.current
+ const chainChanged = chainId !== prevChainRef.current
+ prevAddressRef.current = address
+ prevChainRef.current = chainId
+
+ if (!isConnected || !address || isWrongChain) {
+ setStreams([])
+ setPools([])
+ setSuperTokenBalance(null)
+ setSupReserveBalance(null)
+ return
+ }
+
+ if (!addressChanged && !chainChanged) return
+
+ void fetchStreams()
+ void fetchPools()
+ void fetchBalance()
+ void fetchSupReserve()
+ }, [isConnected, address, chainId, isWrongChain, fetchStreams, fetchPools, fetchBalance, fetchSupReserve])
+
+ // ---------------------------------------------------------------------------
+ // Set-stream form update — recomputes flowRate on every change
+ // ---------------------------------------------------------------------------
+ const updateSetStreamForm = useCallback((partial: Partial) => {
+ setSetStreamForm((prev) => validateSetStreamForm({ ...prev, ...partial }))
+ }, [])
+
+ // ---------------------------------------------------------------------------
+ // Submit set-stream form
+ // ---------------------------------------------------------------------------
+ const submitSetStream = useCallback(async () => {
+ if (!streamingSDK) return
+
+ const validated = validateSetStreamForm(setStreamForm)
+ setSetStreamForm(validated)
+
+ if (!validated.flowRate || validated.validationError) return
+
+ setSetStreamStatus('pending')
+ setSetStreamError(null)
+ setSetStreamTxHash(null)
+
+ try {
+ const hash = await streamingSDK.createOrUpdateStream({
+ receiver: validated.receiver as Address,
+ flowRate: validated.flowRate,
+ onHash: (h) => setSetStreamTxHash(h),
+ })
+ setSetStreamTxHash(hash)
+ setSetStreamStatus('success')
+ // Refresh streams after a successful write
+ void fetchStreams()
+ } catch (err) {
+ setSetStreamStatus('error')
+ setSetStreamError(humanReadableError(err))
+ }
+ }, [streamingSDK, setStreamForm, fetchStreams])
+
+ // ---------------------------------------------------------------------------
+ // Reset set-stream form and write status
+ // ---------------------------------------------------------------------------
+ const resetSetStream = useCallback(() => {
+ setSetStreamForm(DEFAULT_FORM_STATE)
+ setSetStreamStatus('idle')
+ setSetStreamError(null)
+ setSetStreamTxHash(null)
+ }, [])
+
+ // ---------------------------------------------------------------------------
+ // Pool connect/disconnect
+ // ---------------------------------------------------------------------------
+ const connectToPool = useCallback(
+ async (poolAddress: Address) => {
+ if (!gdaSDK) return
+
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'pending' }))
+ setPoolConnectError((prev) => ({ ...prev, [poolAddress]: null }))
+
+ try {
+ await gdaSDK.connectToPool({ poolAddress })
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' }))
+ void fetchPools()
+ } catch (err) {
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'error' }))
+ setPoolConnectError((prev) => ({
+ ...prev,
+ [poolAddress]: humanReadableError(err),
+ }))
+ }
+ },
+ [gdaSDK, fetchPools],
+ )
+
+ const disconnectFromPool = useCallback(
+ async (poolAddress: Address) => {
+ if (!gdaSDK) return
+
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'pending' }))
+ setPoolConnectError((prev) => ({ ...prev, [poolAddress]: null }))
+
+ try {
+ await gdaSDK.disconnectFromPool({ poolAddress })
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' }))
+ void fetchPools()
+ } catch (err) {
+ setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'error' }))
+ setPoolConnectError((prev) => ({
+ ...prev,
+ [poolAddress]: humanReadableError(err),
+ }))
+ }
+ },
+ [gdaSDK, fetchPools],
+ )
+
+ // ---------------------------------------------------------------------------
+ // Chain switch via EIP-1193
+ // ---------------------------------------------------------------------------
+ const switchChain = useCallback(
+ async (targetChainId: number) => {
+ if (!provider) return
+ const hexId = `0x${targetChainId.toString(16)}`
+ await (provider as { request: (args: { method: string; params?: unknown[] }) => Promise }).request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: hexId }],
+ })
+ },
+ [provider],
+ )
+
+ // ---------------------------------------------------------------------------
+ // Compose state and actions
+ // ---------------------------------------------------------------------------
+ const state: StreamingWidgetAdapterState = useMemo(
+ () => ({
+ isConnected,
+ address: address as Address | null,
+ chainId,
+ isWrongChain,
+ streams,
+ streamsLoading,
+ streamsError,
+ pools,
+ poolsLoading,
+ poolsError,
+ superTokenBalance,
+ balanceLoading,
+ balanceError,
+ supReserveBalance,
+ supReserveLoading,
+ supReserveError,
+ setStreamForm,
+ setStreamStatus,
+ setStreamError,
+ setStreamTxHash,
+ poolConnectStatus,
+ poolConnectError,
+ }),
+ [
+ isConnected,
+ address,
+ chainId,
+ isWrongChain,
+ streams,
+ streamsLoading,
+ streamsError,
+ pools,
+ poolsLoading,
+ poolsError,
+ superTokenBalance,
+ balanceLoading,
+ balanceError,
+ supReserveBalance,
+ supReserveLoading,
+ supReserveError,
+ setStreamForm,
+ setStreamStatus,
+ setStreamError,
+ setStreamTxHash,
+ poolConnectStatus,
+ poolConnectError,
+ ],
+ )
+
+ const actions: StreamingWidgetAdapterActions = useMemo(
+ () => ({
+ connect,
+ switchChain,
+ refreshStreams: fetchStreams,
+ refreshPools: fetchPools,
+ refreshBalance: fetchBalance,
+ updateSetStreamForm,
+ submitSetStream,
+ resetSetStream,
+ connectToPool,
+ disconnectFromPool,
+ }),
+ [
+ connect,
+ switchChain,
+ fetchStreams,
+ fetchPools,
+ fetchBalance,
+ updateSetStreamForm,
+ submitSetStream,
+ resetSetStream,
+ connectToPool,
+ disconnectFromPool,
+ ],
+ )
+
+ return { state, actions }
+}
diff --git a/packages/streaming-widget/src/element.ts b/packages/streaming-widget/src/element.ts
new file mode 100644
index 0000000..483c55e
--- /dev/null
+++ b/packages/streaming-widget/src/element.ts
@@ -0,0 +1,25 @@
+import { createMiniAppElement } from '@goodwidget/embed'
+import { StreamingWidget } from './StreamingWidget'
+import type React from 'react'
+
+/**
+ * A Custom Element class wrapping the StreamingWidget React component.
+ *
+ * Register it with any tag name:
+ * customElements.define('gw-streaming', StreamingWidgetElement)
+ *
+ * Then use in HTML:
+ *
+ *
+ * Set the wallet provider via JS properties:
+ * const el = document.querySelector('gw-streaming')
+ * el.provider = window.ethereum
+ */
+export const StreamingWidgetElement = createMiniAppElement(
+ StreamingWidget as React.ComponentType>,
+ {
+ shadow: true,
+ defaultTheme: 'light',
+ events: [],
+ },
+)
diff --git a/packages/streaming-widget/src/index.ts b/packages/streaming-widget/src/index.ts
new file mode 100644
index 0000000..77266d2
--- /dev/null
+++ b/packages/streaming-widget/src/index.ts
@@ -0,0 +1,23 @@
+// Runtime contract types
+export type {
+ StreamingWidgetProps,
+ StreamingWidgetEnvironment,
+ StreamingWidgetTab,
+ StreamDirection,
+ StreamTimeUnit,
+ StreamListItem,
+ PoolMembershipItem,
+ SetStreamFormState,
+ WriteStatus,
+ StreamingWidgetAdapterState,
+ StreamingWidgetAdapterActions,
+ StreamingWidgetAdapterResult,
+} from './widgetRuntimeContract'
+export { STREAMING_CHAINS } from './widgetRuntimeContract'
+
+// Adapter hook
+export { useStreamingAdapter } from './adapter'
+export type { UseStreamingAdapterOptions } from './adapter'
+
+// Widget component
+export { StreamingWidget } from './StreamingWidget'
diff --git a/packages/streaming-widget/src/register.ts b/packages/streaming-widget/src/register.ts
new file mode 100644
index 0000000..46536fc
--- /dev/null
+++ b/packages/streaming-widget/src/register.ts
@@ -0,0 +1,27 @@
+import { StreamingWidgetElement } from './element'
+
+const DEFAULT_TAG_NAME = 'gw-streaming'
+
+/**
+ * Register the custom element.
+ *
+ * Call once at the top of your app or in a