diff --git a/packages/good-design/src/apps/onramp/GdOnramperWidget.tsx b/packages/good-design/src/apps/onramp/GdOnramperWidget.tsx index b2b95579c..3ab6cc385 100644 --- a/packages/good-design/src/apps/onramp/GdOnramperWidget.tsx +++ b/packages/good-design/src/apps/onramp/GdOnramperWidget.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Onramper } from "./Onramper"; import { useEthers, useEtherBalance, useTokenBalance } from "@usedapp/core"; import { WebViewMessageEvent } from "react-native-webview"; -import { AsyncStorage, useBuyGd } from "@gooddollar/web3sdk-v2"; +import { AsyncStorage, Envs, useBuyGd, useGetEnvChainId } from "@gooddollar/web3sdk-v2"; import { noop } from "lodash"; import { useModal } from "../../hooks/useModal"; @@ -38,6 +38,9 @@ export const GdOnramperWidget = ({ const cusd = "0x765de816845861e75a25fca122bb6898b8b1282a"; const { account, library } = useEthers(); const swapLock = useRef(false); + const { baseEnv } = useGetEnvChainId(42220); + const devEnv = baseEnv === "fuse" ? "development" : baseEnv; + const backend = Envs[devEnv]?.backend; const { createAndSwap, swap, swapState, createState, gdHelperAddress, triggerSwapTx } = useBuyGd({ donateOrExecTo, @@ -55,18 +58,48 @@ export const GdOnramperWidget = ({ const { showModal, Modal } = useModal(); const [step, setStep] = useState(0); + const [onramperSignContent, setOnramperSignContent] = useState(""); + const [onramperUrlSignature, setOnramperUrlSignature] = useState(undefined); // console.log({ selfSwap, account, gdHelperAddress, accountCeloBalance, cusdBalance, celoBalance }); /** * callback to get event from onramper iframe + * Optimized to avoid unnecessary parsing and improve error handling */ - const callback = useCallback(async (event: WebViewMessageEvent) => { - if ((event.nativeEvent.data as any).title === "success") { - await AsyncStorage.setItem("gdOnrampSuccess", "true"); - //start the stepper - setStep(2); - } - }, []); + const callback = useCallback( + async (event: WebViewMessageEvent) => { + const rawData = event.nativeEvent?.data; + if (!rawData) return; + + let eventData; + try { + // Only parse if it's a string, otherwise use directly + eventData = typeof rawData === "string" ? JSON.parse(rawData) : rawData; + } catch (error) { + // Silent fail for invalid JSON - expected for non-JSON messages + return; + } + + // Early return if no valid event data + if (!eventData?.type && !eventData?.title) return; + + // Handle different Onramper event types + switch (eventData.type || eventData.title) { + case "initiated": + case "opened": + // User opened/interacted with the widget + onEvents("widget_clicked"); + break; + case "success": + await AsyncStorage.setItem("gdOnrampSuccess", "true"); + setStep(2); + break; + default: + break; + } + }, + [onEvents] + ); const triggerSwap = async () => { if (swapLock.current) return; //prevent from useEffect retriggering this @@ -74,6 +107,9 @@ export const GdOnramperWidget = ({ try { setStep(3); + // Emit swap_started event to animate progress bar step 2 + onEvents("swap_started"); + //user sends swap tx if (selfSwap && gdHelperAddress && library && account) { const minAmount = 0; // we let contract use oracle for minamount, we might calculate it for more precision in the future @@ -103,8 +139,11 @@ export const GdOnramperWidget = ({ // when done set stepper at final step setStep(5); swapLock.current = false; + // Emit swap_completed event to move progress bar to step 3 + onEvents("swap_completed"); onEvents("buy_success"); } catch (e: any) { + swapLock.current = false; // Reset lock on error console.log("swap error:", e.message, e); showModal(); onEvents("buygd_swap_failed", e.message); @@ -116,6 +155,8 @@ export const GdOnramperWidget = ({ useEffect(() => { if (cusdBalance?.gt(0) || celoBalance?.gt(0)) { void AsyncStorage.removeItem("gdOnrampSuccess"); + // Emit funds_received event to update progress bar to step 2 + onEvents("funds_received"); console.log("starting swap:", cusdBalance?.toString(), celoBalance?.toString()); triggerSwap().catch(e => { showModal(); @@ -124,6 +165,48 @@ export const GdOnramperWidget = ({ } }, [celoBalance, cusdBalance]); + useEffect(() => { + if (!onramperSignContent) return; + + if (!backend) { + setOnramperUrlSignature(undefined); + console.error("Onramper: Missing backend URL for signing request"); + return; + } + + setOnramperUrlSignature(undefined); + + const requestSignature = async () => { + try { + const response = await fetch(`${backend}/verify/onramper/sign`, { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ signContent: onramperSignContent }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const signature = data?.signature; + + if (!signature) { + throw new Error("Invalid signature response"); + } + + setOnramperUrlSignature(signature); + } catch (e: any) { + setOnramperUrlSignature(undefined); + console.error("Onramper: failed to fetch URL signature", e?.message || e); + } + }; + + requestSignature().catch(e => { + console.error("Onramper: signature request failed", e); + }); + }, [backend, onramperSignContent]); + return ( <> } _modalContainer={{ paddingBottom: 18, paddingLeft: 18, paddingRight: 18 }} /> @@ -134,10 +217,12 @@ export const GdOnramperWidget = ({ step={step} setStep={setStep} targetNetwork="CELO" - widgetParams={undefined} + widgetParams={{ onlyCryptos: "CUSD_CELO", isAddressEditable: false }} isTesting={isTesting} onGdEvent={onEvents} apiKey={apiKey} + urlSignature={onramperUrlSignature} + onUrlSignContentReady={setOnramperSignContent} /> diff --git a/packages/good-design/src/apps/onramp/Onramper.tsx b/packages/good-design/src/apps/onramp/Onramper.tsx index 345f61d45..d76cba1bf 100644 --- a/packages/good-design/src/apps/onramp/Onramper.tsx +++ b/packages/good-design/src/apps/onramp/Onramper.tsx @@ -1,6 +1,6 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { WebView, WebViewMessageEvent } from "react-native-webview"; -import { Box, Circle, HStack, Stack, Text, VStack } from "native-base"; +import { Box, Circle, HStack, Stack, Text, VStack, useBreakpointValue } from "native-base"; import { AsyncStorage, isMobile as deviceDetect } from "@gooddollar/web3sdk-v2"; import { CentreBox } from "../../core/layout/CentreBox"; @@ -10,6 +10,9 @@ import { BaseButton } from "../../core"; import { useWindowFocus } from "../../hooks"; export type OnramperCallback = (event: WebViewMessageEvent) => void; +const SENSITIVE_WIDGET_PARAMS = ["wallets", "walletAddressTags", "networkWallets"] as const; +type SensitiveWidgetParam = (typeof SENSITIVE_WIDGET_PARAMS)[number]; +type WidgetParams = Record; const stepValues = [0, 0, 50, 50, 100, 100]; @@ -90,6 +93,8 @@ export const Onramper = ({ setStep, isTesting, apiKey, + urlSignature, + onUrlSignContentReady, widgetParams = { onlyCryptos: "CUSD_CELO", isAddressEditable: false }, targetNetwork = "CELO", targetWallet @@ -99,38 +104,181 @@ export const Onramper = ({ step: number; setStep: (step: number) => void; isTesting: boolean; - widgetParams?: any; + widgetParams?: WidgetParams; targetWallet?: string; targetNetwork?: string; apiKey?: string; + urlSignature?: string; + onUrlSignContentReady?: (signContent: string) => void; }) => { - const url = new URL("https://buy.onramper.com/"); + /** + * Onramper URL-signing guide: + * - sort nested mapping keys alphabetically (e.g. "bitcoin:...,ethereum:...") + * - keep values unencoded while preparing signContent + */ + const sortNestedMappingValueAlphabetically = useCallback((value: string) => { + return value + .split(",") + .filter(Boolean) + .map(entry => entry.trim()) + .filter(Boolean) + .map(entry => { + const [rawKey, ...rawRest] = entry.split(":"); + if (!rawKey || rawRest.length === 0) return undefined; + return [rawKey.trim().toLowerCase(), rawRest.join(":").trim()] as const; + }) + .filter((pair): pair is readonly [string, string] => Boolean(pair)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, mappedValue]) => `${key}:${mappedValue}`) + .join(","); + }, []); - if (apiKey) { - url.searchParams.set("apiKey", apiKey); - } - url.searchParams.set("networkWallets", `${targetNetwork}:${targetWallet}`); - Object.entries(widgetParams).forEach(([k, v]: [string, any]) => { - url.searchParams.append(k, v); - }); + const sensitiveParamsForSigning = useMemo(() => { + const params: Partial> = {}; + + if (targetWallet) { + params.networkWallets = `${targetNetwork.toLowerCase()}:${targetWallet}`; + } + + SENSITIVE_WIDGET_PARAMS.forEach(paramKey => { + const widgetValue = widgetParams[paramKey]; + if (widgetValue !== undefined) { + params[paramKey] = String(widgetValue); + } + }); + + SENSITIVE_WIDGET_PARAMS.forEach(paramKey => { + const sensitiveValue = params[paramKey]; + if (sensitiveValue) { + params[paramKey] = sortNestedMappingValueAlphabetically(sensitiveValue); + } + }); + + return params; + }, [sortNestedMappingValueAlphabetically, targetNetwork, targetWallet, widgetParams]); + + /** + * Onramper URL-signing guide: + * - signContent is ONLY sensitive params subset: + * wallets, networkWallets, walletAddressTags + * - sort top-level keys alphabetically + * - keep this string unencoded before signing + */ + const signContent = useMemo(() => { + return Object.entries(sensitiveParamsForSigning) + .filter(([, value]) => Boolean(value)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + }, [sensitiveParamsForSigning]); + + useEffect(() => { + if (onUrlSignContentReady && signContent) { + onUrlSignContentReady(signContent); + } + }, [signContent, onUrlSignContentReady]); + + // Memoize URL construction to avoid reconstruction on every render + const uri = useMemo(() => { + const url = new URL("https://buy.onramper.com/"); + + // Always include API key for proper authentication + // SECURITY NOTE: Onramper API keys are designed for client-side use + // - These are PUBLIC API keys specifically intended for browser environments + // - They are NOT secret keys and are safe to expose in client-side code + // - Similar to Google Maps API keys, they're restricted by domain/referrer + // - This follows Onramper's official integration documentation + // - See: https://docs.onramper.com for official security guidelines + if (apiKey) { + url.searchParams.set("apiKey", apiKey); + } else { + console.warn("Onramper: No API key provided"); + } + Object.entries(sensitiveParamsForSigning).forEach(([key, value]) => { + if (value) { + url.searchParams.set(key, value); + } + }); + + Object.entries(widgetParams).forEach(([k, v]: [string, any]) => { + if (v !== undefined && !SENSITIVE_WIDGET_PARAMS.includes(k as SensitiveWidgetParam)) { + url.searchParams.set(k, String(v)); + } + }); + + if (urlSignature) { + url.searchParams.set("signature", urlSignature); + } else if (signContent) { + console.warn( + "Onramper: Sensitive wallet params are present but no urlSignature was provided. Checkout may be restricted." + ); + } + + return url.toString(); + }, [apiKey, sensitiveParamsForSigning, signContent, widgetParams, urlSignature]); const { title } = useWindowFocus(); + const shouldWaitForSignature = Boolean(signContent) && !urlSignature; - const uri = url.toString(); + // Cache AsyncStorage value to avoid repeated reads + const [cachedOnrampStatus, setCachedOnrampStatus] = useState(null); const isMobile = deviceDetect(); + // Responsive dimensions for the widget + const widgetDimensions = useBreakpointValue({ + base: { + maxWidth: "100%", + width: "95vw", + height: 500, + webViewWidth: "100%", + webViewHeight: 500 + }, + sm: { + maxWidth: 400, + width: "90%", + height: 550, + webViewWidth: 380, + webViewHeight: 550 + }, + md: { + maxWidth: 450, + width: "80%", + height: 600, + webViewWidth: 430, + webViewHeight: 600 + }, + lg: { + maxWidth: 480, + width: "100%", + height: 630, + webViewWidth: 480, + webViewHeight: 630 + }, + xl: { + maxWidth: 480, + width: "200%", + height: 630, + webViewWidth: 480, + webViewHeight: 630 + } + }); + // on page load check if a returning user is awaiting funds + // Cache the value to avoid repeated AsyncStorage reads useEffect(() => { const isOnramping = async () => { - const isOnramping = await AsyncStorage.getItem("gdOnrampSuccess"); - if (isOnramping === "true") { - setStep(2); + if (cachedOnrampStatus === null) { + const status = await AsyncStorage.getItem("gdOnrampSuccess"); + setCachedOnrampStatus(status); + if (status === "true") { + setStep(2); + } } }; void isOnramping(); - }, []); + }, [cachedOnrampStatus]); useEffect(() => { if (title === "Onramper widget" && step === 0) { @@ -148,33 +296,63 @@ export const Onramper = ({ }, [step]); if (!targetWallet) { - return <>; + return ( + + Wallet not found. Please select a valid wallet to continue. + + ); } return ( - + - - + + {shouldWaitForSignature ? ( + + + Preparing secure checkout... + + + ) : ( + { + const { nativeEvent } = syntheticEvent; + console.error("Onramper WebView error:", nativeEvent); + }} + onHttpError={syntheticEvent => { + const { nativeEvent } = syntheticEvent; + console.error("Onramper HTTP error:", nativeEvent.statusCode, nativeEvent.description); + }} + height={widgetDimensions?.webViewHeight} + width={widgetDimensions?.webViewWidth} + title="Onramper widget" + allow="accelerometer; autoplay; camera; gyroscope; payment" + > + )} {isTesting && ( diff --git a/packages/good-design/src/core/web3/Converter.tsx b/packages/good-design/src/core/web3/Converter.tsx index c6b35393f..f7bb5e515 100644 --- a/packages/good-design/src/core/web3/Converter.tsx +++ b/packages/good-design/src/core/web3/Converter.tsx @@ -19,24 +19,29 @@ interface CurrencyBoxProps { const CurrencyBox = ({ title, placeholder, logoSrc, currencyUnit, onBlur, onChangeText }: CurrencyBoxProps) => ( - {title} + + {title} + - {currencyUnit} + + {currencyUnit} + diff --git a/packages/good-design/src/stories/apps/onramp/Onramper.stories.tsx b/packages/good-design/src/stories/apps/onramp/Onramper.stories.tsx index f4ade33b4..c6c31d528 100644 --- a/packages/good-design/src/stories/apps/onramp/Onramper.stories.tsx +++ b/packages/good-design/src/stories/apps/onramp/Onramper.stories.tsx @@ -4,12 +4,32 @@ import { noop } from "lodash"; import { GdOnramperWidget } from "../../../apps/onramp/GdOnramperWidget"; import { W3Wrapper } from "../../W3Wrapper"; +/** + * GdOnramperWidget - Buy G$ flow with Onramper integration + * + * Features: + * - 3-step progress bar (Buy cUSD → Swap to G$ → Done) + * - Responsive widget dimensions for all screen sizes + * - Event tracking (widget_clicked, swap_started, funds_received, swap_completed) + * - AsyncStorage caching for better performance + * - Built-in Stepper component with animations + * - Error handling with modal display + * - Server-side URL signing for enhanced security + * + * Improvements in this version (fix/calculator-styling): + * - Better event parsing (handles both 'type' and 'title' event fields) + * - Fixed critical bug: swap lock now resets properly on error + * - Added progress event emissions for UI updates (funds_received, swap_started, swap_completed) + * - Server-side URL signing flow via backend /verify/onramper/sign endpoint + * - Enhanced error handling with proper lock cleanup + * - Dynamic backend URL detection based on environment (fuse/celo) + * - Improved event handling with early returns and proper type checking + */ export const OnramperWidget = { args: { - step: 0, - widgetParams: undefined, - targetWallet: "0x0", - targetNetwork: "CELO" + apiKey: undefined, // Optional: Onramper API key for production use + selfSwap: false, // If true, user sends swap tx; if false, backend handles it + withSwap: true // Enable automatic cUSD to G$ swap } }; @@ -17,7 +37,7 @@ export default { title: "Apps/Onramper", component: props => ( -
+
diff --git a/packages/good-design/src/stories/core/web3/Converter.stories.tsx b/packages/good-design/src/stories/core/web3/Converter.stories.tsx new file mode 100644 index 000000000..ff4b494ad --- /dev/null +++ b/packages/good-design/src/stories/core/web3/Converter.stories.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import Converter from "../../../core/web3/Converter"; +import { NativeBaseProvider, theme } from "../../../theme"; + +/** + * Converter (G$ Calculator) - cUSD to G$ conversion calculator + * + * This story showcases the improved styling for better text readability: + * - Enhanced font sizes and weights for better hierarchy + * - Improved color contrast with goodGrey palette + * - Better visual separation between elements + * - Clearer currency unit labels + * + * Improvements in this version: + * - Input text: fontSize changed from "6" to "2xl" for better readability + * - Input color: Added "goodGrey.800" for proper contrast + * - Title text: fontSize "sm" with fontWeight "500" and color "goodGrey.600" + * - Currency unit: fontSize "md" with fontWeight "600" for better emphasis + * - Overall improved visual hierarchy and readability + */ +export default { + title: "Core/Web3/Converter", + component: Converter, + decorators: [ + (Story: any) => ( + +
+ +
+
+ ) + ], + argTypes: { + gdPrice: { + control: { type: "number", min: 0.0001, max: 1, step: 0.0001 }, + description: "G$ price in USD (e.g., 0.01 means 1 G$ = $0.01)" + } + } +}; + +// Basic converter with default G$ price +export const Default = { + args: { + gdPrice: 0.01 // 1 G$ = $0.01 USD + } +}; + +// Converter with higher G$ price +export const HigherPrice = { + args: { + gdPrice: 0.05 // 1 G$ = $0.05 USD + } +}; + +// Converter with lower G$ price +export const LowerPrice = { + args: { + gdPrice: 0.005 // 1 G$ = $0.005 USD + } +}; + +// Interactive example showing the styling improvements +export const InteractiveCalculator = { + render: (args: any) => { + return ( +
+

G$ Calculator - Improved Styling

+

+ Try typing different amounts to see the conversion in action. Notice the improved text readability with better + font sizes, weights, and color contrast. +

+ +
+

Styling Improvements:

+
    +
  • ✓ Larger input text (2xl) for better readability
  • +
  • ✓ Proper color contrast (goodGrey.800 on inputs)
  • +
  • ✓ Clearer labels (sm size, 500 weight)
  • +
  • ✓ Better currency unit emphasis (md size, 600 weight)
  • +
  • ✓ Improved visual hierarchy throughout
  • +
+
+
+ ); + }, + args: { + gdPrice: 0.01 + } +};