diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..7ba7baa --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Gitleaks ignore file +# Used to ignore false positives (like contract addresses flagging as generic API keys) + +# Problematic commits containing the G$ contract address in documentation +3d5802f57bbb5e443526e614c36adb878d7cfa70 +ec1871fcd04e7fcc098f99cf8befe797b0503339 +7a3ce6e98211b40974f26b5e8654c8612ca204e3 +2e0353c7c7f5367a86847c21043c7b80a184e9c7 diff --git a/apps/demo-streaming-app/.env.example b/apps/demo-streaming-app/.env.example new file mode 100644 index 0000000..074cbf0 --- /dev/null +++ b/apps/demo-streaming-app/.env.example @@ -0,0 +1,2 @@ +# Environment variables for demo app +VITE_WALLETCONNECT_PROJECT_ID=your_project_id_here diff --git a/apps/demo-streaming-app/.gitignore b/apps/demo-streaming-app/.gitignore new file mode 100644 index 0000000..d0fc167 --- /dev/null +++ b/apps/demo-streaming-app/.gitignore @@ -0,0 +1,23 @@ +# Local environment variables +.env +.env.local +.env.*.local + +# Build output +dist +build + +# Dependencies +node_modules + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS files +.DS_Store + +# IDE +.vscode/ +.idea/ diff --git a/apps/demo-streaming-app/README.md b/apps/demo-streaming-app/README.md new file mode 100644 index 0000000..467b1a7 --- /dev/null +++ b/apps/demo-streaming-app/README.md @@ -0,0 +1,63 @@ +# GoodDollar Streaming SDK Demo + +A demonstration application showcasing the GoodDollar Superfluid Streaming SDK with the simplified API that automatically resolves G$ token addresses. + +## What This Demo Shows + +This app demonstrates the key improvement in the Streaming SDK: you no longer need to manually specify G$ token addresses. The SDK automatically resolves the correct token based on your selected environment. + +### Before (Old API) +```typescript +await sdk.createStream({ + receiver: '', + token: '', + flowRate +}) +``` + +### After (New Simplified API) +```typescript +const sdk = new StreamingSDK(publicClient, walletClient, { + environment: 'production' // Auto-resolves G$ token +}) + +await sdk.createStream({ + receiver: '', + // No token parameter needed! + flowRate +}) +``` + +## Features + +- **Environment Selector** - Switch between production, staging, and development +- **Wallet Connection** - Connect via WalletConnect to Celo or Base +- **Create Streams** - Create G$ streams with a simple form (no token address needed!) +- **View Active Streams** - See all your incoming and outgoing streams +- **Manage Streams** - Delete streams you've created +- **Visual API Examples** - See the actual code being used in real-time + +## Setup + +1. **Install Dependencies** + ```bash + yarn install + ``` + +2. **Configure Environment** + Create a `.env` file with your WalletConnect Project ID. + +3. **Build Dependencies** + ```bash + yarn build + ``` + +## Running the Demo + +```bash +cd apps/demo-streaming-app +yarn dev +``` + +Open the URL shown in your terminal to view the app. + diff --git a/apps/demo-streaming-app/eslint.config.js b/apps/demo-streaming-app/eslint.config.js new file mode 100644 index 0000000..85c3ed3 --- /dev/null +++ b/apps/demo-streaming-app/eslint.config.js @@ -0,0 +1,35 @@ +export default [ + { + ignores: ["dist/**", "node_modules/**", ".turbo/**"], + }, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: await import("@typescript-eslint/parser"), + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "@typescript-eslint": await import("@typescript-eslint/eslint-plugin"), + "react": await import("eslint-plugin-react"), + "react-hooks": await import("eslint-plugin-react-hooks"), + }, + rules: { + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + settings: { + react: { + version: "detect", + }, + }, + }, +]; diff --git a/apps/demo-streaming-app/index.html b/apps/demo-streaming-app/index.html new file mode 100644 index 0000000..e82c10c --- /dev/null +++ b/apps/demo-streaming-app/index.html @@ -0,0 +1,13 @@ + + + + + + + GoodDollar Streaming SDK Demo + + +
+ + + diff --git a/apps/demo-streaming-app/package.json b/apps/demo-streaming-app/package.json new file mode 100644 index 0000000..6efba53 --- /dev/null +++ b/apps/demo-streaming-app/package.json @@ -0,0 +1,46 @@ +{ + "name": "demo-streaming-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite --port 3001", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "check-types": "tsc --noEmit", + "format": "prettier --write ." + }, + "dependencies": { + "@goodsdks/react-hooks": "*", + "@goodsdks/streaming-sdk": "*", + "@reown/appkit": "^1.7.2", + "@reown/appkit-adapter-wagmi": "^1.7.2", + "@repo/eslint-config": "workspace:*", + "@tamagui/config": "^1.125.22", + "@tamagui/core": "^1.125.22", + "@tamagui/font-inter": "^1.125.22", + "@tamagui/vite-plugin": "^1.125.22", + "@tamagui/web": "^1.125.22", + "@tanstack/react-query": "^4.36.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tamagui": "^1.125.22", + "viem": "^1.21.4", + "wagmi": "^1.4.13" + }, + "devDependencies": { + "@tamagui/babel-plugin": "^1.125.22", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^8.57.1", + "eslint-config-prettier": "^8.10.0", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.2.0", + "prettier": "^3.5.3", + "typescript": "^5.8.2", + "vite": "6.3.5" + } +} diff --git a/apps/demo-streaming-app/src/App.tsx b/apps/demo-streaming-app/src/App.tsx new file mode 100644 index 0000000..755037f --- /dev/null +++ b/apps/demo-streaming-app/src/App.tsx @@ -0,0 +1,557 @@ +import React, { useEffect, useState } from "react" +import { + View, + Text, + YStack, + XStack, + Button, + ScrollView, + Separator, + Spinner, + Input, + TamaguiProvider, + createTamagui, +} from "tamagui" +import { config as tamaguiConfigBase } from "@tamagui/config/v3" +import { useAccount, usePublicClient, useSwitchChain } from "wagmi" +import { + useCreateStream, + useUpdateStream, + useDeleteStream, + useStreamList, + useGDAPools, + useSupReserves, + useConnectToPool, + useDisconnectFromPool, +} from "@goodsdks/react-hooks" +import { + calculateFlowRate, + formatFlowRate, + getG$Token, + getSUPToken, + SupportedChains, + type Environment, + type StreamInfo, + type GDAPool, + type SUPReserveLocker, + type TokenSymbol, +} from "@goodsdks/streaming-sdk" +import { parseEther, type Address } from "viem" + +const tamaguiConfig = createTamagui(tamaguiConfigBase) + +// mutation wrapper with error/success alerts +function runMutationWithAlerts( + mutate: ( + args: TArgs, + options?: { onSuccess?: (data: unknown) => void; onError?: (error: unknown) => void } + ) => void, + args: TArgs, + { onSuccess, successMessage }: { onSuccess?: (hash: string) => void; successMessage?: string } = {} +) { + try { + mutate(args, { + onSuccess: (data: unknown) => { + const hash = data as string + if (successMessage) { + alert(`${successMessage} Transaction: ${hash}`) + } + onSuccess?.(hash) + }, + onError: (error: unknown) => { + const err = error as Error + console.error(err) + alert(`Error: ${err.message}`) + }, + }) + } catch (error) { + const err = error as Error + console.error(err) + alert(`Error: ${err.message}`) + } +} + +// UI Components +const SectionCard: React.FC> = ({ + children, + gap = "$3", + bg = "white", + title, + borderColor = "#E2E8F0", +}) => ( + + {title && ( + + {title} + + )} + {children} + +) + +const OperationSection: React.FC<{ + title: string + buttonText: string + buttonColor: string + isLoading: boolean + onAction: (receiver: string, amount: string) => void + showAmount?: boolean + timeUnit?: "hour" | "day" | "month" + setTimeUnit?: (unit: "hour" | "day" | "month") => void + disabled?: boolean +}> = ({ + title, + buttonText, + buttonColor, + isLoading, + onAction, + showAmount = true, + timeUnit, + setTimeUnit, + disabled, +}) => { + const [receiver, setReceiver] = useState("") + const [amount, setAmount] = useState("10") + + return ( + + + {showAmount && ( + + + {setTimeUnit && ( + + {(["hour", "day", "month"] as const).map(unit => ( + + ))} + + )} + + )} + + + ) + } + +const ActiveStreamsList: React.FC<{ + streams: StreamInfo[] + isLoading: boolean + onRefresh: () => void +}> = ({ streams, isLoading, onRefresh }) => ( + + + + + {isLoading ? : (streams && streams.length > 0) ? ( + + {streams.map((s, i) => ( + + + To: + {s.receiver?.slice(0, 10)}...{s.receiver?.slice(-6)} + + + Flow Rate: + {formatFlowRate(s.flowRate, "month")} + + + ))} + + ) : No active streams found} + +) + +const GDAPoolsSection: React.FC<{ + pools: GDAPool[] + isLoading: boolean + poolAddress: string + setPoolAddress: (addr: string) => void + onConnect: () => void + onDisconnect: () => void + isConnecting: boolean + isDisconnecting: boolean +}> = ({ pools, isLoading, poolAddress, setPoolAddress, onConnect, onDisconnect, isConnecting, isDisconnecting }) => ( + + + + + + + {isLoading ? : (pools && pools.length > 0) ? ( + + {pools.slice(0, 5).map((p, i) => ( + setPoolAddress(p.id)} + hoverStyle={{ backgroundColor: "#EDF2F7" }} + cursor="pointer" + borderWidth={poolAddress === p.id ? 1 : 0} + borderColor="$blue10" + gap="$1" + > + {p.id.slice(0, 10)}... Admin: {p.admin?.slice(0, 6)} + Current Flow Rate: {formatFlowRate(p.flowRate, "month")} + + ))} + + ) : No pools found} + + +) + +// Main App +export default function App() { + const { address, isConnected } = useAccount() + const publicClient = usePublicClient() + const { switchChain } = useSwitchChain() + + const [environment, setEnvironment] = useState("production") + const [selectedToken, setSelectedToken] = useState("G$") + const [poolAddress, setPoolAddress] = useState("") + const [timeUnit, setTimeUnit] = useState<"month" | "day" | "hour">("month") + const apiKey = import.meta.env.VITE_GRAPH_API_KEY || "" + + const { mutate: createStream, isLoading: isCreating } = useCreateStream() + const { mutate: updateStream, isLoading: isUpdating } = useUpdateStream() + const { mutate: deleteStream, isLoading: isDeleting } = useDeleteStream() + const { mutate: connectToPool, isLoading: isConnecting } = useConnectToPool() + const { mutate: disconnectFromPool, isLoading: isDisconnecting } = useDisconnectFromPool() + + const { + data: streams, + isLoading: streamsLoading, + refetch: refetchStreams, + } = useStreamList({ + account: address as Address, + environment, + enabled: !!address, + }) + + const { data: pools, isLoading: poolsLoading } = useGDAPools({ + enabled: !!address, + }) + + const { data: supReserves, isLoading: supLoading } = useSupReserves({ + apiKey, + enabled: isConnected && environment === "production", + }) + + const chainId = publicClient?.chain?.id + + // Keep UI state consistent with network capabilities + useEffect(() => { + if (chainId === SupportedChains.BASE) { + if (selectedToken !== "SUP") setSelectedToken("SUP") + if (environment !== "production") setEnvironment("production") + } + if (chainId === SupportedChains.CELO) { + if (selectedToken !== "G$") setSelectedToken("G$") + } + }, [chainId, environment, selectedToken]) + + // Resolves address for display + const RESOLVED_TOKEN_ADDR = chainId + ? (selectedToken === "G$" ? getG$Token(chainId, environment) : getSUPToken(chainId, environment)) + : undefined + + const handleCreateStream = (receiver: string, amount: string) => { + if (!receiver || !amount) return alert("Please fill in all fields") + const flowRate = calculateFlowRate(parseEther(amount), timeUnit) + runMutationWithAlerts(createStream, { + receiver: receiver as Address, + environment, + flowRate, + token: selectedToken + }, { + onSuccess: () => refetchStreams(), + successMessage: "Stream created!" + }) + } + + const handleUpdateStream = (receiver: string, amount: string) => { + if (!receiver || !amount) return alert("Please fill in all fields") + const newFlowRate = calculateFlowRate(parseEther(amount), timeUnit) + runMutationWithAlerts(updateStream, { + receiver: receiver as Address, + environment, + newFlowRate, + token: selectedToken + }, { + onSuccess: () => refetchStreams(), + successMessage: "Stream updated!" + }) + } + + const handleDeleteStream = (receiver: string) => { + if (!receiver) return alert("Please provide receiver address") + runMutationWithAlerts(deleteStream, { + receiver: receiver as Address, + environment, + token: selectedToken + }, { + onSuccess: () => refetchStreams(), + successMessage: "Stream deleted!" + }) + } + + if (!isConnected) { + return ( + + + + Streaming SDK Demo + + Connect your wallet to start streaming GoodDollar (G$) on Celo or Base. + + + + + + ) + } + + return ( + + + + + + Streaming SDK + + + + {/* Network and Environment selection */} + + + + {publicClient?.chain?.name || "Unknown"} ({chainId}) + + + {[ + { id: SupportedChains.CELO, name: "Celo" }, + { id: SupportedChains.BASE, name: "Base" }, + ].map(c => ( + + ))} + + + + + + {(["G$", "SUP"] as const).map(tk => ( + // Token availability is chain-dependent: G$ on Celo, SUP on Base + // Keep UI explicit to avoid "Not available" confusion. + + ))} + + + {(["production", "staging", "development"] as const).map(env => ( + + ))} + + + Current {selectedToken}: {RESOLVED_TOKEN_ADDR ? `${RESOLVED_TOKEN_ADDR.slice(0, 10)}...` : "Not available"} + + + + + {chainId === SupportedChains.BASE && ( + + + Base Network Mode (SUP Only) + + + Streaming and GDA Pools are not yet configured for G$ on Base. This view is filtered to show **SUP Reserves** only. + + + )} + + {/* SDK Operations - Only on Celo */} + {chainId !== SupportedChains.BASE && ( + + + + + + + + + + + handleDeleteStream(r)} + showAmount={false} + disabled={!RESOLVED_TOKEN_ADDR} + /> + + )} + + + + {/* Data Displays */} + + {chainId !== SupportedChains.BASE && ( + <> + + refetchStreams()} /> + + + runMutationWithAlerts(connectToPool, { poolAddress: poolAddress as Address }, { successMessage: "Connected!" })} + onDisconnect={() => runMutationWithAlerts(disconnectFromPool, { poolAddress: poolAddress as Address }, { successMessage: "Disconnected!" })} + isConnecting={isConnecting} + isDisconnecting={isDisconnecting} + /> + + + )} + + + {chainId === SupportedChains.BASE && ( + + + (Only on Base mainnet) + + {supLoading ? : (supReserves && supReserves.length > 0) ? ( + + {supReserves.slice(0, 5).map((l: SUPReserveLocker, i: number) => ( + + + Locker: {l.id?.slice(0, 10)}... + Owner: {l.lockerOwner?.slice(0, 10)}... + + ID: {i + 1} + + ))} + + ) : ( + + No SUP lockers found or unsupported chain + + )} + + )} + + + + ) +} diff --git a/apps/demo-streaming-app/src/index.css b/apps/demo-streaming-app/src/index.css new file mode 100644 index 0000000..30e0bdf --- /dev/null +++ b/apps/demo-streaming-app/src/index.css @@ -0,0 +1,73 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: auto; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + margin: auto; + width: 100%; +} diff --git a/apps/demo-streaming-app/src/main.tsx b/apps/demo-streaming-app/src/main.tsx new file mode 100644 index 0000000..ead9bdb --- /dev/null +++ b/apps/demo-streaming-app/src/main.tsx @@ -0,0 +1,59 @@ +/// +import React from 'react' +import ReactDOM from 'react-dom/client' +import { http, WagmiProvider } from 'wagmi' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createAppKit } from '@reown/appkit/react' +import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' +import { celo, base } from '@reown/appkit/networks' +import App from './App' + +// wagmi configuration +const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || '71dd03d057d89d0af68a4c627ec59694' + +const metadata = { + name: 'GoodDollar Streaming SDK Demo', + description: 'Demo app for GoodDollar Superfluid Streaming SDK', + url: 'https://gooddollar.org', + icons: ['https://avatars.githubusercontent.com/u/42399395'] +} + +const networks = [celo, base] as [any, ...any[]] + +const wagmiAdapter = new WagmiAdapter({ + networks, + projectId, + ssr: true, + transports: { + [42220]: http('https://forno.celo.org'), + [8453]: http('https://mainnet.base.org'), + } +}) + +createAppKit({ + adapters: [wagmiAdapter], + networks: networks as [any, ...any[]], + projectId, + metadata, + allWallets: 'HIDE', + features: { + analytics: false, + allWallets: false, + onramp: false, + swaps: false, + email: false, + socials: [], + }, +}) + +const queryClient = new QueryClient() + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +) diff --git a/apps/demo-streaming-app/src/tamagui.config.ts b/apps/demo-streaming-app/src/tamagui.config.ts new file mode 100644 index 0000000..b0a9334 --- /dev/null +++ b/apps/demo-streaming-app/src/tamagui.config.ts @@ -0,0 +1,12 @@ +import { createTamagui } from 'tamagui' +import { config } from '@tamagui/config/v3' + +const tamaguiConfig = createTamagui(config) + +export default tamaguiConfig + +export type Conf = typeof tamaguiConfig + +declare module 'tamagui' { + interface TamaguiCustomConfig extends Conf { } +} diff --git a/apps/demo-streaming-app/tsconfig.json b/apps/demo-streaming-app/tsconfig.json new file mode 100644 index 0000000..e5ae377 --- /dev/null +++ b/apps/demo-streaming-app/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/apps/demo-streaming-app/tsconfig.node.json b/apps/demo-streaming-app/tsconfig.node.json new file mode 100644 index 0000000..b85dd47 --- /dev/null +++ b/apps/demo-streaming-app/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/apps/demo-streaming-app/vite.config.ts b/apps/demo-streaming-app/vite.config.ts new file mode 100644 index 0000000..07fe5b4 --- /dev/null +++ b/apps/demo-streaming-app/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + include: ['tamagui'], + }, +}) diff --git a/package.json b/package.json index 9b193af..ca3e20a 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "turbo build", "dev": "turbo dev", + "dev:streaming-demo": "turbo dev --filter=demo-streaming-app --filter=@goodsdks/react-hooks --filter=@goodsdks/streaming-sdk", "lint": "turbo lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md index afeeb4f..cd9e12b 100644 --- a/packages/react-hooks/README.md +++ b/packages/react-hooks/README.md @@ -66,6 +66,19 @@ export const App = () => ( - Builds on `useIdentitySDK` and returns a ready `ClaimSDK` once identity checks resolve. - Surfaces entitlement errors via the returned `error` string. +### Streaming Hooks + +- `useStreamList({ account, environment, enabled })` + - Fetches all active streams for an account (subgraph query is chain-based; `environment` only affects SDK token resolution for write operations). +- `useGDAPools({ enabled })` + - Lists all available distribution pools for the connected chain. +- `useSupReserves({ apiKey, enabled })` + - Fetches SUP reserve holdings. **Requires `apiKey`** for Base mainnet (decentralized subgraph). +- `useCreateStream()`, `useUpdateStream()`, `useDeleteStream()` + - Mutators for managing 1-to-1 streams. Supports `token` as `TokenSymbol` ('G$' | 'SUP') or `Address`. +- `useConnectToPool()`, `useDisconnectFromPool()` + - Mutators for GDA pool memberships. + Both hooks re-run whenever the connected wallet, public client, or environment changes. ## Demo & Further Reading diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 4f8525e..abccb4a 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -37,6 +37,8 @@ }, "dependencies": { "@goodsdks/citizen-sdk": "*", + "@goodsdks/streaming-sdk": "*", + "@tanstack/react-query": "^4.36.1", "lz-string": "^1.5.0", "react": "^19.1.1", "tsup": "^8.4.0" diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 35b68e6..193ff02 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -1 +1,2 @@ export * from "./citizen-sdk" +export * from "./streaming" diff --git a/packages/react-hooks/src/streaming/index.ts b/packages/react-hooks/src/streaming/index.ts new file mode 100644 index 0000000..954c4f1 --- /dev/null +++ b/packages/react-hooks/src/streaming/index.ts @@ -0,0 +1,305 @@ +import { useMemo } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { type Address, type Hash, type WalletClient } from "viem" +import { usePublicClient, useWalletClient } from "wagmi" +import { + StreamingSDK, + GdaSDK, + SubgraphClient, + SupportedChains, + type StreamInfo, + type GDAPool, + type PoolMembership, + type SUPReserveLocker, + type Environment, + type TokenSymbol, +} from "@goodsdks/streaming-sdk" + +/** + * Hook parameter interfaces + */ +export interface UseCreateStreamParams { + receiver: Address + token?: TokenSymbol | Address + flowRate: bigint + userData?: `0x${string}` + environment?: Environment +} + +export interface UseUpdateStreamParams { + receiver: Address + token?: TokenSymbol | Address + newFlowRate: bigint + userData?: `0x${string}` + environment?: Environment +} + +export interface UseDeleteStreamParams { + receiver: Address + token?: TokenSymbol | Address + userData?: `0x${string}` + environment?: Environment +} + +export interface UseStreamListParams { + account: Address + direction?: "incoming" | "outgoing" | "all" + environment?: Environment + enabled?: boolean +} + +export interface UseGDAPoolsParams { + enabled?: boolean +} + +export interface UsePoolMembershipsParams { + account: Address + enabled?: boolean +} + +export interface UseConnectToPoolParams { + poolAddress: Address + userData?: `0x${string}` +} + +export interface UseDisconnectFromPoolParams { + poolAddress: Address + userData?: `0x${string}` +} + +export interface UseSupReservesParams { + apiKey?: string + enabled?: boolean +} + +/** + * Internal helper to manage SDK instances by environment efficiently + */ +function useStreamingSdks(options?: { defaultToken?: TokenSymbol | Address }) { + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + + // Use a lazy getter to avoid creating SDKs for all environments at once + return useMemo(() => { + const cache = new Map() + + return { + get: (env: Environment): StreamingSDK | undefined => { + if (!publicClient) return undefined + if (cache.has(env)) return cache.get(env) + + try { + const sdk = new StreamingSDK( + publicClient, + walletClient ? (walletClient as WalletClient) : undefined, + { + environment: env, + defaultToken: options?.defaultToken + } + ) + cache.set(env, sdk) + return sdk + } catch (err) { + return undefined + } + } + } + }, [publicClient, walletClient, options?.defaultToken]) +} + +/** + * React Hooks for Superfluid operations + */ + +/** + * React Hooks for Creating Streams + */ +export function useCreateStream() { + const sdks = useStreamingSdks() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + receiver, + token, + flowRate, + userData = "0x", + environment = "production", + }: UseCreateStreamParams): Promise => { + const sdk = sdks.get(environment) + if (!sdk) throw new Error(`SDK not available for environment: ${environment}`) + return sdk.createStream({ receiver, token, flowRate, userData }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["streams"] }) + }, + }) +} + +export function useUpdateStream() { + const sdks = useStreamingSdks() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + receiver, + token, + newFlowRate, + userData = "0x", + environment = "production", + }: UseUpdateStreamParams): Promise => { + const sdk = sdks.get(environment) + if (!sdk) throw new Error(`SDK not available for environment: ${environment}`) + return sdk.updateStream({ receiver, token, newFlowRate, userData }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["streams"] }) + }, + }) +} + +export function useDeleteStream() { + const sdks = useStreamingSdks() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + receiver, + token, + environment = "production", + userData = "0x", + }: UseDeleteStreamParams): Promise => { + const sdk = sdks.get(environment) + if (!sdk) throw new Error(`SDK not available for environment: ${environment}`) + return sdk.deleteStream({ receiver, token, userData }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["streams"] }) + }, + }) +} + +export function useStreamList({ + account, + direction = "all", + environment = "production", + enabled = true, +}: UseStreamListParams) { + const sdks = useStreamingSdks() + const publicClient = usePublicClient() + + return useQuery({ + queryKey: ["streams", account, direction, environment, publicClient?.chain?.id], + queryFn: async () => { + const sdk = sdks.get(environment) + if (!sdk) throw new Error(`SDK not available for environment: ${environment}`) + return sdk.getActiveStreams({ account, direction }) + }, + enabled: enabled && !!account && !!publicClient, + }) +} + +export function useGDAPools({ + enabled = true, +}: UseGDAPoolsParams = {}) { + const publicClient = usePublicClient() + const sdk = useMemo(() => { + if (!publicClient) return null + try { + return new GdaSDK(publicClient, undefined, { chainId: publicClient.chain?.id }) + } catch (e) { + return null + } + }, [publicClient]) + + return useQuery({ + queryKey: ["gda-pools", publicClient?.chain?.id], + queryFn: async () => { + if (!sdk) throw new Error("Public client not available") + return sdk.getDistributionPools() + }, + enabled: enabled && !!publicClient, + }) +} + +export function usePoolMemberships({ + account, + enabled = true, +}: UsePoolMembershipsParams) { + const publicClient = usePublicClient() + const sdk = useMemo(() => { + if (!publicClient) return null + try { + return new GdaSDK(publicClient) + } catch (e) { + return null + } + }, [publicClient]) + + return useQuery({ + queryKey: ["gda-memberships", account, publicClient?.chain?.id], + queryFn: async () => { + if (!sdk) throw new Error("Public client not available") + return sdk.getPoolMemberships(account) + }, + enabled: enabled && !!publicClient && !!account, + }) +} + +export function useConnectToPool() { + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + poolAddress, + userData = "0x", + }: UseConnectToPoolParams): Promise => { + if (!publicClient) throw new Error("Public client not available") + if (!walletClient) throw new Error("Wallet client not available") + const sdk = new GdaSDK(publicClient, walletClient as WalletClient) + return sdk.connectToPool({ poolAddress, userData }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["gda-pools"] }) + queryClient.invalidateQueries({ queryKey: ["gda-memberships"] }) + }, + }) +} + +export function useDisconnectFromPool() { + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + poolAddress, + userData = "0x", + }: UseDisconnectFromPoolParams): Promise => { + if (!publicClient) throw new Error("Public client not available") + if (!walletClient) throw new Error("Wallet client not available") + const sdk = new GdaSDK(publicClient, walletClient as WalletClient) + return sdk.disconnectFromPool({ poolAddress, userData }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["gda-pools"] }) + queryClient.invalidateQueries({ queryKey: ["gda-memberships"] }) + }, + }) +} + +export function useSupReserves({ + apiKey, + enabled = true +}: UseSupReservesParams = {}) { + return useQuery({ + queryKey: ["sup-reserves", SupportedChains.BASE, apiKey], + queryFn: async () => { + const client = new SubgraphClient(SupportedChains.BASE, { apiKey }) + return client.querySUPReserves() + }, + enabled, + }) +} diff --git a/packages/streaming-sdk/README.md b/packages/streaming-sdk/README.md new file mode 100644 index 0000000..662ebc0 --- /dev/null +++ b/packages/streaming-sdk/README.md @@ -0,0 +1,153 @@ +# @goodsdks/streaming-sdk + +TypeScript SDK for Superfluid streams on Celo and Base, supporting G$ and SUP SuperTokens and GDA (General Distribution Agreement) pools. + +## Features + +- Auto-resolving token addresses (defaults to G$ on Celo, SUP on Base) +- Stream lifecycle: create, update, delete +- GDA pool connections +- Subgraph queries for balances and history +- Multi-token support: G$ (Celo), SUP (Base) +- Environment-based address resolution (production/staging/development) + +## Installation + +```bash +yarn add @goodsdks/streaming-sdk viem +``` + +## Quick Start + +```typescript +import { StreamingSDK, calculateFlowRate } from '@goodsdks/streaming-sdk' +import { createPublicClient, createWalletClient, http, parseEther } from 'viem' +import { celo } from 'viem/chains' + +// Defaults to chain default token with production environment (G$ on Celo, SUP on Base) +const sdk = new StreamingSDK(publicClient, walletClient) + +// Create stream (100 G$ per month) +const flowRate = calculateFlowRate(parseEther('100'), 'month') +await sdk.createStream({ + receiver: '0x...', + flowRate +}) +``` + +## Token Configuration + +Defaults to chain default token (resolved from environment + chainId): + +```typescript +// Uses chain default token (default) +const sdk = new StreamingSDK(publicClient, walletClient, { + environment: 'production' +}) + +// Use SUP token +const sdk = new StreamingSDK(publicClient, walletClient, { + defaultToken: 'SUP' +}) + +// Use custom token +const sdk = new StreamingSDK(publicClient, walletClient, { + defaultToken: '0x...' as Address +}) + +// Override per operation +await sdk.createStream({ + receiver: '0x...', + token: '0x...' as Address, + flowRate: 1000n +}) +``` + +## API Reference + +### StreamingSDK + +#### `createStream(params)` +Creates a new stream. + +```typescript +await sdk.createStream({ + receiver: '0x...', + flowRate: 1000n, + token?: 'G$' | 'SUP' | Address, // optional override + userData?: '0x', + onHash?: (hash) => console.log(hash) +}) +``` + +#### `updateStream(params)` +Updates stream flow rate. + +```typescript +await sdk.updateStream({ + receiver: '0x...', + newFlowRate: 2000n, + token?: 'G$' | 'SUP' | Address, + userData?: '0x', + onHash?: (hash) => console.log(hash) +}) +``` + +#### `deleteStream(params)` +Deletes a stream. + +```typescript +await sdk.deleteStream({ + receiver: '0x...', + token?: 'G$' | 'SUP' | Address, + userData?: '0x', + onHash?: (hash) => console.log(hash) +}) +``` + +#### `getActiveStreams(options)` +Returns active streams for an account. +```typescript +const streams = await sdk.getActiveStreams({ + account: '0x...', + direction: 'outgoing' // 'incoming' | 'outgoing' | 'all' +}) +``` + +#### `getSuperTokenBalance(account, token?)` +Returns balance for a SuperToken. Uses default token if `token` is omitted. +```typescript +const balance = await sdk.getSuperTokenBalance('0x...', 'SUP'?) +``` + +### GdaSDK + +#### `connectToPool(params)` +Connects to a distribution pool. +```typescript +const gda = new GdaSDK(publicClient, walletClient) +await gda.connectToPool({ + poolAddress: '0x...', + userData?: '0x', + onHash?: (hash) => console.log(hash) +}) +``` + +#### `disconnectFromPool(params)` +Disconnects from a distribution pool. +```typescript +await gda.disconnectFromPool({ + poolAddress: '0x...' +}) +``` + +## Supported Chains + +| Token | Chain | Chain ID | Environment | +|-------|-------|----------|-------------| +| G$ | Celo | 42220 | production, staging, development | +| SUP | Base | 8453 | production | + +## License + +MIT diff --git a/packages/streaming-sdk/TESTING.md b/packages/streaming-sdk/TESTING.md new file mode 100644 index 0000000..06d62e5 --- /dev/null +++ b/packages/streaming-sdk/TESTING.md @@ -0,0 +1,332 @@ +# Testing the Superfluid Streaming SDK + +## Quick Testing Options + +### 1. **Create a Test Script (Recommended for Quick Validation)** + +Create a simple test file to verify the SDK works: + +```bash +# Create test directory +mkdir -p test-sdk +cd test-sdk +npm init -y +npm install viem @goodsdks/streaming-sdk +``` + +**test-sdk/index.js:** +```javascript +import { StreamingSDK, calculateFlowRate, SupportedChains } from '@goodsdks/streaming-sdk' +import { createPublicClient, http } from 'viem' +import { celo } from 'viem/chains' + +async function testSDK() { + console.log('Testing Superfluid Streaming SDK...\n') + + // 1. Test SDK initialization + console.log('Step 1: Initialize SDK') + const publicClient = createPublicClient({ + chain: celo, + transport: http('https://forno.celo.org') + }) + + const sdk = new StreamingSDK(publicClient, undefined, { + chainId: SupportedChains.CELO, + environment: 'production' + }) + console.log(' SDK initialized successfully\n') + + // 2. Test flow rate calculation + console.log('Step 2: Test flow rate utilities') + const flowRate = calculateFlowRate(BigInt('100000000000000000000'), 'month') // 100 tokens/month + console.log(` Flow rate: ${flowRate.toString()} wei/second\n`) + + // 3. Test subgraph queries + console.log('Step 3: Query active streams') + try { + const streams = await sdk.getActiveStreams({ + account: '0x0000000000000000000000000000000000000001', + direction: 'all', + }) + console.log(` Found ${streams.length} active streams\n`) + } catch (error) { + console.log(` Query executed (account may not exist): ${error.message}\n`) + } + + // 4. Test balance query + console.log('Step 4: Query SuperToken balance') + try { + const balance = await sdk.getSuperTokenBalance('0x0000000000000000000000000000000000000001') + console.log(` Balance: ${balance.toString()} wei\n`) + } catch (error) { + console.log(` Query executed: ${error.message}\n`) + } + + console.log('All tests completed') +} + +testSDK().catch(console.error) +``` + +Run with: +```bash +node index.js +``` + +--- + +### 2. **Test in an Existing App (Integration Testing)** + +Add to your existing GoodDollar app: + +```bash +cd apps/demo-identity-app # or any existing app +yarn add @goodsdks/streaming-sdk +``` + +**Example usage in a React component:** +```typescript +import { usePublicClient, useWalletClient } from 'wagmi' +import { StreamingSDK, calculateFlowRate } from '@goodsdks/streaming-sdk' +import { parseEther } from 'viem' + +function StreamingDemo() { + const publicClient = usePublicClient() + const { data: walletClient } = useWalletClient() + + const createStream = async () => { + if (!publicClient || !walletClient) return + + const sdk = new StreamingSDK(publicClient, walletClient, { + environment: 'production' + }) + + const flowRate = calculateFlowRate(parseEther('100'), 'month') + + const hash = await sdk.createStream({ + receiver: '0x...', + token: '', // replace with your local/testnet token address + flowRate, + onHash: (hash) => console.log('Transaction:', hash) + }) + + console.log('Stream created:', hash) + } + + return +} +``` + +--- + +### 3. **Test Subgraph Queries (No Wallet Needed)** + +You can test subgraph functionality without a wallet: + +```javascript +import { SubgraphClient, SupportedChains } from '@goodsdks/streaming-sdk' + +async function testSubgraph() { + const client = new SubgraphClient(SupportedChains.CELO) + + // Test querying streams for a known address + const streams = await client.queryStreams({ + account: '0x...', // Use a real address with streams + direction: 'all' + }) + + console.log('Streams found:', streams) + + // Test querying pools + const pools = await client.queryPools() + console.log('GDA Pools:', pools) +} + +testSubgraph() +``` + +--- + +### 4. **Test with Superfluid Subgraph Playground** + +Visit the Superfluid subgraph explorer to verify queries work: + +**Celo Subgraph:** +https://api.thegraph.com/subgraphs/id/6dRuPxMvaJAp32hvcTsYbAya69A4t1KUHh2EnV3YQeXU + +Test this query: +```graphql +{ + streams(where: { currentFlowRate_gt: "0" }, first: 5) { + id + sender + receiver + currentFlowRate + token { + id + symbol + } + } +} +``` + +--- + +### 5. **Manual Testing Checklist** + +#### Build Verification +```bash +# From repo root +yarn build +# Should complete without errors +``` + +#### Type Checking +```bash +yarn tsc --noEmit --project packages/streaming-sdk/tsconfig.json +# Should show no errors +``` + +#### Import Test +```bash +node -e "import('@goodsdks/streaming-sdk').then(m => console.log(Object.keys(m)))" +# Should show exported members +``` + +#### Package Exports +Check that all exports are accessible: +```javascript +import { + StreamingSDK, + GdaSDK, + SubgraphClient, + calculateFlowRate, + SupportedChains, + // ... etc +} from '@goodsdks/streaming-sdk' + +console.log('All exports loaded successfully') +``` + +--- + + +--- + +### 7. **React Hooks Testing** + +Test the React hooks in a component: + +```typescript +import { useCreateStream, useStreamList } from '@goodsdks/react-hooks' +import { useAccount } from 'wagmi' + +function StreamingComponent() { + const { address } = useAccount() + const { data: streams, isLoading } = useStreamList({ + account: address!, + enabled: !!address + }) + + const { mutate: createStream } = useCreateStream() + + return ( +
+

