Add Pay Playground to testapp (#84)

* Add Pay Playground to testapp

* slight ux polish

* format

* fix lint

* fix typecheck
This commit is contained in:
Spencer Stock
2025-08-11 18:22:48 -06:00
committed by GitHub
parent 23fffcfa53
commit 8f7e5b3c96
27 changed files with 2477 additions and 7 deletions
+6
View File
@@ -41,5 +41,11 @@ examples/**/yarn.lock
# next.js
.next
out/
.vercel
# testapp specific
examples/testapp/out/
examples/testapp/.next/
/**/tsconfig.tsbuildinfo
+5
View File
@@ -185,6 +185,11 @@
"expect"
]
},
"css": {
"parser": {
"cssModules": true
}
},
"overrides": [
{
"include": [
+2
View File
@@ -20,6 +20,8 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@metamask/eth-sig-util": "^7.0.0",
"@types/acorn": "^4.0.6",
"acorn": "^8.15.0",
"framer-motion": "^10.13.1",
"next": "^14.2.10",
"react": "^18.2.0",
+8 -1
View File
@@ -28,7 +28,14 @@ type LayoutProps = {
export const WIDTH_2XL = '1536px';
const PAGES = ['/', '/add-sub-account', '/import-sub-account', '/auto-sub-account', '/payment'];
const PAGES = [
'/',
'/add-sub-account',
'/import-sub-account',
'/auto-sub-account',
'/payment',
'/pay-playground',
];
export function Layout({ children }: LayoutProps) {
const { version, setSDKVersion, scwUrl, setScwUrlAndSave } = useConfig();
+4 -3
View File
@@ -6,13 +6,14 @@ import { EIP1193ProviderContextProvider } from '../context/EIP1193ProviderContex
import { systemStorageManager, theme } from '../theme';
export default function App({ Component, pageProps }) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => <Layout>{page}</Layout>);
return (
<ChakraProvider theme={theme} colorModeManager={systemStorageManager}>
<ConfigContextProvider>
<EIP1193ProviderContextProvider>
<Layout>
<Component {...pageProps} />
</Layout>
{getLayout(<Component {...pageProps} />)}
</EIP1193ProviderContextProvider>
</ConfigContextProvider>
</ChakraProvider>
@@ -0,0 +1,34 @@
# Pay Playground
An interactive playground for testing the Base Pay SDK functions (`pay` and `getPaymentStatus`).
## Features
- **Interactive Code Editor**: Edit and execute payment code in real-time
- **Live Output**: See results, errors, and console output immediately
- **Quick Tips**: Helpful hints and examples for common use cases
- **Payer Info Toggle**: Easily switch between basic and payer info collection modes
- **Dual Function Support**: Test both `pay` and `getPaymentStatus` functions
## Layout
This page uses a custom layout without the main app header (SDK type switcher, environment selector, and Reset button) to provide a cleaner, focused experience for testing payment functionality.
## Components
- `CodeEditor`: Monaco-based code editor with syntax highlighting
- `Output`: Displays execution results, errors, and console logs
- `QuickTips`: Shows contextual help and examples
- `Header`: Simple header specific to the playground
## Usage
1. Navigate to `/pay-playground` in the testapp
2. Edit the code in the editor
3. Click "Run" to execute the code
4. View results in the output panel
5. Use Quick Tips for guidance on common patterns
## Development
The playground executes code in a sandboxed environment with access to the Base Pay SDK functions. Console output is captured and displayed in real-time.
@@ -0,0 +1,272 @@
.editorPanel {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
display: flex;
flex-direction: column;
height: fit-content;
}
.panelHeader {
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.panelTitle {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon {
width: 20px;
height: 20px;
color: #64748b;
}
.checkboxContainer {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: #475569;
}
.checkbox {
width: 16px;
height: 16px;
border: 2px solid #cbd5e1;
border-radius: 4px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.checkbox:checked {
background: #0052ff;
border-color: #0052ff;
}
.checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkboxText {
font-weight: 500;
user-select: none;
}
.editorWrapper {
position: relative;
height: 390px;
}
.codeEditor {
width: 100%;
height: 100%;
padding: 1.5rem;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
background: #0f172a;
color: #e2e8f0;
border: none;
resize: none;
outline: none;
}
.codeEditor::placeholder {
color: #475569;
}
.codeEditor:disabled {
opacity: 0.7;
}
.editorOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 90%, rgba(15, 23, 42, 0.1));
pointer-events: none;
}
.resetButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.resetButton:hover:not(:disabled) {
background: #e2e8f0;
color: #334155;
transform: translateY(-1px);
}
.resetButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.securityDisclaimer {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: #fef3c7;
border-top: 1px solid #fde68a;
border-bottom: 1px solid #fde68a;
}
.warningIcon {
width: 20px;
height: 20px;
color: #d97706;
flex-shrink: 0;
margin-top: 2px;
}
.disclaimerText {
font-size: 0.8125rem;
line-height: 1.5;
color: #92400e;
}
.disclaimerText strong {
font-weight: 600;
color: #78350f;
}
.executeButton {
margin: 1.5rem;
padding: 1rem 2rem;
background: #0052ff;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: calc(100% - 3rem);
}
.executeButton:hover:not(:disabled) {
background: #0041d0;
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 82, 255, 0.25);
}
.executeButton:active:not(:disabled) {
transform: translateY(0);
}
.executeButton:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.buttonIcon {
width: 18px;
height: 18px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive design */
@media (max-width: 1600px) {
.editorWrapper {
height: 260px;
}
}
@media (max-width: 768px) {
.panelHeader {
padding: 1rem;
}
.panelTitle {
font-size: 1rem;
}
.codeEditor {
padding: 1rem;
font-size: 13px;
}
.executeButton {
margin: 1rem;
padding: 0.875rem 1.5rem;
width: calc(100% - 2rem);
font-size: 0.875rem;
}
}
@media (max-width: 480px) {
.resetButton {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.buttonIcon {
width: 16px;
height: 16px;
}
.editorWrapper {
height: 195px;
}
.codeEditor {
font-size: 12px;
}
.executeButton {
padding: 0.75rem 1.25rem;
}
}
@@ -0,0 +1,126 @@
import styles from './CodeEditor.module.css';
interface CodeEditorProps {
code: string;
onChange: (code: string) => void;
onExecute: () => void;
onReset: () => void;
isLoading: boolean;
includePayerInfo: boolean;
onPayerInfoToggle: (checked: boolean) => void;
showPayerInfoToggle?: boolean;
}
export const CodeEditor = ({
code,
onChange,
onExecute,
onReset,
isLoading,
includePayerInfo,
onPayerInfoToggle,
showPayerInfoToggle = true,
}: CodeEditorProps) => {
return (
<div className={styles.editorPanel}>
<div className={styles.panelHeader}>
<h2 className={styles.panelTitle}>
<svg
className={styles.icon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2l6 6v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2h8z" />
<path d="M14 2v6h6" />
<path d="M16 13H8" />
<path d="M16 17H8" />
<path d="M10 9H8" />
</svg>
Code Editor
</h2>
<button onClick={onReset} className={styles.resetButton} disabled={isLoading}>
<svg
className={styles.buttonIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 102.13-9.36L1 10" />
</svg>
Reset
</button>
</div>
{showPayerInfoToggle && (
<div className={styles.checkboxContainer}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={includePayerInfo}
onChange={(e) => onPayerInfoToggle(e.target.checked)}
disabled={isLoading}
className={styles.checkbox}
/>
<span className={styles.checkboxText}>Include payer info</span>
</label>
</div>
)}
<div className={styles.editorWrapper}>
<textarea
value={code}
onChange={(e) => onChange(e.target.value)}
className={styles.codeEditor}
spellCheck={false}
disabled={isLoading}
placeholder="Enter your code here..."
/>
<div className={styles.editorOverlay}></div>
</div>
<div className={styles.securityDisclaimer}>
<svg
className={styles.warningIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<div className={styles.disclaimerText}>
<strong>Warning:</strong> Intended for testing purposes only. Never paste code from
untrusted sources. Can lose real funds if you are not using testnet.
</div>
</div>
<button onClick={onExecute} disabled={isLoading} className={styles.executeButton}>
{isLoading ? (
<>
<span className={styles.spinner}></span>
Executing...
</>
) : (
<>
<svg
className={styles.buttonIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
Execute Code
</>
)}
</button>
</div>
);
};
@@ -0,0 +1,70 @@
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 2rem 0;
margin-bottom: 2rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.headerContent {
width: 100%;
margin: 0 auto;
padding: 0 3rem;
}
.title {
margin: 0;
font-size: 2.5rem;
font-weight: 800;
color: #0f172a;
display: flex;
align-items: center;
gap: 0.75rem;
}
.titleIcon {
font-size: 2.5rem;
}
.subtitle {
margin: 0.5rem 0 0;
font-size: 1.125rem;
color: #64748b;
}
/* Responsive design */
@media (max-width: 768px) {
.header {
padding: 1.5rem 0;
}
.headerContent {
padding: 0 1rem;
}
.title {
font-size: 2rem;
}
.titleIcon {
font-size: 2rem;
}
.subtitle {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.title {
font-size: 1.5rem;
}
.titleIcon {
font-size: 1.5rem;
}
.subtitle {
font-size: 0.875rem;
}
}
@@ -0,0 +1,11 @@
import styles from './Header.module.css';
export const Header = () => {
return (
<header className={styles.header}>
<div className={styles.headerContent}>
<h1 className={styles.title}>Base Pay SDK Playground</h1>
</div>
</header>
);
};
@@ -0,0 +1,409 @@
.outputPanel {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panelHeader {
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.panelTitle {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon {
width: 20px;
height: 20px;
color: #64748b;
}
.outputContent {
padding: 1.5rem;
min-height: 800px;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Result cards */
.resultCard {
border-radius: 8px;
overflow: hidden;
border: 1px solid;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.resultCard.success {
background: #f0fdf4;
border-color: #86efac;
}
.resultCard.error {
background: #fef2f2;
border-color: #fca5a5;
}
.resultCard.pending {
background: #fef3c7;
border-color: #fcd34d;
}
.resultCard.notFound {
background: #f3f4f6;
border-color: #d1d5db;
}
.resultCard.info {
background: #f0f9ff;
border-color: #93c5fd;
}
.resultHeader {
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
}
.resultCard.success .resultHeader {
background: #dcfce7;
color: #166534;
}
.resultCard.error .resultHeader {
background: #fee2e2;
color: #991b1b;
}
.resultCard.pending .resultHeader {
background: #fde68a;
color: #92400e;
}
.resultCard.notFound .resultHeader {
background: #e5e7eb;
color: #374151;
}
.resultCard.info .resultHeader {
background: #dbeafe;
color: #1e40af;
}
.resultIcon {
width: 20px;
height: 20px;
}
.resultTitle {
font-size: 1rem;
}
.resultBody {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.resultRow {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.resultLabel {
font-size: 0.875rem;
color: #64748b;
font-weight: 500;
}
.resultValue {
font-size: 0.875rem;
color: #0f172a;
word-break: break-all;
}
code.resultValue {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-block;
}
.errorMessage {
color: #dc2626;
font-size: 0.875rem;
}
/* Stack trace for debugging */
.stackTrace {
margin-top: 0.75rem;
border-top: 1px solid #e2e8f0;
padding-top: 0.75rem;
}
.stackTrace summary {
cursor: pointer;
user-select: none;
font-size: 0.875rem;
color: #64748b;
font-weight: 500;
}
.stackTrace summary:hover {
color: #475569;
}
.stackContent {
margin-top: 0.5rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 0.75rem;
line-height: 1.4;
color: #475569;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* User data section */
.userDataSection {
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.userDataHeader {
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: #475569;
background: #f1f5f9;
}
.userDataIcon {
width: 18px;
height: 18px;
color: #64748b;
}
.userDataBody {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.userDataRow {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
align-items: start;
}
.userDataLabel {
font-size: 0.875rem;
color: #64748b;
font-weight: 500;
}
.userDataValue {
font-size: 0.875rem;
color: #0f172a;
word-break: break-word;
}
code.userDataValue {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
background: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-block;
border: 1px solid #e2e8f0;
}
/* Console output */
.consoleCard {
background: #1e293b;
border-radius: 8px;
overflow: hidden;
animation: slideIn 0.3s ease-out;
display: flex;
flex-direction: column;
max-height: 400px;
}
.consoleHeader {
padding: 1rem 1.5rem;
background: #0f172a;
color: #e2e8f0;
font-weight: 600;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.consoleHeader .icon {
color: #94a3b8;
}
.consoleBody {
margin: 0;
padding: 1.5rem;
color: #e2e8f0;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
flex: 1;
min-height: 100px;
}
/* Empty state */
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: #94a3b8;
text-align: center;
}
.emptyIcon {
width: 48px;
height: 48px;
color: #cbd5e1;
}
.emptyState p {
margin: 0;
font-size: 0.875rem;
}
/* Responsive design */
@media (max-width: 1600px) {
.outputContent {
min-height: 350px;
max-height: 60vh;
}
.consoleCard {
max-height: 300px;
}
}
@media (max-width: 768px) {
.panelHeader {
padding: 1rem;
}
.panelTitle {
font-size: 1rem;
}
.outputContent {
padding: 1rem;
min-height: 300px;
}
.resultHeader {
padding: 0.75rem 1rem;
}
.resultBody {
padding: 1rem;
}
.userDataHeader {
padding: 0.75rem 1rem;
}
.userDataBody {
padding: 1rem;
}
.userDataRow {
grid-template-columns: 100px 1fr;
}
.consoleCard {
max-height: 250px;
}
.consoleHeader {
padding: 0.75rem 1rem;
}
.consoleBody {
padding: 1rem;
font-size: 12px;
min-height: 80px;
}
}
@media (max-width: 480px) {
.resultRow {
font-size: 0.75rem;
}
.resultLabel {
font-size: 0.75rem;
}
.resultValue {
font-size: 0.75rem;
}
.userDataRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.userDataLabel {
font-size: 0.75rem;
}
.userDataValue {
font-size: 0.75rem;
}
}
@@ -0,0 +1,431 @@
import type { PaymentResult, PaymentStatus } from '@base-org/account';
import styles from './Output.module.css';
interface OutputProps {
result: PaymentResult | PaymentStatus | null;
error: string | null;
consoleOutput: string[];
isLoading: boolean;
}
// Type guard to check if result is PaymentResult
const isPaymentResult = (result: unknown): result is PaymentResult => {
return result !== null && typeof result === 'object' && 'success' in result;
};
// Type guard to check if result is PaymentStatus
const isPaymentStatus = (result: unknown): result is PaymentStatus => {
return result !== null && typeof result === 'object' && 'status' in result && 'id' in result;
};
export const Output = ({ result, error, consoleOutput, isLoading }: OutputProps) => {
return (
<div className={styles.outputPanel}>
<div className={styles.panelHeader}>
<h2 className={styles.panelTitle}>
<svg
className={styles.icon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M9 9l6 6" />
<path d="M15 9l-6 6" />
</svg>
Output
</h2>
</div>
<div className={styles.outputContent}>
{result && isPaymentResult(result) && (
<div className={`${styles.resultCard} ${result.success ? styles.success : styles.error}`}>
<div className={styles.resultHeader}>
{result.success ? (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span className={styles.resultTitle}>Payment Successful!</span>
</>
) : (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span className={styles.resultTitle}>Payment Failed</span>
</>
)}
</div>
<div className={styles.resultBody}>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Amount</span>
<span className={styles.resultValue}>{result.amount} USDC</span>
</div>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Recipient</span>
<code className={styles.resultValue}>{result.to}</code>
</div>
{result.success && result.id && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Transaction ID</span>
<code className={styles.resultValue}>{result.id}</code>
</div>
)}
{!result.success && 'error' in result && (result as { error?: string }).error && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Error</span>
<span className={styles.errorMessage}>
{(result as { error?: string }).error}
</span>
</div>
)}
</div>
{result.success && result.payerInfoResponses && (
<div className={styles.userDataSection}>
<div className={styles.userDataHeader}>
<svg
className={styles.userDataIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>User Info</span>
</div>
<div className={styles.userDataBody}>
{result.payerInfoResponses.name && (
<div className={styles.userDataRow}>
<span className={styles.userDataLabel}>Name</span>
<span className={styles.userDataValue}>
{(() => {
const name = result.payerInfoResponses.name as unknown as {
firstName: string;
familyName: string;
};
return `${name.firstName} ${name.familyName}`;
})()}
</span>
</div>
)}
{result.payerInfoResponses.email && (
<div className={styles.userDataRow}>
<span className={styles.userDataLabel}>Email</span>
<span className={styles.userDataValue}>
{result.payerInfoResponses.email}
</span>
</div>
)}
{result.payerInfoResponses.phoneNumber && (
<div className={styles.userDataRow}>
<span className={styles.userDataLabel}>Phone</span>
<span className={styles.userDataValue}>
{result.payerInfoResponses.phoneNumber.number} (
{result.payerInfoResponses.phoneNumber.country})
</span>
</div>
)}
{result.payerInfoResponses.physicalAddress && (
<div className={styles.userDataRow}>
<span className={styles.userDataLabel}>Address</span>
<span className={styles.userDataValue}>
{(() => {
const addr = result.payerInfoResponses.physicalAddress as unknown as {
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
countryCode: string;
name?: {
firstName: string;
familyName: string;
};
};
const parts = [
addr.name ? `${addr.name.firstName} ${addr.name.familyName}` : null,
addr.address1,
addr.address2,
`${addr.city}, ${addr.state} ${addr.postalCode}`,
addr.countryCode,
].filter(Boolean);
return parts.join(', ');
})()}
</span>
</div>
)}
{result.payerInfoResponses.onchainAddress && (
<div className={styles.userDataRow}>
<span className={styles.userDataLabel}>On-chain Address</span>
<span className={styles.userDataValue}>
{result.payerInfoResponses.onchainAddress}
</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{result && isPaymentStatus(result) && (
<div
className={`${styles.resultCard} ${
result.status === 'completed'
? styles.success
: result.status === 'pending'
? styles.pending
: result.status === 'failed'
? styles.error
: styles.notFound
}`}
>
<div className={styles.resultHeader}>
{result.status === 'completed' && (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span className={styles.resultTitle}>Payment Completed</span>
</>
)}
{result.status === 'pending' && (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className={styles.resultTitle}>Payment Pending</span>
</>
)}
{result.status === 'failed' && (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span className={styles.resultTitle}>Payment Failed</span>
</>
)}
{result.status === 'not_found' && (
<>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className={styles.resultTitle}>Payment Not Found</span>
</>
)}
</div>
<div className={styles.resultBody}>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Status</span>
<span className={styles.resultValue}>{result.status}</span>
</div>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Transaction ID</span>
<code className={styles.resultValue}>{result.id}</code>
</div>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Message</span>
<span className={styles.resultValue}>{result.message}</span>
</div>
{result.sender && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Sender</span>
<code className={styles.resultValue}>{result.sender}</code>
</div>
)}
{result.amount && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Amount</span>
<span className={styles.resultValue}>{result.amount} USDC</span>
</div>
)}
{result.recipient && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Recipient</span>
<code className={styles.resultValue}>{result.recipient}</code>
</div>
)}
{'error' in result && (result as { error?: string }).error && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Error</span>
<span className={styles.errorMessage}>
{(result as { error?: string }).error}
</span>
</div>
)}
</div>
</div>
)}
{error && (
<div className={`${styles.resultCard} ${styles.error}`}>
<div className={styles.resultHeader}>
<svg
className={styles.resultIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span className={styles.resultTitle}>Execution Error</span>
</div>
<div className={styles.resultBody}>
{(() => {
try {
// Try to parse the error as JSON for structured display
const errorDetails = JSON.parse(error);
return (
<>
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Error</span>
<span className={styles.errorMessage}>{errorDetails.message}</span>
</div>
{errorDetails.type && errorDetails.type !== 'unknown' && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Type</span>
<span className={styles.resultValue}>{errorDetails.type}</span>
</div>
)}
{errorDetails.code && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Code</span>
<span className={styles.resultValue}>{errorDetails.code}</span>
</div>
)}
{errorDetails.statusCode && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Status Code</span>
<span className={styles.resultValue}>{errorDetails.statusCode}</span>
</div>
)}
{errorDetails.details && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Details</span>
<span className={styles.resultValue}>
{typeof errorDetails.details === 'string'
? errorDetails.details
: JSON.stringify(errorDetails.details, null, 2)}
</span>
</div>
)}
{errorDetails.response && (
<div className={styles.resultRow}>
<span className={styles.resultLabel}>Response</span>
<span className={styles.resultValue}>
{typeof errorDetails.response === 'string'
? errorDetails.response
: JSON.stringify(errorDetails.response, null, 2)}
</span>
</div>
)}
{process.env.NODE_ENV === 'development' && errorDetails.stack && (
<details className={styles.stackTrace}>
<summary className={styles.resultLabel}>Stack Trace</summary>
<pre className={styles.stackContent}>{errorDetails.stack}</pre>
</details>
)}
</>
);
} catch (_parseError) {
// If parsing fails, display as plain text (backward compatibility)
return <div className={styles.errorMessage}>{error}</div>;
}
})()}
</div>
</div>
)}
{consoleOutput.length > 0 && (
<div className={styles.consoleCard}>
<div className={styles.consoleHeader}>
<svg
className={styles.icon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
Console Output
</div>
<pre className={styles.consoleBody}>{consoleOutput.join('\n')}</pre>
</div>
)}
{!result && !error && !isLoading && consoleOutput.length === 0 && (
<div className={styles.emptyState}>
<svg
className={styles.emptyIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M9 9h6v6H9z" />
</svg>
<p>Click &quot;Execute Code&quot; to run your code</p>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,67 @@
.quickTips {
margin-top: 1.5rem;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.tipsTitle {
margin: 0 0 1.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tipsIcon {
width: 20px;
height: 20px;
color: #64748b;
flex-shrink: 0;
}
.tipsList {
margin: 0;
padding-left: 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tipItem {
color: #475569;
line-height: 1.6;
}
.tipItem :global(code) {
background: #f1f5f9;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
color: #0f172a;
}
/* Responsive design */
@media (max-width: 768px) {
.quickTips {
padding: 1.5rem;
}
.tipsTitle {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.tipsList {
padding-left: 1.5rem;
gap: 0.5rem;
}
.tipItem {
font-size: 0.875rem;
}
}
@@ -0,0 +1,56 @@
import styles from './QuickTips.module.css';
interface QuickTipsProps {
tips: string[];
}
// Helper function to safely render tips with links
const renderTip = (tip: string) => {
// Check if the tip contains an HTML link
const linkMatch = tip.match(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/);
if (linkMatch) {
const [fullMatch, href, text] = linkMatch;
const parts = tip.split(fullMatch);
return (
<>
{parts[0]}
<a href={href} target="_blank" rel="noopener noreferrer">
{text}
</a>
{parts[1]}
</>
);
}
return tip;
};
export const QuickTips = ({ tips }: QuickTipsProps) => {
return (
<div className={styles.quickTips}>
<h3 className={styles.tipsTitle}>
<svg
className={styles.tipsIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
Quick Tips
</h3>
<ul className={styles.tipsList}>
{tips.map((tip, index) => (
<li key={index} className={styles.tipItem}>
{renderTip(tip)}
</li>
))}
</ul>
</div>
);
};
@@ -0,0 +1,4 @@
export { CodeEditor } from './CodeEditor';
export { Header } from './Header';
export { Output } from './Output';
export { QuickTips } from './QuickTips';
@@ -0,0 +1,8 @@
export {
DEFAULT_GET_PAYMENT_STATUS_CODE,
DEFAULT_PAY_CODE,
GET_PAYMENT_STATUS_QUICK_TIPS,
PAY_CODE_WITH_PAYER_INFO,
PAY_QUICK_TIPS,
QUICK_TIPS,
} from './playground';
@@ -0,0 +1,71 @@
export const DEFAULT_PAY_CODE = `import { base } from '@base-org/account'
try {
const result = await base.pay({
amount: '.01',
to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
testnet: true
})
return result;
} catch (error) {
console.error('Payment failed:', error.message);
throw error;
}`;
export const PAY_CODE_WITH_PAYER_INFO = `import { base } from '@base-org/account'
try {
const result = await base.pay({
amount: '.01',
to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
testnet: true,
payerInfo: {
requests: [
{ type: 'name'},
{ type: 'email' },
{ type: 'phoneNumber', optional: true },
{ type: 'physicalAddress', optional: true },
{ type: 'onchainAddress' }
]
}
})
return result;
} catch (error) {
console.error('Payment failed:', error.message);
throw error;
}`;
export const DEFAULT_GET_PAYMENT_STATUS_CODE = `import { base } from '@base-org/account'
try {
const result = await base.getPaymentStatus({
id: '0x...', // Automatically filled with your recent transaction
testnet: true
})
return result;
} catch (error) {
// This will catch network errors if any occur
console.error('Failed to check payment status:', error.message);
throw error;
}`;
export const PAY_QUICK_TIPS = [
'Get testnet ETH at <a href="https://faucet.circle.com/" target="_blank" rel="noopener noreferrer">https://faucet.circle.com/</a> - select "Base Sepolia" as the network',
'testnet (`true`) toggles base sepolia testnet',
'Amount is in USDC (e.g., "1" = $1 of USDC)',
'Only USDC on base and base sepolia are supported',
'Use payerInfo to request user information.',
];
export const GET_PAYMENT_STATUS_QUICK_TIPS = [
'Use an `id` returned from the pay function',
'Status can be: pending, completed, failed, or not_found',
'For completed payments, you can see the amount and recipient',
'For failed payments, you can see the failure reason',
'Make sure to use the same testnet setting as the original payment',
];
export const QUICK_TIPS = PAY_QUICK_TIPS;
@@ -0,0 +1,2 @@
export { useCodeExecution } from './useCodeExecution';
export { useConsoleCapture } from './useConsoleCapture';
@@ -0,0 +1,156 @@
import type { PaymentResult, PaymentStatus } from '@base-org/account';
import { getPaymentStatus, pay } from '@base-org/account';
import { useCallback, useState } from 'react';
import { transformAndSanitizeCode } from '../utils/codeTransform';
import { useConsoleCapture } from './useConsoleCapture';
export const useCodeExecution = () => {
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<PaymentResult | PaymentStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [consoleOutput, setConsoleOutput] = useState<string[]>([]);
const { captureConsole } = useConsoleCapture();
const executeCode = useCallback(
async (code: string) => {
setIsLoading(true);
setResult(null);
setError(null);
setConsoleOutput([]);
const logs: string[] = [];
const restoreConsole = captureConsole((message) => logs.push(message));
try {
// Sanitize and validate the code first
const sanitizationResult = transformAndSanitizeCode(code);
if (!sanitizationResult.isValid) {
// Format validation errors for display
const errorMessages = sanitizationResult.errors
.map((err) => {
if (err.line) {
return `Line ${err.line}: ${err.message}`;
}
return err.message;
})
.join('\n');
setError(`Code validation failed:\n${errorMessages}`);
setConsoleOutput(logs);
return;
}
// Create a function from the sanitized code with additional context
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
// Create a context object with commonly used utilities
const context = {
// Individual functions for direct access
pay,
getPaymentStatus,
// Namespaced access via base object
base: {
pay,
getPaymentStatus,
},
};
// Use the sanitized code
const sanitizedCode = sanitizationResult.code;
// Create function with destructured context
const userFunction = new AsyncFunction(...Object.keys(context), sanitizedCode);
const paymentResult = await userFunction(...Object.values(context));
// biome-ignore lint/suspicious/noConsole: Useful for debugging payment results
console.log('Payment result:', paymentResult);
setResult(paymentResult);
setConsoleOutput(logs);
} catch (error) {
console.error('Execution error:', error);
// Create a more detailed error object/message
interface ErrorDetails {
message: string;
type: string;
details?: unknown;
code?: string | number;
response?: unknown;
statusCode?: number;
stack?: string;
}
let errorDetails: ErrorDetails = {
message: 'Unknown error occurred',
type: 'unknown',
details: null,
};
if (error instanceof Error) {
errorDetails.message = error.message;
errorDetails.type = error.name || 'Error';
// Check if the error has additional properties (common in SDK errors)
const errorObj = error as {
code?: string | number;
details?: unknown;
response?: unknown;
statusCode?: number;
};
if (errorObj.code) {
errorDetails.code = errorObj.code;
}
if (errorObj.details) {
errorDetails.details = errorObj.details;
}
if (errorObj.response) {
errorDetails.response = errorObj.response;
}
if (errorObj.statusCode) {
errorDetails.statusCode = errorObj.statusCode;
}
// For stack traces in development
if (process.env.NODE_ENV === 'development' && error.stack) {
errorDetails.stack = error.stack;
}
} else if (typeof error === 'string') {
errorDetails.message = error;
errorDetails.type = 'string';
} else if (error && typeof error === 'object') {
// Handle plain objects that might be thrown
errorDetails = {
...errorDetails,
...error,
message: (error as { message?: string }).message || JSON.stringify(error),
};
}
// Set error as JSON string to preserve structure
setError(JSON.stringify(errorDetails));
setConsoleOutput(logs);
} finally {
restoreConsole();
setIsLoading(false);
}
},
[captureConsole]
);
const reset = useCallback(() => {
setResult(null);
setError(null);
setConsoleOutput([]);
}, []);
return {
isLoading,
result,
error,
consoleOutput,
executeCode,
reset,
};
};
@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { safeStringify } from '../utils/codeTransform';
export const useConsoleCapture = () => {
const captureConsole = useCallback((onLog: (message: string) => void) => {
const originalLog = console.log;
const originalError = console.error;
const formatArgs = (args: unknown[]) => {
return args
.map((arg) => {
if (typeof arg === 'object' && arg !== null) {
try {
return safeStringify(arg);
} catch (_e) {
return '[Object with circular reference]';
}
}
return String(arg);
})
.join(' ');
};
console.log = (...args) => {
onLog(formatArgs(args));
originalLog(...args);
};
console.error = (...args) => {
onLog(`ERROR: ${formatArgs(args)}`);
originalError(...args);
};
// Return cleanup function
return () => {
console.log = originalLog;
console.error = originalError;
};
}, []);
return { captureConsole };
};
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { CodeEditor, Header, Output, QuickTips } from './components';
import {
DEFAULT_GET_PAYMENT_STATUS_CODE,
DEFAULT_PAY_CODE,
GET_PAYMENT_STATUS_QUICK_TIPS,
PAY_CODE_WITH_PAYER_INFO,
PAY_QUICK_TIPS,
} from './constants';
import { useCodeExecution } from './hooks';
import styles from './styles/Home.module.css';
function PayPlayground() {
const [includePayerInfo, setIncludePayerInfo] = useState(false);
const [payCode, setPayCode] = useState(DEFAULT_PAY_CODE);
const [getPaymentStatusCode, setGetPaymentStatusCode] = useState(DEFAULT_GET_PAYMENT_STATUS_CODE);
const payExecution = useCodeExecution();
const getPaymentStatusExecution = useCodeExecution();
const handlePayExecute = () => {
payExecution.executeCode(payCode);
};
const handlePayReset = () => {
setIncludePayerInfo(false);
setPayCode(DEFAULT_PAY_CODE);
payExecution.reset();
};
const handlePayerInfoToggle = (checked: boolean) => {
setIncludePayerInfo(checked);
const newCode = checked ? PAY_CODE_WITH_PAYER_INFO : DEFAULT_PAY_CODE;
setPayCode(newCode);
payExecution.reset();
};
const handleGetPaymentStatusExecute = () => {
getPaymentStatusExecution.executeCode(getPaymentStatusCode);
};
const handleGetPaymentStatusReset = () => {
setGetPaymentStatusCode(DEFAULT_GET_PAYMENT_STATUS_CODE);
getPaymentStatusExecution.reset();
};
// Watch for successful payment results and update getPaymentStatus code with the transaction ID
useEffect(() => {
if (
payExecution.result &&
'success' in payExecution.result &&
payExecution.result.success &&
payExecution.result.id
) {
const transactionId = payExecution.result.id;
const updatedCode = `import { base } from '@base-org/account'
try {
const result = await base.getPaymentStatus({
id: '${transactionId}', // Automatically filled with your recent transaction
testnet: true
})
return result;
} catch (error) {
// This will catch network errors if any occur
console.error('Failed to check payment status:', error.message);
throw error;
}`;
setGetPaymentStatusCode(updatedCode);
}
}, [payExecution.result]);
return (
<div className={styles.container}>
<Header />
<main className={styles.main}>
{/* pay Section */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>pay Function</h2>
<p className={styles.sectionDescription}>Send USDC payments on Base</p>
<div className={styles.playground}>
<div className={styles.leftColumn}>
<CodeEditor
code={payCode}
onChange={setPayCode}
onExecute={handlePayExecute}
onReset={handlePayReset}
isLoading={payExecution.isLoading}
includePayerInfo={includePayerInfo}
onPayerInfoToggle={handlePayerInfoToggle}
showPayerInfoToggle={true}
/>
<QuickTips tips={PAY_QUICK_TIPS} />
</div>
<div className={styles.rightColumn}>
<Output
result={payExecution.result}
error={payExecution.error}
consoleOutput={payExecution.consoleOutput}
isLoading={payExecution.isLoading}
/>
</div>
</div>
</section>
{/* getPaymentStatus Section */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>getPaymentStatus Function</h2>
<p className={styles.sectionDescription}>Check the status of a payment</p>
<div className={styles.playground}>
<div className={styles.leftColumn}>
<CodeEditor
code={getPaymentStatusCode}
onChange={setGetPaymentStatusCode}
onExecute={handleGetPaymentStatusExecute}
onReset={handleGetPaymentStatusReset}
isLoading={getPaymentStatusExecution.isLoading}
includePayerInfo={false}
onPayerInfoToggle={() => {}}
showPayerInfoToggle={false}
/>
<QuickTips tips={GET_PAYMENT_STATUS_QUICK_TIPS} />
</div>
<div className={styles.rightColumn}>
<Output
result={getPaymentStatusExecution.result}
error={getPaymentStatusExecution.error}
consoleOutput={getPaymentStatusExecution.consoleOutput}
isLoading={getPaymentStatusExecution.isLoading}
/>
</div>
</div>
</section>
</main>
</div>
);
}
// Custom layout for this page - no app header
PayPlayground.getLayout = function getLayout(page) {
return page;
};
export default PayPlayground;
@@ -0,0 +1,82 @@
.container {
min-height: 100vh;
background: #f8fafc;
color: #1e293b;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
}
.main {
width: 100%;
margin: 0 auto;
padding: 0 3rem 4rem;
}
.section {
margin-bottom: 4rem;
}
.sectionTitle {
font-size: 2rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.5rem;
}
.sectionDescription {
font-size: 1.125rem;
color: #64748b;
margin-bottom: 2rem;
}
.playground {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
align-items: start;
}
.leftColumn {
display: flex;
flex-direction: column;
gap: 0;
}
.rightColumn {
display: flex;
flex-direction: column;
}
/* Responsive design */
@media (max-width: 1600px) {
.playground {
grid-template-columns: 1fr;
}
.leftColumn {
margin-bottom: 2rem;
}
}
@media (max-width: 768px) {
.main {
padding: 0 1rem 3rem;
}
.playground {
gap: 1.5rem;
}
.section {
margin-bottom: 3rem;
}
.sectionTitle {
font-size: 1.5rem;
}
.sectionDescription {
font-size: 1rem;
}
}
@@ -0,0 +1,388 @@
import * as acorn from 'acorn';
// Define the whitelist of allowed operations
export const WHITELIST = {
// Allowed SDK functions
allowedFunctions: ['pay', 'getPaymentStatus'],
// Allowed object properties and methods
allowedObjects: {
base: ['pay', 'getPaymentStatus'],
console: ['log', 'error', 'warn', 'info'],
Promise: ['resolve', 'reject', 'all', 'race'],
Object: ['keys', 'values', 'entries', 'assign'],
Array: ['isArray', 'from'],
JSON: ['stringify', 'parse'],
Math: ['floor', 'ceil', 'round', 'min', 'max', 'abs'],
} as Record<string, string[]>,
// Allowed keywords and statements
allowedStatements: [
'VariableDeclaration',
'VariableDeclarator', // Added: Part of variable declarations
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
'BlockStatement',
'ExpressionStatement',
'ReturnStatement',
'IfStatement',
'TryStatement',
'CatchClause',
'ThrowStatement',
'AwaitExpression',
'CallExpression',
'MemberExpression',
'Identifier',
'Literal',
'TemplateLiteral',
'TemplateElement', // Added: Part of template literals
'ObjectExpression',
'ArrayExpression',
'Property',
'AssignmentExpression',
'BinaryExpression',
'UnaryExpression',
'ConditionalExpression',
'LogicalExpression',
'UpdateExpression',
'SpreadElement',
'ForStatement', // Added: For loops
'ForInStatement', // Added: For-in loops
'ForOfStatement', // Added: For-of loops
'WhileStatement', // Added: While loops
'DoWhileStatement', // Added: Do-while loops
'BreakStatement', // Added: Break statements
'ContinueStatement', // Added: Continue statements
'SwitchStatement', // Added: Switch statements
'SwitchCase', // Added: Switch cases
'AssignmentPattern', // Added: Destructuring assignments
'ObjectPattern', // Added: Object destructuring
'ArrayPattern', // Added: Array destructuring
'RestElement', // Added: Rest parameters
'ThisExpression', // Added: 'this' keyword
'ChainExpression', // Added: Optional chaining
'OptionalMemberExpression', // Added: Optional member access
'OptionalCallExpression', // Added: Optional function calls
'SequenceExpression', // Added: Comma operator
],
// Disallowed global objects and functions
disallowedGlobals: [
'eval',
'Function',
'AsyncFunction',
'GeneratorFunction',
'AsyncGeneratorFunction',
'require',
'import',
'export',
'process',
'global',
'window',
'document',
'XMLHttpRequest',
'fetch',
'WebSocket',
'Worker',
'SharedWorker',
'ServiceWorker',
'localStorage',
'sessionStorage',
'indexedDB',
'crypto',
'location',
'history',
'navigator',
'__dirname',
'__filename',
'module',
'exports',
'Buffer',
'setInterval',
'setTimeout',
'setImmediate',
'clearInterval',
'clearTimeout',
'clearImmediate',
],
};
interface ValidationError {
message: string;
line?: number;
column?: number;
}
interface ASTNode {
body?: ASTNode[];
type?: string;
loc?: { start: { line: number; column: number } };
callee?: ASTNode;
object?: ASTNode;
property?: ASTNode;
name?: string;
value?: unknown;
computed?: boolean;
[key: string]: unknown;
}
export class CodeSanitizer {
private errors: ValidationError[] = [];
/**
* Sanitize and validate the code based on whitelist
*/
sanitize(code: string): {
isValid: boolean;
sanitizedCode: string;
errors: ValidationError[];
} {
this.errors = [];
try {
// First, apply basic sanitization (remove imports, etc.)
const preSanitized = this.applySanitization(code);
// Wrap the code in an async function for parsing
// This allows return statements and await expressions at the top level
const wrappedCode = `async function __userCode__() {\n${preSanitized}\n}`;
// Parse the wrapped code into an AST
const ast = acorn.parse(wrappedCode, {
ecmaVersion: 2020,
sourceType: 'module',
locations: true,
});
// Extract the function body for validation
// The AST structure will be: Program -> FunctionDeclaration -> BlockStatement
const program = ast as acorn.Program;
const functionNode = program.body[0] as unknown as ASTNode;
if (functionNode && functionNode.body) {
// Validate the function body
this.validateNode(functionNode.body as unknown as ASTNode);
}
// If validation passes, return the sanitized code
if (this.errors.length === 0) {
return {
isValid: true,
sanitizedCode: preSanitized,
errors: [],
};
}
return {
isValid: false,
sanitizedCode: '',
errors: this.errors,
};
} catch (error) {
// Parse error - try to extract meaningful line number
if (error instanceof SyntaxError) {
const match = error.message.match(/\((\d+):(\d+)\)/);
let line;
let column;
if (match) {
// Adjust line number since we wrapped the code
line = Number.parseInt(match[1]) - 1;
column = Number.parseInt(match[2]);
}
this.errors.push({
message: error.message.replace(/\(\d+:\d+\)/, line ? `(${line}:${column})` : ''),
line,
column,
});
} else {
this.errors.push({
message: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
});
}
return {
isValid: false,
sanitizedCode: '',
errors: this.errors,
};
}
}
/**
* Recursively validate AST nodes
*/
private validateNode(node: ASTNode): void {
if (!node) return;
// Check if the node type is allowed
if (!WHITELIST.allowedStatements.includes(node.type)) {
this.errors.push({
message: `Disallowed statement type: ${node.type}`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
return;
}
// Special validation for specific node types
switch (node.type) {
case 'CallExpression':
this.validateCallExpression(node);
break;
case 'MemberExpression':
this.validateMemberExpression(node);
break;
case 'Identifier':
this.validateIdentifier(node);
break;
case 'NewExpression':
this.errors.push({
message: `Constructor calls are not allowed`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
return;
}
// Recursively validate child nodes
for (const key in node) {
if (key === 'loc' || key === 'range' || key === 'type') continue;
const child = node[key];
if (Array.isArray(child)) {
child.forEach((item) => {
if (typeof item === 'object' && item !== null) {
this.validateNode(item as ASTNode);
}
});
} else if (typeof child === 'object' && child !== null) {
this.validateNode(child as ASTNode);
}
}
}
/**
* Validate function calls
*/
private validateCallExpression(node: ASTNode): void {
// Check if it's a direct function call
if (node.callee.type === 'Identifier') {
const funcName = node.callee.name;
// Check if it's an allowed SDK function
if (!WHITELIST.allowedFunctions.includes(funcName)) {
// Check if it's a disallowed global
if (WHITELIST.disallowedGlobals.includes(funcName)) {
this.errors.push({
message: `Function '${funcName}' is not allowed`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
}
}
}
// Check if it's a method call
if (node.callee.type === 'MemberExpression') {
this.validateMemberExpression(node.callee);
}
}
/**
* Validate member expressions (object.property)
*/
private validateMemberExpression(node: ASTNode): void {
// Get the object name
let objectName = '';
if (node.object.type === 'Identifier') {
objectName = node.object.name;
} else if (
node.object.type === 'MemberExpression' &&
node.object.object.type === 'Identifier'
) {
objectName = node.object.object.name;
}
// Get the property name
let propertyName = '';
if (node.property.type === 'Identifier') {
propertyName = node.computed ? '' : node.property.name;
} else if (node.property.type === 'Literal') {
propertyName = String(node.property.value);
}
// Validate against whitelist
if (objectName && objectName in WHITELIST.allowedObjects) {
const allowedProps = WHITELIST.allowedObjects[objectName];
if (propertyName && !allowedProps.includes(propertyName)) {
this.errors.push({
message: `Property '${objectName}.${propertyName}' is not allowed`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
}
} else if (objectName && WHITELIST.disallowedGlobals.includes(objectName)) {
this.errors.push({
message: `Object '${objectName}' is not allowed`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
}
}
/**
* Validate identifiers
*/
private validateIdentifier(node: ASTNode): void {
// Skip validation for allowed functions and objects
if (WHITELIST.allowedFunctions.includes(node.name)) return;
if (node.name in WHITELIST.allowedObjects) return;
// Check for disallowed globals
if (WHITELIST.disallowedGlobals.includes(node.name)) {
this.errors.push({
message: `Identifier '${node.name}' is not allowed`,
line: node.loc?.start.line ? node.loc.start.line - 1 : undefined,
column: node.loc?.start.column,
});
}
}
/**
* Apply sanitization transformations to the code
*/
private applySanitization(code: string): string {
let sanitized = code;
// Remove import statements
sanitized = sanitized.replace(/^\s*import\s+.*?(?:from\s+['"][^'"]+['"])?[;\s]*$/gm, '');
// Remove multiline imports
sanitized = sanitized.replace(/^\s*import\s+[\s\S]*?from\s+['"][^'"]+['"]\s*;?\s*$/gm, '');
// Remove export statements
sanitized = sanitized.replace(/^\s*export\s+.*?[;\s]*$/gm, '');
// Clean up extra newlines
sanitized = sanitized.replace(/^\s*\n/gm, '');
return sanitized;
}
}
/**
* Convenience function to sanitize code
*/
export function sanitizeCode(code: string): {
isValid: boolean;
sanitizedCode: string;
errors: ValidationError[];
} {
const sanitizer = new CodeSanitizer();
return sanitizer.sanitize(code);
}
@@ -0,0 +1,51 @@
import { sanitizeCode } from './codeSanitizer';
// Transform and sanitize code with whitelist validation
export const transformAndSanitizeCode = (
code: string
): {
isValid: boolean;
code: string;
errors: Array<{ message: string; line?: number; column?: number }>;
} => {
// First, sanitize the code using whitelist validation
const sanitizationResult = sanitizeCode(code);
if (!sanitizationResult.isValid) {
return {
isValid: false,
code: '',
errors: sanitizationResult.errors,
};
}
return {
isValid: true,
code: sanitizationResult.sanitizedCode,
errors: [],
};
};
// Legacy function for backward compatibility (deprecated)
export const transformImports = (code: string): string => {
const result = transformAndSanitizeCode(code);
return result.isValid ? result.code : code;
};
// Helper function to safely stringify objects with circular references
export const safeStringify = (obj: unknown, indent = 2): string => {
const seen = new WeakSet();
return JSON.stringify(
obj,
(_key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
},
indent
);
};
@@ -0,0 +1,2 @@
export { CodeSanitizer, WHITELIST, sanitizeCode } from './codeSanitizer';
export { safeStringify, transformAndSanitizeCode, transformImports } from './codeTransform';
@@ -42,7 +42,11 @@ export default function Payment() {
const [payTo, setPayTo] = useState('0x0000000000000000000000000000000000000000');
const [payLoading, setPayLoading] = useState(false);
const [payResult, setPayResult] = useState<PaymentResult | null>(null);
const [payError, setPayError] = useState<any>(null);
const [payError, setPayError] = useState<{
error: string;
errorDetails?: unknown;
attemptedPayment?: unknown;
} | null>(null);
// Optional parameters state
const [customWalletUrl, setCustomWalletUrl] = useState('');
@@ -64,7 +68,9 @@ export default function Payment() {
const [statusId, setStatusId] = useState('');
const [statusLoading, setStatusLoading] = useState(false);
const [statusResult, setStatusResult] = useState<PaymentStatus | null>(null);
const [statusError, setStatusError] = useState<any>(null);
const [statusError, setStatusError] = useState<{ error: string; errorDetails?: unknown } | null>(
null
);
const [statusTestnet, setStatusTestnet] = useState(true); // Separate testnet state for status check
const handlePay = async () => {
+12 -1
View File
@@ -3246,6 +3246,15 @@ __metadata:
languageName: node
linkType: hard
"@types/acorn@npm:^4.0.6":
version: 4.0.6
resolution: "@types/acorn@npm:4.0.6"
dependencies:
"@types/estree": "npm:*"
checksum: 10/e00671d5055d06b07feccb8c2841467a4bdd1ab95a29e191d51cacc08c496e1ba1f54edeefab274bb2ba51cb45b0aaaa662a63897650e9d02e9997ad82124ae4
languageName: node
linkType: hard
"@types/aria-query@npm:^5.0.1":
version: 5.0.4
resolution: "@types/aria-query@npm:5.0.4"
@@ -3905,7 +3914,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.14.0":
"acorn@npm:^8.14.0, acorn@npm:^8.15.0":
version: 8.15.0
resolution: "acorn@npm:8.15.0"
bin:
@@ -8061,7 +8070,9 @@ __metadata:
"@emotion/styled": "npm:^11.11.0"
"@metamask/eth-sig-util": "npm:^7.0.0"
"@testing-library/react": "npm:^16.2.0"
"@types/acorn": "npm:^4.0.6"
"@types/react": "npm:18.2.15"
acorn: "npm:^8.15.0"
framer-motion: "npm:^10.13.1"
next: "npm:^14.2.10"
react: "npm:^18.2.0"