Streams: {streams?.length ?? 0}

+ +
+ ) +} +``` + +--- + +## Recommended Testing Flow + +### Phase 1: Local Verification (5 minutes) +1. Run `yarn build` - verify no errors +2. Run type check - verify no type errors +3. Test imports in Node REPL + +### Phase 2: Subgraph Testing (10 minutes) +1. Test SubgraphClient with real addresses +2. Verify queries return expected data +3. Test on Superfluid playground + +### Phase 3: Integration Testing (30 minutes) +1. Add to existing app +2. Test read operations (no wallet needed) +3. Test with wallet on a supported network +4. Create a test stream with small amount + +### Phase 4: Production Testing (Careful!) +1. Test on mainnet with very small amounts (0.01 G$) +2. Verify transaction on block explorer +3. Confirm stream appears in Superfluid dashboard + +--- + +## Common Issues & Solutions + +### Issue: "Module not found" +**Solution:** Run `yarn install` from repo root + +### Issue: "Chain not supported" +**Solution:** Ensure you're using Celo (42220) or Base (8453) + +### Issue: "Wallet client not initialized" +**Solution:** Pass walletClient to SDK constructor for write operations + +### Issue: Subgraph query fails +**Solution:** Check network connectivity and subgraph endpoint + +--- + +## Quick Validation Script + +Save this as `validate-sdk.sh`: + +```bash +#!/bin/bash +echo "Validating Superfluid Streaming SDK..." + +# Build +echo "1. Building packages..." +yarn build > /dev/null 2>&1 +if [ $? -eq 0 ]; then + echo " Build successful" +else + echo " Build failed" + exit 1 +fi + +# Type check +echo "2. Type checking..." +yarn tsc --noEmit --project packages/streaming-sdk/tsconfig.json > /dev/null 2>&1 +if [ $? -eq 0 ]; then + echo " Type check passed" +else + echo " Type check failed" + exit 1 +fi + +# Check exports +echo "3. Checking exports..." +node -e "import('@goodsdks/streaming-sdk').then(() => console.log(' Exports valid'))" 2>/dev/null + +echo "" +echo "SDK validation complete" +``` + +Run with: +```bash +chmod +x validate-sdk.sh +./validate-sdk.sh +``` + diff --git a/packages/streaming-sdk/package.json b/packages/streaming-sdk/package.json new file mode 100644 index 0000000..d77a244 --- /dev/null +++ b/packages/streaming-sdk/package.json @@ -0,0 +1,48 @@ +{ + "name": "@goodsdks/streaming-sdk", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsup --clean", + "dev": "tsc --watch", + "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage", + "lint": "echo 'No linting configured for SDK packages'", + "check-types": "tsc --noEmit" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/node": "latest", + "@vitest/coverage-v8": "^4.0.18", + "typescript": "latest", + "viem": "latest", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "viem": "*" + }, + "dependencies": { + "@sfpro/sdk": "^0.1.0", + "graphql": "^16.9.0", + "graphql-request": "^7.1.2", + "tsup": "^8.3.5" + } +} diff --git a/packages/streaming-sdk/src/constants.ts b/packages/streaming-sdk/src/constants.ts new file mode 100644 index 0000000..ee572c9 --- /dev/null +++ b/packages/streaming-sdk/src/constants.ts @@ -0,0 +1,92 @@ +import { Address } from "viem" +import { Environment } from "./types" +import { cfaForwarderAddress, gdaForwarderAddress } from "@sfpro/sdk/abi" + +// Network definitions +export enum SupportedChains { + CELO = 42220, + BASE = 8453, +} + +export type SupportedChainId = SupportedChains + +/** + * Resolves G$ SuperToken address for chain and environment. + * + * Available: Celo (42220) on production, staging, development. + * @returns Address or undefined if not configured + */ +export function getG$Token( + chainId: number, + env: Environment = 'production' +): Address | undefined { + const addresses: Record>> = { + production: { + [SupportedChains.CELO]: "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A", + }, + staging: { + [SupportedChains.CELO]: "0x61FA0fB802fd8345C06da558240E0651886fec69", + }, + development: { + [SupportedChains.CELO]: "0xFa51eFDc0910CCdA91732e6806912Fa12e2FD475", + }, + } + + return addresses[env]?.[chainId] +} + +/** + * Resolves SUP SuperToken address for chain and environment. + * + * Available: Base (8453) production. + * @returns Address or undefined if not configured + */ +export function getSUPToken( + chainId: number, + env: Environment = 'production' +): Address | undefined { + const addresses: Record>> = { + production: { + [SupportedChains.BASE]: "0xa69f80524381275A7fFdb3AE01c54150644c8792", + }, + staging: {}, + development: {}, + } + + return addresses[env]?.[chainId] +} + +// Protocol indexers +export const SUBGRAPH_URLS: Record = { + [SupportedChains.CELO]: "https://celo-mainnet.subgraph.x.superfluid.dev/", + [SupportedChains.BASE]: "https://base-mainnet.subgraph.x.superfluid.dev/", + supReserve: + "https://gateway.thegraph.com/api/subgraphs/id/6dRuPxMvaJAp32hvcTsYbAya69A4t1KUHh2EnV3YQeXU", +} + +// Standard protocol interfaces +export const CFA_FORWARDER_ADDRESSES = cfaForwarderAddress +export const GDA_FORWARDER_ADDRESSES = gdaForwarderAddress + +// Metadata for frontend integration +export interface ChainConfig { + id: SupportedChains + name: string + rpcUrls: string[] + explorer: string +} + +export const CHAIN_CONFIGS: Record = { + [SupportedChains.CELO]: { + id: SupportedChains.CELO, + name: "Celo", + rpcUrls: ["https://forno.celo.org"], + explorer: "https://celoscan.io", + }, + [SupportedChains.BASE]: { + id: SupportedChains.BASE, + name: "Base", + rpcUrls: ["https://mainnet.base.org"], + explorer: "https://basescan.org", + }, +} diff --git a/packages/streaming-sdk/src/gda-sdk.ts b/packages/streaming-sdk/src/gda-sdk.ts new file mode 100644 index 0000000..66ecc8e --- /dev/null +++ b/packages/streaming-sdk/src/gda-sdk.ts @@ -0,0 +1,176 @@ +import { + Address, + Hash, + PublicClient, + WalletClient, + type SimulateContractParameters, +} from "viem" +import { gdaForwarderAbi } from "@sfpro/sdk/abi" +import { + GDAPool, + PoolMembership, + ConnectToPoolParams, + DisconnectFromPoolParams, + TokenSymbol, + Environment, + StreamingSDKOptions, +} from "./types" +import { validateChain } from "./utils" +import { SupportedChains, GDA_FORWARDER_ADDRESSES, getG$Token, getSUPToken } from "./constants" +import { SubgraphClient } from "./subgraph/client" + +export class GdaSDK { + private publicClient: PublicClient + private walletClient: WalletClient | null = null + private chainId: SupportedChains + private subgraphClient: SubgraphClient + private gdaForwarder: Address + private defaultToken: Address | undefined + private environment: Environment + + constructor( + publicClient: PublicClient, + walletClient?: WalletClient, + options?: StreamingSDKOptions, + ) { + if (!publicClient) { + throw new Error("Public client is required") + } + + this.publicClient = publicClient + this.chainId = validateChain( + options?.chainId ?? publicClient.chain?.id, + ) + this.environment = options?.environment ?? "production" + + // Protocol address from sfpro map + this.gdaForwarder = (GDA_FORWARDER_ADDRESSES as Record)[this.chainId] + + if (!this.gdaForwarder || this.gdaForwarder === "0x0000000000000000000000000000000000000000") { + throw new Error(`GDA Forwarder address not found or invalid for chain ID: ${this.chainId}`) + } + + this.defaultToken = this.resolveTokenSymbol(options?.defaultToken) + + if (walletClient) { + this.setWalletClient(walletClient) + } + + this.subgraphClient = new SubgraphClient(this.chainId, { + apiKey: options?.apiKey, + }) + } + + setWalletClient(walletClient: WalletClient) { + const chainId = validateChain(walletClient.chain?.id) + if (chainId !== this.chainId) { + throw new Error( + `Wallet client chain (${chainId}) does not match SDK chain (${this.chainId})`, + ) + } + this.walletClient = walletClient + } + + // Resolves symbol or address to concrete token address + private resolveTokenSymbol(token?: TokenSymbol | Address): Address | undefined { + if (!token) { + return this.chainId === SupportedChains.BASE + ? getSUPToken(this.chainId, this.environment) + : getG$Token(this.chainId, this.environment) + } + if (token === "G$") return getG$Token(this.chainId, this.environment) + if (token === "SUP") return getSUPToken(this.chainId, this.environment) + return token as Address + } + + async connectToPool(params: ConnectToPoolParams): Promise { + const { poolAddress, userData = "0x", onHash } = params + + return this.submitAndWait( + { + address: this.gdaForwarder, + abi: gdaForwarderAbi, + functionName: "connectPool", + args: [poolAddress, userData], + }, + onHash, + ) + } + + async disconnectFromPool(params: DisconnectFromPoolParams): Promise { + const { poolAddress, userData = "0x", onHash } = params + + return this.submitAndWait( + { + address: this.gdaForwarder, + abi: gdaForwarderAbi, + functionName: "disconnectPool", + args: [poolAddress, userData], + }, + onHash, + ) + } + + async getDistributionPools(options: { first?: number; skip?: number } = {}): Promise { + return this.subgraphClient.queryPools(options) + } + + async getPoolMemberships(account: Address): Promise { + return this.subgraphClient.queryPoolMemberships(account) + } + + async getPoolDetails(poolId: Address): Promise { + const pools = await this.getDistributionPools() + return pools.find((p) => p.id.toLowerCase() === poolId.toLowerCase()) ?? null + } + + async querySUPReserves() { + return this.subgraphClient.querySUPReserves() + } + + /** + * Submit transaction and wait for receipt + */ + private async submitAndWait( + simulateParams: SimulateContractParameters, + onHash?: (hash: Hash) => void, + ): Promise { + if (!this.walletClient) { + throw new Error("Wallet client not initialized") + } + + const account = await this.getAccount() + + const { request } = await this.publicClient.simulateContract({ + account, + ...simulateParams, + }) + + const hash = await this.walletClient.writeContract(request) + + if (onHash) { + onHash(hash) + } + + await this.publicClient.waitForTransactionReceipt({ hash }) + + return hash + } + + /** + * Get current account address from wallet client + */ + private async getAccount(): Promise
{ + if (!this.walletClient) { + throw new Error("Wallet client not initialized") + } + + const [account] = await this.walletClient.getAddresses() + + if (!account) { + throw new Error("No account found in wallet client") + } + + return account + } +} diff --git a/packages/streaming-sdk/src/index.ts b/packages/streaming-sdk/src/index.ts new file mode 100644 index 0000000..610615d --- /dev/null +++ b/packages/streaming-sdk/src/index.ts @@ -0,0 +1,32 @@ +// Core SDK classes +export { StreamingSDK } from "./streaming-sdk" +export { GdaSDK } from "./gda-sdk" +export { SubgraphClient } from "./subgraph/client" + +// Types +export * from "./types" + +// Constants +export { + SupportedChains, + getG$Token, + getSUPToken, + SUBGRAPH_URLS, + CHAIN_CONFIGS, +} from "./constants" + +// Utilities +export { + calculateFlowRate, + calculateStreamedAmount, + formatFlowRate, + flowRateFromAmount, + type TimeUnit, + isSupportedChain, + validateChain, + getSuperTokenAddress, + getSuperTokenAddressSafe, + getSuperTokenAddressForSymbol, + getSuperTokenAddressForSymbolSafe, + getChainConfig, +} from "./utils" diff --git a/packages/streaming-sdk/src/sdk.test.ts b/packages/streaming-sdk/src/sdk.test.ts new file mode 100644 index 0000000..f8fcc61 --- /dev/null +++ b/packages/streaming-sdk/src/sdk.test.ts @@ -0,0 +1,525 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Address, parseEther } from "viem" +import { + StreamingSDK, + GdaSDK, + SupportedChains, + isSupportedChain, + validateChain, + calculateFlowRate, + formatFlowRate, + flowRateFromAmount, + getG$Token, + getSUPToken, +} from "./index" + +/** + * --- MOCKS --- + */ + +const createMockPublicClient = (chainId: number = SupportedChains.CELO) => ({ + chain: { id: chainId, name: "Celo" }, + simulateContract: vi.fn().mockResolvedValue({ request: {} }), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), +} as any) + +const createMockWalletClient = (chainId: number = SupportedChains.CELO) => ({ + chain: { id: chainId }, + getAddresses: vi.fn().mockResolvedValue(["0x0000000000000000000000000000000000000001"]), + writeContract: vi.fn().mockResolvedValue("0xhash"), +} as any) + +const TEST_SUPERTOKEN = getG$Token(SupportedChains.CELO) as Address + +/** + * --- UTILS TESTS --- + */ + +describe("Utilities", () => { + describe("isSupportedChain", () => { + it("should return true for SupportedChains", () => { + expect(isSupportedChain(SupportedChains.CELO)).toBe(true) + expect(isSupportedChain(SupportedChains.BASE)).toBe(true) + }) + it("should return false for unsupported chains", () => { + expect(isSupportedChain(1)).toBe(false) + }) + }) + + describe("validateChain", () => { + it("should return chainId for supported chains", () => { + expect(validateChain(SupportedChains.CELO)).toBe(SupportedChains.CELO) + }) + it("should throw for unsupported chains", () => { + expect(() => validateChain(1)).toThrow("Unsupported chain ID") + }) + }) + + describe("Flow Rate Calculation", () => { + it("should calculate flow rate correctly", () => { + const amount = parseEther("100") + const month = 2592000 + const expected = amount / BigInt(month) + expect(calculateFlowRate(amount, "month")).toBe(expected) + }) + + it("should format flow rate correctly", () => { + const flowRate = parseEther("1") / BigInt(3600) + const formatted = formatFlowRate(flowRate, "hour") + expect(formatted).toContain("tokens/hour") + }) + + it("should derive flow rate from amount string", () => { + const flowRate = flowRateFromAmount("100", "month") + expect(flowRate).toBe(parseEther("100") / BigInt(2592000)) + }) + }) +}) + +/** + * --- SDK TESTS --- + */ + +describe("StreamingSDK", () => { + let publicClient: any + let walletClient: any + + beforeEach(() => { + publicClient = createMockPublicClient() + walletClient = createMockWalletClient() + }) + + it("should initialize with public client", () => { + const sdk = new StreamingSDK(publicClient) + expect(sdk).toBeDefined() + }) + + it("should create a stream", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + const hash = await sdk.createStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + flowRate: BigInt(100), + userData: "0x1234", + }) + + expect(hash).toBe("0xhash") + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([ + TEST_SUPERTOKEN, + "0x0000000000000000000000000000000000000001", + "0xreceiver", + BigInt(100), + "0x1234", + ]), + }) + ) + }) + + it("should delete a stream", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + const hash = await sdk.deleteStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + userData: "0xabcd" as `0x${string}`, + }) + expect(hash).toBe("0xhash") + }) + + describe("Token Auto-Resolution", () => { + it("should auto-resolve G$ token for Celo production", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + // No token provided + const hash = await sdk.createStream({ + receiver: "0xreceiver" as Address, + flowRate: BigInt(100), + }) + + expect(hash).toBe("0xhash") + // Verify simulateContract was called with TEST_SUPERTOKEN + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([TEST_SUPERTOKEN]) + }) + ) + }) + + it("should allow overriding auto-resolved token", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + const customToken = "0xcustom" as Address + const hash = await sdk.createStream({ + receiver: "0xreceiver" as Address, + token: customToken, + flowRate: BigInt(100), + }) + + expect(hash).toBe("0xhash") + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([customToken]) + }) + ) + }) + + }) + + describe("defaultToken Option", () => { + it("should default to G$ when no defaultToken is specified", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + await sdk.createStream({ receiver: "0xreceiver" as Address, flowRate: BigInt(100) }) + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([TEST_SUPERTOKEN]) + }) + ) + }) + + it("should use G$ address when defaultToken is 'G$'", async () => { + const sdk = new StreamingSDK(publicClient, walletClient, { defaultToken: "G$" }) + await sdk.createStream({ receiver: "0xreceiver" as Address, flowRate: BigInt(100) }) + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([TEST_SUPERTOKEN]) + }) + ) + }) + + it("should use raw address when defaultToken is an Address", async () => { + const customToken = "0xcafe000000000000000000000000000000000001" as Address + const sdk = new StreamingSDK(publicClient, walletClient, { defaultToken: customToken }) + await sdk.createStream({ receiver: "0xreceiver" as Address, flowRate: BigInt(100) }) + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([customToken]) + }) + ) + }) + + it("should resolve SUP token for Base when defaultToken is 'SUP'", () => { + const basePublicClient = createMockPublicClient(SupportedChains.BASE) + const baseWalletClient = createMockWalletClient(SupportedChains.BASE) + const sdk = new StreamingSDK(basePublicClient, baseWalletClient, { defaultToken: "SUP" }) + expect(sdk).toBeDefined() + + const supAddr = getSUPToken(SupportedChains.BASE, "production") + expect(supAddr).toBe("0xa69f80524381275A7fFdb3AE01c54150644c8792") + }) + + it("should allow per-call token to override defaultToken", async () => { + const overrideToken = "0xoverride000000000000000000000000000000001" as Address + const sdk = new StreamingSDK(publicClient, walletClient, { defaultToken: "G$" }) + await sdk.createStream({ receiver: "0xreceiver" as Address, flowRate: BigInt(100), token: overrideToken }) + expect(publicClient.simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([overrideToken]) + }) + ) + }) + + it("should resolve balance for defaultToken when symbol given to getSuperTokenBalance", async () => { + const sdk = new StreamingSDK(publicClient, undefined, { defaultToken: "G$" }) + const mockBalances = [{ token: TEST_SUPERTOKEN, balance: BigInt(500) }] + vi.spyOn(sdk.getSubgraphClient(), "queryBalances").mockResolvedValue(mockBalances as any) + + const balance = await sdk.getSuperTokenBalance("0xaccount" as Address) + expect(balance).toBe(BigInt(500)) + }) + + it("should resolve balance for SUP when explicitly requested via symbol", async () => { + const basePublicClient = createMockPublicClient(SupportedChains.BASE) + const sdk = new StreamingSDK(basePublicClient) + const SUP_ADDR = getSUPToken(SupportedChains.BASE) as Address + const mockBalances = [{ token: SUP_ADDR, balance: BigInt(1000) }] + vi.spyOn(sdk.getSubgraphClient(), "queryBalances").mockResolvedValue(mockBalances as any) + + const balance = await sdk.getSuperTokenBalance("0xaccount" as Address, "SUP") + expect(balance).toBe(BigInt(1000)) + }) + + }) +}) + +describe("GdaSDK", () => { + let publicClient: any + let walletClient: any + + beforeEach(() => { + publicClient = createMockPublicClient() + walletClient = createMockWalletClient() + }) + + it("should initialize GdaSDK", () => { + const sdk = new GdaSDK(publicClient) + expect(sdk).toBeDefined() + }) + + it("should connect to pool", async () => { + const sdk = new GdaSDK(publicClient, walletClient) + const hash = await sdk.connectToPool({ + poolAddress: "0xpool" as Address, + }) + expect(hash).toBe("0xhash") + }) + + it("should fetch distribution pools via subgraph", async () => { + const sdk = new GdaSDK(publicClient) + const mockPools = [{ id: "0xpool", token: "0xtoken", totalUnits: BigInt(0), flowRate: BigInt(0), admin: "0xadmin" }] + + // Mocking private subgraph client response + vi.spyOn(sdk as any, "getDistributionPools").mockResolvedValue(mockPools) + + const pools = await sdk.getDistributionPools() + expect(pools).toEqual(mockPools) + }) +}) + +/** + * --- ERROR HANDLING TESTS --- + */ + +describe("Error Handling", () => { + let publicClient: any + let walletClient: any + + beforeEach(() => { + publicClient = createMockPublicClient() + walletClient = createMockWalletClient() + }) + + describe("StreamingSDK", () => { + it("should throw when public client is missing", () => { + expect(() => new StreamingSDK(null as any)).toThrow("Public client is required") + }) + + it("should throw when chain is unsupported", () => { + const mockClient = createMockPublicClient(123) + expect(() => new StreamingSDK(mockClient)).toThrow("Unsupported chain ID") + }) + + it("should throw when wallet not initialized for write operations", async () => { + const sdk = new StreamingSDK(publicClient) // No wallet client + await expect( + sdk.createStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + flowRate: BigInt(100), + }) + ).rejects.toThrow("Wallet client not initialized") + }) + + it("should throw for invalid flow rate (zero)", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + await expect( + sdk.createStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + flowRate: BigInt(0), + }) + ).rejects.toThrow("Flow rate must be greater than zero") + }) + + it("should throw for negative flow rate", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + await expect( + sdk.updateStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + newFlowRate: BigInt(-100), + }) + ).rejects.toThrow("newFlowRate must be a positive non-zero value") + }) + + it("should throw when wallet chain doesn't match SDK chain", () => { + const misMatchedWallet = createMockWalletClient(SupportedChains.BASE) // Base + const sdk = new StreamingSDK(publicClient, undefined, { chainId: SupportedChains.CELO }) + expect(() => sdk.setWalletClient(misMatchedWallet)).toThrow( + "does not match SDK chain" + ) + }) + + it("should validate unsupported chain on initialization", () => { + expect(() => new StreamingSDK(publicClient, walletClient, { chainId: 1 })).toThrow( + "Unsupported chain ID" + ) + }) + + it("should throw when token cannot be resolved (Base with G$ symbol)", async () => { + const basePublicClient = createMockPublicClient(SupportedChains.BASE) + const sdk = new StreamingSDK(basePublicClient) + await expect( + sdk.createStream({ + receiver: "0xreceiver" as Address, + flowRate: BigInt(100), + token: "G$" + }) + ).rejects.toThrow("Token address not available") + }) + }) + + describe("GdaSDK", () => { + it("should throw when public client is missing", () => { + expect(() => new GdaSDK(null as any)).toThrow("Public client is required") + }) + + it("should throw when wallet chain doesn't match", () => { + const misMatchedWallet = createMockWalletClient(8453) // Base + const celoClient = createMockPublicClient(SupportedChains.CELO) + const sdk = new GdaSDK(celoClient, undefined) + expect(() => sdk.setWalletClient(misMatchedWallet)).toThrow( + "does not match SDK chain" + ) + }) + }) +}) + +/** + * --- EDGE CASES & UTILITY TESTS --- + */ + +describe("Edge Cases & Utilities", () => { + describe("Chain Configuration", () => { + it("should support all Celo chains", () => { + expect(isSupportedChain(SupportedChains.CELO)).toBe(true) + }) + + it("should support all Base chains", () => { + expect(isSupportedChain(SupportedChains.BASE)).toBe(true) + }) + + it("should reject other chains", () => { + expect(isSupportedChain(1)).toBe(false) + expect(isSupportedChain(137)).toBe(false) + expect(isSupportedChain(undefined)).toBe(false) + }) + }) + + describe("Flow Rate Utilities", () => { + it("should calculate flow rate for all time units", () => { + const amount = parseEther("1") + expect(calculateFlowRate(amount, "second")).toBe(amount) + expect(calculateFlowRate(amount, "minute")).toBe(amount / BigInt(60)) + expect(calculateFlowRate(amount, "hour")).toBe(amount / BigInt(3600)) + expect(calculateFlowRate(amount, "day")).toBe(amount / BigInt(86400)) + expect(calculateFlowRate(amount, "week")).toBe(amount / BigInt(604800)) + expect(calculateFlowRate(amount, "year")).toBe(amount / BigInt(31536000)) + }) + + it("should handle small amounts correctly", () => { + const smallAmount = BigInt(1) + const flowRate = calculateFlowRate(smallAmount, "month") + expect(flowRate).toBe(BigInt(0)) // 1 wei / 2592000 seconds = 0 + }) + + it("should format flow rate with precision", () => { + const flowRate = parseEther("1") / BigInt(3600) // 1 token per hour + const formatted = formatFlowRate(flowRate, "hour") + expect(formatted).toMatch(/tokens\/hour/) + expect(formatted).toContain("hour") + }) + + it("should calculate streamed amount correctly", () => { + const flowRate = parseEther("100") / BigInt(2592000) // 100 tokens/month + const secondsInDay = BigInt(86400) + const streamedInDay = flowRate * secondsInDay + + expect(streamedInDay).toBeGreaterThan(BigInt(0)) + expect(streamedInDay).toBeLessThan(parseEther("100")) + }) + }) + + describe("Environment Configuration", () => { + it("should use production environment by default", () => { + const publicClient = createMockPublicClient() + const sdk = new StreamingSDK(publicClient) + expect(sdk).toBeDefined() + }) + + it("should support staging environment", () => { + const publicClient = createMockPublicClient() + const sdk = new StreamingSDK(publicClient, undefined, { environment: "staging" }) + expect(sdk).toBeDefined() + }) + + it("should support development environment", () => { + const publicClient = createMockPublicClient() + const sdk = new StreamingSDK(publicClient, undefined, { environment: "development" }) + expect(sdk).toBeDefined() + }) + }) + + describe("StreamingSDK Methods", () => { + let publicClient: any + let walletClient: any + + beforeEach(() => { + publicClient = createMockPublicClient() + walletClient = createMockWalletClient() + }) + + it("should update stream with new flow rate", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + const hash = await sdk.updateStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + newFlowRate: BigInt(250), + }) + expect(hash).toBe("0xhash") + expect(publicClient.simulateContract).toHaveBeenCalled() + }) + + it("should call onHash callback when provided", async () => { + const sdk = new StreamingSDK(publicClient, walletClient) + const onHashMock = vi.fn() + + await sdk.createStream({ + receiver: "0xreceiver" as Address, + token: TEST_SUPERTOKEN, + flowRate: BigInt(100), + onHash: onHashMock, + }) + + expect(onHashMock).toHaveBeenCalledWith("0xhash") + }) + }) + + describe("GdaSDK Methods", () => { + let publicClient: any + let walletClient: any + + beforeEach(() => { + publicClient = createMockPublicClient() + walletClient = createMockWalletClient() + }) + + it("should disconnect from pool", async () => { + const sdk = new GdaSDK(publicClient, walletClient) + const hash = await sdk.disconnectFromPool({ + poolAddress: "0xpool" as Address, + }) + expect(hash).toBe("0xhash") + }) + + it("should include userData in pool operations", async () => { + const sdk = new GdaSDK(publicClient, walletClient) + const userData = "0xabcd" as `0x${string}` + const hash = await sdk.connectToPool({ + poolAddress: "0xpool" as Address, + userData, + }) + expect(hash).toBe("0xhash") + }) + + it("should call onHash callback for pool operations", async () => { + const sdk = new GdaSDK(publicClient, walletClient) + const onHashMock = vi.fn() + + await sdk.connectToPool({ + poolAddress: "0xpool" as Address, + onHash: onHashMock, + }) + + expect(onHashMock).toHaveBeenCalledWith("0xhash") + }) + }) +}) diff --git a/packages/streaming-sdk/src/streaming-sdk.ts b/packages/streaming-sdk/src/streaming-sdk.ts new file mode 100644 index 0000000..86cb32f --- /dev/null +++ b/packages/streaming-sdk/src/streaming-sdk.ts @@ -0,0 +1,266 @@ +import { + Address, + Hash, + PublicClient, + WalletClient, + type SimulateContractParameters, +} from "viem" +import { cfaForwarderAbi } from "@sfpro/sdk/abi" +import { + StreamingSDKOptions, + CreateStreamParams, + UpdateStreamParams, + DeleteStreamParams, + StreamInfo, + GetStreamsOptions, + GetBalanceHistoryOptions, + Environment, + TokenSymbol, +} from "./types" +import { validateChain } from "./utils" +import { SupportedChains, CFA_FORWARDER_ADDRESSES, getG$Token, getSUPToken } from "./constants" +import { SubgraphClient } from "./subgraph/client" + +export class StreamingSDK { + private publicClient: PublicClient + private walletClient: WalletClient | null = null + private chainId: SupportedChains + private environment: Environment + private subgraphClient: SubgraphClient + private cfaForwarder: Address + private defaultToken: Address | undefined + + constructor( + publicClient: PublicClient, + walletClient?: WalletClient, + options?: StreamingSDKOptions, + ) { + if (!publicClient) { + throw new Error("Public client is required") + } + + this.publicClient = publicClient + this.chainId = validateChain( + options?.chainId ?? publicClient.chain?.id, + ) + this.environment = options?.environment ?? "production" + + // Protocol addresses from sfpro maps + this.cfaForwarder = (CFA_FORWARDER_ADDRESSES as Record)[this.chainId] + + if (!this.cfaForwarder || this.cfaForwarder === "0x0000000000000000000000000000000000000000") { + throw new Error(`CFA Forwarder address not found or invalid for chain ID: ${this.chainId}`) + } + + this.defaultToken = this.resolveTokenSymbol(options?.defaultToken) + + if (walletClient) { + this.setWalletClient(walletClient) + } + + this.subgraphClient = new SubgraphClient(this.chainId, { + apiKey: options?.apiKey, + }) + } + + // Resolves symbol or address to concrete token address + private resolveTokenSymbol(token?: TokenSymbol | Address): Address | undefined { + if (!token) { + return this.chainId === SupportedChains.BASE + ? getSUPToken(this.chainId, this.environment) + : getG$Token(this.chainId, this.environment) + } + if (token === "G$") return getG$Token(this.chainId, this.environment) + if (token === "SUP") return getSUPToken(this.chainId, this.environment) + return token as Address + } + + setWalletClient(walletClient: WalletClient) { + const chainId = validateChain(walletClient.chain?.id) + if (chainId !== this.chainId) { + throw new Error( + `Wallet client chain (${chainId}) does not match SDK chain (${this.chainId})`, + ) + } + this.walletClient = walletClient + } + + async createStream(params: CreateStreamParams): Promise { + const { receiver, token, flowRate, userData = "0x", onHash } = params + + if (!receiver) throw new Error("Receiver address is required") + if (flowRate <= BigInt(0)) { + throw new Error("Flow rate must be greater than zero") + } + + // resolve token + const resolvedToken = this.resolveTokenSymbol(token ?? this.defaultToken) + if (!resolvedToken) { + throw new Error( + `Token address not available for chain ${this.chainId} in ${this.environment} environment. ` + + `Please provide an address explicitly or set a defaultToken symbol.` + ) + } + + const account = await this.getAccount() + + return this.submitAndWait( + { + address: this.cfaForwarder, + abi: cfaForwarderAbi, + functionName: "createFlow", + args: [resolvedToken, account, receiver, flowRate, userData], + }, + onHash, + ) + } + + async updateStream(params: UpdateStreamParams): Promise { + const { receiver, token, newFlowRate, userData = "0x", onHash } = params + + if (newFlowRate <= BigInt(0)) { + throw new Error("newFlowRate must be a positive non-zero value") + } + + // resolve token + const resolvedToken = this.resolveTokenSymbol(token ?? this.defaultToken) + if (!resolvedToken) { + throw new Error( + `Token address not available for chain ${this.chainId} in ${this.environment} environment. ` + + `Please provide an address explicitly or set a defaultToken symbol.` + ) + } + + const account = await this.getAccount() + + return this.submitAndWait( + { + address: this.cfaForwarder, + abi: cfaForwarderAbi, + functionName: "updateFlow", + args: [resolvedToken, account, receiver, newFlowRate, userData], + }, + onHash, + ) + } + + async deleteStream(params: DeleteStreamParams): Promise { + const { receiver, token, userData = "0x", onHash } = params + + // resolve token + const resolvedToken = this.resolveTokenSymbol(token ?? this.defaultToken) + if (!resolvedToken) { + throw new Error( + `Token address not available for chain ${this.chainId} in ${this.environment} environment. ` + + `Please provide an address explicitly or set a defaultToken symbol.` + ) + } + + const account = await this.getAccount() + + return this.submitAndWait( + { + address: this.cfaForwarder, + abi: cfaForwarderAbi, + functionName: "deleteFlow", + args: [resolvedToken, account, receiver, userData], + }, + onHash, + ) + } + + async getActiveStreams( + options: GetStreamsOptions, + ): Promise { + const streams = await this.subgraphClient.queryStreams(options) + + return streams.map((stream) => ({ + sender: stream.sender, + receiver: stream.receiver, + token: stream.token, + flowRate: stream.currentFlowRate, + timestamp: BigInt(stream.createdAtTimestamp), + streamedSoFar: stream.streamedUntilUpdatedAt, + })) + } + + /** + * Get the balance of a SuperToken for an account. + * If no token is provided, uses the SDK's defaultToken. + */ + async getSuperTokenBalance(account: Address, token?: TokenSymbol | Address): Promise { + const resolvedToken = this.resolveTokenSymbol(token ?? this.defaultToken) + + if (!resolvedToken) return BigInt(0) + + const balances = await this.subgraphClient.queryBalances(account) + const tokenBalance = balances.find( + (b) => b.token.toLowerCase() === resolvedToken.toLowerCase(), + ) + + return tokenBalance?.balance ?? BigInt(0) + } + + /** + * Retrieve balance history for an account + */ + async getBalanceHistory( + options: GetBalanceHistoryOptions, + ) { + return this.subgraphClient.queryBalanceHistory(options) + } + + async querySUPReserves(options: { first?: number; skip?: number } = {}) { + return this.subgraphClient.querySUPReserves(options) + } + + getSubgraphClient(): SubgraphClient { + return this.subgraphClient + } + + /** + * Submit transaction and wait for transaction receipt + */ + private async submitAndWait( + simulateParams: SimulateContractParameters, + onHash?: (hash: Hash) => void, + ): Promise { + if (!this.walletClient) { + throw new Error("Wallet client not initialized") + } + + const account = await this.getAccount() + + const { request } = await this.publicClient.simulateContract({ + account, + ...simulateParams, + }) + + const hash = await this.walletClient.writeContract(request) + + if (onHash) { + onHash(hash) + } + + await this.publicClient.waitForTransactionReceipt({ hash }) + + return hash + } + + /** + * Resolve current account address from wallet client + */ + private async getAccount(): Promise
{ + if (!this.walletClient) { + throw new Error("Wallet client not initialized") + } + + const [account] = await this.walletClient.getAddresses() + + if (!account) { + throw new Error("No account found in wallet client") + } + + return account + } +} diff --git a/packages/streaming-sdk/src/subgraph/client.ts b/packages/streaming-sdk/src/subgraph/client.ts new file mode 100644 index 0000000..690509a --- /dev/null +++ b/packages/streaming-sdk/src/subgraph/client.ts @@ -0,0 +1,338 @@ +import { GraphQLClient, gql } from "graphql-request" +import { Address } from "viem" +import { SUBGRAPH_URLS, SupportedChains } from "../constants" +import { + StreamQueryResult, + SuperTokenBalance, + GDAPool, + PoolMembership, + SUPReserveLocker, + GetStreamsOptions, + GetBalanceHistoryOptions, +} from "../types" + +/** + * GraphQL query definitions + */ +const GET_STREAMS = gql` + query GetStreams($account: String!, $skip: Int = 0, $first: Int = 100) { + outgoingStreams: streams( + where: { sender: $account, currentFlowRate_gt: "0" } + first: $first + skip: $skip + orderBy: createdAtTimestamp + orderDirection: desc + ) { + id + sender { id } + receiver { id } + token { id, symbol } + currentFlowRate + streamedUntilUpdatedAt + updatedAtTimestamp + createdAtTimestamp + } + incomingStreams: streams( + where: { receiver: $account, currentFlowRate_gt: "0" } + first: $first + skip: $skip + orderBy: createdAtTimestamp + orderDirection: desc + ) { + id + sender { id } + receiver { id } + token { id, symbol } + currentFlowRate + streamedUntilUpdatedAt + updatedAtTimestamp + createdAtTimestamp + } + } +` + +const GET_TOKEN_BALANCE = gql` + query GetTokenBalance($account: String!) { + account(id: $account) { + id + accountTokenSnapshots { + token { id, name, symbol } + balanceUntilUpdatedAt + updatedAtTimestamp + totalNetFlowRate + } + } + } +` + +const GET_BALANCE_HISTORY = gql` + query GetBalanceHistory( + $account: String!, + $fromTimestamp: Int, + $toTimestamp: Int, + $first: Int = 100, + $skip: Int = 0 + ) { + accountTokenSnapshotLogs( + where: { + account: $account + timestamp_gte: $fromTimestamp + timestamp_lte: $toTimestamp + } + first: $first + skip: $skip + orderBy: timestamp + orderDirection: asc + ) { + id + timestamp + token { id, symbol } + balance + totalNetFlowRate + } + } +` + +const GET_POOL_MEMBERSHIPS = gql` + query GetPoolMemberships($account: String!) { + account(id: $account) { + poolMemberships { + pool { + id + token { id, symbol } + totalUnits + totalAmountDistributedUntilUpdatedAt + flowRate + admin { id } + } + units + isConnected + totalAmountClaimed + } + } + } +` + +const GET_DISTRIBUTION_POOLS = gql` + query GetDistributionPools($first: Int = 100, $skip: Int = 0) { + pools( + first: $first + skip: $skip + orderBy: createdAtTimestamp + orderDirection: desc + ) { + id + token { id, symbol } + totalUnits + totalAmountDistributedUntilUpdatedAt + flowRate + admin { id } + createdAtTimestamp + } + } +` + +const GET_SUP_RESERVES = gql` + query GetSUPReserves($first: Int = 10, $skip: Int = 0) { + lockers( + first: $first + skip: $skip + orderBy: blockTimestamp + orderDirection: desc + ) { + id + lockerOwner { id } + blockNumber + blockTimestamp + } + } +` + +/** + * Subgraph data structures + */ +interface SubgraphAccount { id: string } +interface SubgraphToken { id: string; symbol: string; name?: string } +interface SubgraphStream { + id: string + sender: SubgraphAccount + receiver: SubgraphAccount + token: SubgraphToken + currentFlowRate: string + streamedUntilUpdatedAt: string + updatedAtTimestamp: string + createdAtTimestamp: string +} +interface SubgraphSnapshot { token: SubgraphToken; balanceUntilUpdatedAt: string; updatedAtTimestamp: string } +interface SubgraphSnapshotLog { token: SubgraphToken; balance: string; timestamp: string } +interface SubgraphPool { + id: string + token: SubgraphToken + totalUnits: string + totalAmountDistributedUntilUpdatedAt: string + flowRate: string + admin: SubgraphAccount +} +interface SubgraphPoolMembership { pool: SubgraphPool; units: string; isConnected: boolean; totalAmountClaimed: string } +interface SubgraphLocker { id: string; lockerOwner: SubgraphAccount; blockNumber: string; blockTimestamp: string } + +export class SubgraphClient { + private client: GraphQLClient + private chainId: SupportedChains + private apiKey?: string + + constructor(chainId: SupportedChains, options: { apiKey?: string } = {}) { + this.chainId = chainId + this.apiKey = options.apiKey + const endpoint = SUBGRAPH_URLS[chainId] + if (!endpoint) { + throw new Error(`No subgraph endpoint configured for chain ${chainId}`) + } + + const headers: Record = {} + if (options.apiKey) { + headers["Authorization"] = `Bearer ${options.apiKey}` + } + + this.client = new GraphQLClient(endpoint, { + headers, + }) + } + + async queryStreams(options: GetStreamsOptions): Promise { + const { account, direction = "all", first: requestedFirst, skip: requestedSkip } = options + + // Internal helper for paginated request + const fetchBatch = async (first: number, skip: number) => { + if (!account) return { outgoingStreams: [], incomingStreams: [] } + return this.client.request<{ + outgoingStreams: SubgraphStream[] + incomingStreams: SubgraphStream[] + }>(GET_STREAMS, { account: account.toLowerCase(), first, skip }) + } + + let allOutgoing: SubgraphStream[] = [] + let allIncoming: SubgraphStream[] = [] + + if (requestedFirst !== undefined && requestedSkip !== undefined) { + // User requested explicit pagination, just fetch once + const data = await fetchBatch(requestedFirst, requestedSkip) + allOutgoing = data.outgoingStreams + allIncoming = data.incomingStreams + } else { + // Loop to fetch all records in batches of 100 + let skip = requestedSkip ?? 0 + const batchSize = 100 + let hasMore = true + + while (hasMore) { + const data = await fetchBatch(batchSize, skip) + allOutgoing = [...allOutgoing, ...data.outgoingStreams] + allIncoming = [...allIncoming, ...data.incomingStreams] + + // Keep looping if either list was full + hasMore = (data.outgoingStreams.length === batchSize || data.incomingStreams.length === batchSize) + skip += batchSize + + // Safety break to prevent infinite loops in case of weird subgraph behavior + if (skip > 5000) break + } + } + + let streams: SubgraphStream[] = [] + if (direction === "outgoing") streams = allOutgoing + else if (direction === "incoming") streams = allIncoming + else streams = [...allOutgoing, ...allIncoming] + + return streams.map((s) => ({ + id: s.id, + sender: s.sender.id as Address, + receiver: s.receiver.id as Address, + token: s.token.id as Address, + currentFlowRate: BigInt(s.currentFlowRate), + streamedUntilUpdatedAt: BigInt(s.streamedUntilUpdatedAt), + updatedAtTimestamp: Number(s.updatedAtTimestamp), + createdAtTimestamp: Number(s.createdAtTimestamp), + })) + } + + async queryBalances(account: Address): Promise { + if (!account) return [] + const data = await this.client.request<{ + account: { accountTokenSnapshots: SubgraphSnapshot[] } | null + }>(GET_TOKEN_BALANCE, { account: account.toLowerCase() }) + + return data.account?.accountTokenSnapshots.map((s) => ({ + account, + token: s.token.id as Address, + balance: BigInt(s.balanceUntilUpdatedAt), + balanceUntilUpdatedAt: BigInt(s.balanceUntilUpdatedAt), + updatedAtTimestamp: Number(s.updatedAtTimestamp), + })) || [] + } + + async queryBalanceHistory(options: GetBalanceHistoryOptions): Promise { + const { account, fromTimestamp, toTimestamp, first = 100, skip = 0 } = options + if (!account) return [] + const data = await this.client.request<{ + accountTokenSnapshotLogs: SubgraphSnapshotLog[] + }>(GET_BALANCE_HISTORY, { + account: account.toLowerCase(), + fromTimestamp: fromTimestamp ? Math.floor(fromTimestamp / 1000) : undefined, + toTimestamp: toTimestamp ? Math.floor(toTimestamp / 1000) : undefined, + first, + skip, + }) + + return data.accountTokenSnapshotLogs.map((log) => ({ + account, + token: log.token.id as Address, + balance: BigInt(log.balance), + balanceUntilUpdatedAt: BigInt(log.balance), + updatedAtTimestamp: Number(log.timestamp), + })) + } + + async queryPoolMemberships(account: Address): Promise { + if (!account) return [] + const data = await this.client.request<{ + account: { poolMemberships: SubgraphPoolMembership[] } | null + }>(GET_POOL_MEMBERSHIPS, { account: account.toLowerCase() }) + + return data.account?.poolMemberships.map((m) => ({ + pool: m.pool.id as Address, + account, + units: BigInt(m.units), + isConnected: m.isConnected, + totalAmountClaimed: BigInt(m.totalAmountClaimed), + })) || [] + } + + async queryPools(options: { first?: number; skip?: number } = {}): Promise { + const { first = 100, skip = 0 } = options + const data = await this.client.request<{ pools: SubgraphPool[] }>(GET_DISTRIBUTION_POOLS, { first, skip }) + return data.pools.map((p) => ({ + id: p.id as Address, + token: p.token.id as Address, + totalUnits: BigInt(p.totalUnits), + totalAmountClaimed: BigInt(p.totalAmountDistributedUntilUpdatedAt), + flowRate: BigInt(p.flowRate), + admin: p.admin.id as Address, + })) + } + + async querySUPReserves(options: { first?: number; skip?: number } = {}): Promise { + const { first = 10, skip = 0 } = options + const headers: Record = {} + if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}` + const supClient = new GraphQLClient(SUBGRAPH_URLS.supReserve, { headers }) + const data = await supClient.request<{ lockers: SubgraphLocker[] }>(GET_SUP_RESERVES, { first, skip }) + + return data.lockers.map((l) => ({ + id: l.id, + lockerOwner: l.lockerOwner.id as Address, + blockNumber: BigInt(l.blockNumber), + blockTimestamp: BigInt(l.blockTimestamp), + })) + } +} diff --git a/packages/streaming-sdk/src/types.ts b/packages/streaming-sdk/src/types.ts new file mode 100644 index 0000000..f8f0caa --- /dev/null +++ b/packages/streaming-sdk/src/types.ts @@ -0,0 +1,138 @@ +import { Address, Hash } from "viem" + +export type Environment = "production" | "staging" | "development" + +export type TokenSymbol = "G$" | "SUP" + +export interface StreamingSDKOptions { + /** Chain ID. Supported: Celo (42220), Base (8453). Inferred from client if omitted. */ + chainId?: number + + /** Token address resolution environment. @default 'production' */ + environment?: Environment + + /** Subgraph API key for rate limiting. */ + apiKey?: string + + /** + * Default token for stream operations. Defaults to: + * - Celo: `G$` + * - Base: `SUP` + * + * - `'G$'` | `'SUP'` → address resolved from environment + chainId + * - `Address` → use specific token address + * - `undefined` → defaults to chain default token + * + * Can be overridden per-operation via the `token` parameter. + */ + defaultToken?: TokenSymbol | Address +} + +// Stream Types +export interface StreamInfo { + sender: Address + receiver: Address + token: Address + flowRate: bigint + timestamp: bigint + streamedSoFar?: bigint +} + +export interface CreateStreamParams { + receiver: Address + token?: TokenSymbol | Address + flowRate: bigint + /** Optional bytes forwarded to the Superfluid CFA forwarder. */ + userData?: `0x${string}` + onHash?: (hash: Hash) => void +} + +export interface UpdateStreamParams { + receiver: Address + token?: TokenSymbol | Address + newFlowRate: bigint + userData?: `0x${string}` + onHash?: (hash: Hash) => void +} + +export interface DeleteStreamParams { + receiver: Address + token?: TokenSymbol | Address + /** Optional bytes forwarded to the Superfluid CFA forwarder. */ + userData?: `0x${string}` + onHash?: (hash: Hash) => void +} + +// Subgraph Types +export interface SuperTokenBalance { + account: Address + token: Address + balance: bigint + balanceUntilUpdatedAt: bigint + updatedAtTimestamp: number +} + +export interface StreamQueryResult { + id: string + sender: Address + receiver: Address + token: Address + currentFlowRate: bigint + streamedUntilUpdatedAt: bigint + updatedAtTimestamp: number + createdAtTimestamp: number +} + +// GDA Pool Types +export interface GDAPool { + id: Address + token: Address + totalUnits: bigint + totalAmountClaimed: bigint + flowRate: bigint + admin: Address +} + +export interface PoolMembership { + pool: Address + account: Address + units: bigint + isConnected: boolean + totalAmountClaimed: bigint +} + +export interface ConnectToPoolParams { + poolAddress: Address + userData?: `0x${string}` + onHash?: (hash: Hash) => void +} + +export interface DisconnectFromPoolParams { + poolAddress: Address + userData?: `0x${string}` + onHash?: (hash: Hash) => void +} + +// SUP Reserve Types +export interface SUPReserveLocker { + id: string + lockerOwner: Address + blockNumber: bigint + blockTimestamp: bigint +} + +// Query Options +export interface GetStreamsOptions { + account: Address + direction?: "incoming" | "outgoing" | "all" + first?: number + skip?: number +} + +export interface GetBalanceHistoryOptions { + account: Address + fromTimestamp?: number + toTimestamp?: number + first?: number + skip?: number +} diff --git a/packages/streaming-sdk/src/utils.ts b/packages/streaming-sdk/src/utils.ts new file mode 100644 index 0000000..9f3148c --- /dev/null +++ b/packages/streaming-sdk/src/utils.ts @@ -0,0 +1,123 @@ +import { Address, parseEther, formatEther } from "viem" +import { + SupportedChains, + CHAIN_CONFIGS, + getG$Token, + getSUPToken, +} from "./constants" +import { Environment, TokenSymbol } from "./types" + +// Chain utilities +export function isSupportedChain( + chainId: number | undefined, +): chainId is SupportedChains { + return ( + chainId === SupportedChains.CELO || + chainId === SupportedChains.BASE + ) +} + +export function validateChain(chainId: number | undefined): SupportedChains { + if (!isSupportedChain(chainId)) { + throw new Error( + `Unsupported chain ID: ${chainId}. Supported chains: Celo (42220), Base (8453)`, + ) + } + return chainId +} + +export function getSuperTokenAddress( + chainId: SupportedChains, + environment: Environment, +): Address { + return getSuperTokenAddressForSymbol(chainId, environment, "G$") +} + +export function getSuperTokenAddressSafe( + chainId: number | undefined, + environment: Environment, +): Address | undefined { + if (!isSupportedChain(chainId)) return undefined + return getG$Token(chainId, environment) +} + +export function getSuperTokenAddressForSymbol( + chainId: SupportedChains, + environment: Environment, + token: TokenSymbol, +): Address { + const address = + token === "SUP" + ? getSUPToken(chainId, environment) + : getG$Token(chainId, environment) + + if (!address) { + throw new Error( + `${token} SuperToken address not configured for chain ${CHAIN_CONFIGS[chainId].name} in ${environment} environment`, + ) + } + + return address +} + +export function getSuperTokenAddressForSymbolSafe( + chainId: number | undefined, + environment: Environment, + token: TokenSymbol, +): Address | undefined { + if (!isSupportedChain(chainId)) return undefined + return token === "SUP" + ? getSUPToken(chainId, environment) + : getG$Token(chainId, environment) +} + +export function getChainConfig(chainId: SupportedChains) { + return CHAIN_CONFIGS[chainId] +} + +// Flow rate conversion utilities +export type TimeUnit = "second" | "minute" | "hour" | "day" | "week" | "month" | "year" + +const TIME_UNITS_IN_SECONDS: Record = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + month: 2592000, + year: 31536000, +} + +export function calculateFlowRate( + amountWei: bigint, + timeUnit: TimeUnit, +): bigint { + const secondsInUnit = BigInt(TIME_UNITS_IN_SECONDS[timeUnit]) + return amountWei / secondsInUnit +} + +export function calculateStreamedAmount( + flowRate: bigint, + durationSeconds: bigint, +): bigint { + return flowRate * durationSeconds +} + +export function formatFlowRate(flowRate: bigint, timeUnit: TimeUnit): string { + const secondsInUnit = BigInt(TIME_UNITS_IN_SECONDS[timeUnit]) + const amountPerUnit = flowRate * secondsInUnit + const formatted = formatEther(amountPerUnit) + const [integer, fraction] = formatted.split(".") + if (fraction && fraction.length > 4) { + return `${integer}.${fraction.slice(0, 4)} tokens/${timeUnit}` + } + return `${formatted} tokens/${timeUnit}` +} + +export function flowRateFromAmount( + amount: string, + timeUnit: TimeUnit, +): bigint { + const amountWei = parseEther(amount) + return calculateFlowRate(amountWei, timeUnit) +} diff --git a/packages/streaming-sdk/tsconfig.json b/packages/streaming-sdk/tsconfig.json new file mode 100644 index 0000000..0896596 --- /dev/null +++ b/packages/streaming-sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/streaming-sdk/tsup.config.ts b/packages/streaming-sdk/tsup.config.ts new file mode 100644 index 0000000..158c45f --- /dev/null +++ b/packages/streaming-sdk/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + dts: true, + format: ["esm", "cjs"], + treeshake: true, +}); diff --git a/packages/streaming-sdk/vitest.config.ts b/packages/streaming-sdk/vitest.config.ts new file mode 100644 index 0000000..7ae15f1 --- /dev/null +++ b/packages/streaming-sdk/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/__tests__/**"], + }, + }, +}) diff --git a/yarn.lock b/yarn.lock index cc0f3fc..878f877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -747,6 +747,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-identifier@npm:7.25.9" @@ -754,6 +761,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-option@npm:7.25.9" @@ -803,6 +817,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + "@babel/plugin-syntax-jsx@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-syntax-jsx@npm:7.25.9" @@ -941,6 +966,23 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@bytecodealliance/preview2-shim@npm:0.17.0": version: 0.17.0 resolution: "@bytecodealliance/preview2-shim@npm:0.17.0" @@ -2183,7 +2225,9 @@ __metadata: resolution: "@goodsdks/react-hooks@workspace:packages/react-hooks" dependencies: "@goodsdks/citizen-sdk": "npm:*" + "@goodsdks/streaming-sdk": "npm:*" "@repo/typescript-config": "workspace:*" + "@tanstack/react-query": "npm:^4.36.1" "@types/react": "npm:^19" lz-string: "npm:^1.5.0" react: "npm:^19.1.1" @@ -2225,6 +2269,25 @@ __metadata: languageName: unknown linkType: soft +"@goodsdks/streaming-sdk@npm:*, @goodsdks/streaming-sdk@workspace:packages/streaming-sdk": + version: 0.0.0-use.local + resolution: "@goodsdks/streaming-sdk@workspace:packages/streaming-sdk" + dependencies: + "@repo/typescript-config": "workspace:*" + "@sfpro/sdk": "npm:^0.1.0" + "@types/node": "npm:latest" + "@vitest/coverage-v8": "npm:^4.0.18" + graphql: "npm:^16.9.0" + graphql-request: "npm:^7.1.2" + tsup: "npm:^8.3.5" + typescript: "npm:latest" + viem: "npm:latest" + vitest: "npm:^4.0.18" + peerDependencies: + viem: "*" + languageName: unknown + linkType: soft + "@goodsdks/ui-components@npm:*, @goodsdks/ui-components@workspace:packages/ui-components": version: 0.0.0-use.local resolution: "@goodsdks/ui-components@workspace:packages/ui-components" @@ -2241,6 +2304,15 @@ __metadata: languageName: unknown linkType: soft +"@graphql-typed-document-node/core@npm:^3.2.0": + version: 3.2.0 + resolution: "@graphql-typed-document-node/core@npm:3.2.0" + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/94e9d75c1f178bbae8d874f5a9361708a3350c8def7eaeb6920f2c820e82403b7d4f55b3735856d68e145e86c85cbfe2adc444fdc25519cd51f108697e99346c + languageName: node + linkType: hard + "@hookform/resolvers@npm:^3.10.0": version: 3.10.0 resolution: "@hookform/resolvers@npm:3.10.0" @@ -2388,6 +2460,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@lit-labs/ssr-dom-shim@npm:^1.0.0, @lit-labs/ssr-dom-shim@npm:^1.1.0, @lit-labs/ssr-dom-shim@npm:^1.2.0": version: 1.3.0 resolution: "@lit-labs/ssr-dom-shim@npm:1.3.0" @@ -4338,7 +4420,7 @@ __metadata: languageName: node linkType: hard -"@repo/eslint-config@npm:*, @repo/eslint-config@workspace:packages/eslint-config": +"@repo/eslint-config@npm:*, @repo/eslint-config@workspace:*, @repo/eslint-config@workspace:packages/eslint-config": version: 0.0.0-use.local resolution: "@repo/eslint-config@workspace:packages/eslint-config" dependencies: @@ -4996,6 +5078,27 @@ __metadata: languageName: node linkType: hard +"@sfpro/sdk@npm:^0.1.0": + version: 0.1.9 + resolution: "@sfpro/sdk@npm:0.1.9" + peerDependencies: + "@wagmi/core": ^2 + react: ">=18" + viem: ^2 + wagmi: ^2 + peerDependenciesMeta: + "@wagmi/core": + optional: true + react: + optional: true + viem: + optional: true + wagmi: + optional: true + checksum: 10c0/b800a428948d18674fb9ca2a1cec013cd56cdfd844f9b859249d7c174d1f4f99a2eafe74f542f4c11122c7c049389f68d5bbdc72278bcbbbb89586379034abe7 + languageName: node + linkType: hard + "@smithy/abort-controller@npm:^4.0.1": version: 4.0.1 resolution: "@smithy/abort-controller@npm:4.0.1" @@ -7745,6 +7848,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:latest": + version: 25.2.3 + resolution: "@types/node@npm:25.2.3" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/925833029ce0bb4a72c36f90b93287184d3511aeb0fa60a994ae94b5430c22f9be6693d67d210df79267cb54c6f6978caaefb149d99ab5f83af5827ba7cb9822 + languageName: node + linkType: hard + "@types/pbkdf2@npm:^3.0.0": version: 3.1.2 resolution: "@types/pbkdf2@npm:3.1.2" @@ -8098,6 +8210,30 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" + peerDependencies: + "@vitest/browser": 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.18": version: 4.0.18 resolution: "@vitest/expect@npm:4.0.18" @@ -9240,6 +9376,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^0.3.10": + version: 0.3.11 + resolution: "ast-v8-to-istanbul@npm:0.3.11" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/0667dcb5f42bd16f5d50b8687f3471f9b9d000ea7f8808c3cd0ddabc1ef7d5b1a61e19f498d5ca7b1285e6c185e11d0ae724c4f9291491b50b6340110ce63108 + languageName: node + linkType: hard + "astral-regex@npm:^2.0.0": version: 2.0.0 resolution: "astral-regex@npm:2.0.0" @@ -10568,6 +10715,42 @@ __metadata: languageName: unknown linkType: soft +"demo-streaming-app@workspace:apps/demo-streaming-app": + version: 0.0.0-use.local + resolution: "demo-streaming-app@workspace:apps/demo-streaming-app" + dependencies: + "@goodsdks/react-hooks": "npm:*" + "@goodsdks/streaming-sdk": "npm:*" + "@reown/appkit": "npm:^1.7.2" + "@reown/appkit-adapter-wagmi": "npm:^1.7.2" + "@repo/eslint-config": "workspace:*" + "@tamagui/babel-plugin": "npm:^1.125.22" + "@tamagui/config": "npm:^1.125.22" + "@tamagui/core": "npm:^1.125.22" + "@tamagui/font-inter": "npm:^1.125.22" + "@tamagui/vite-plugin": "npm:^1.125.22" + "@tamagui/web": "npm:^1.125.22" + "@tanstack/react-query": "npm:^4.36.1" + "@types/react": "npm:^18.3.18" + "@types/react-dom": "npm:^18.3.5" + "@typescript-eslint/eslint-plugin": "npm:^5.62.0" + "@typescript-eslint/parser": "npm:^5.62.0" + "@vitejs/plugin-react": "npm:^4.3.4" + eslint: "npm:^8.57.1" + eslint-config-prettier: "npm:^8.10.0" + eslint-plugin-react: "npm:^7.37.4" + eslint-plugin-react-hooks: "npm:^5.2.0" + prettier: "npm:^3.5.3" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + tamagui: "npm:^1.125.22" + typescript: "npm:^5.8.2" + viem: "npm:^1.21.4" + vite: "npm:6.3.5" + wagmi: "npm:^1.4.13" + languageName: unknown + linkType: soft + "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -12773,6 +12956,24 @@ __metadata: languageName: node linkType: hard +"graphql-request@npm:^7.1.2": + version: 7.4.0 + resolution: "graphql-request@npm:7.4.0" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.2.0" + peerDependencies: + graphql: 14 - 16 + checksum: 10c0/066531d70b9c4656251e51c950ce9bee3df05291e12e2b983608d75ac3a70d700efd6488d273b043235dc125658973db38b21eba59e808863831a5a19a6a7b41 + languageName: node + linkType: hard + +"graphql@npm:^16.9.0": + version: 16.12.0 + resolution: "graphql@npm:16.12.0" + checksum: 10c0/b6fffa4e8a4e4a9933ebe85e7470b346dbf49050c1a482fac5e03e4a1a7bed2ecd3a4c97e29f04457af929464bc5e4f2aac991090c2f320111eef26e902a5c75 + languageName: node + linkType: hard + "h3@npm:^1.13.0": version: 1.13.1 resolution: "h3@npm:1.13.1" @@ -13022,6 +13223,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -13633,6 +13841,34 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "iterate-object@npm:^1.3.4": version: 1.3.5 resolution: "iterate-object@npm:1.3.5" @@ -13697,6 +13933,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -14196,6 +14439,17 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.5.1": + version: 0.5.2 + resolution: "magicast@npm:0.5.2" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7 + languageName: node + linkType: hard + "make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -14205,6 +14459,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -16721,6 +16984,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + "serialize-javascript@npm:^6.0.2": version: 6.0.2 resolution: "serialize-javascript@npm:6.0.2" @@ -18366,6 +18638,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "undici@npm:^5.14.0": version: 5.28.4 resolution: "undici@npm:5.28.4"