mirror of
https://github.com/hanzoai/account-sdk.git
synced 2026-09-24 16:55:42 +00:00
Add Pay Playground to testapp (#84)
* Add Pay Playground to testapp * slight ux polish * format * fix lint * fix typecheck
This commit is contained in:
@@ -41,5 +41,11 @@ examples/**/yarn.lock
|
||||
|
||||
# next.js
|
||||
.next
|
||||
out/
|
||||
.vercel
|
||||
|
||||
# testapp specific
|
||||
examples/testapp/out/
|
||||
examples/testapp/.next/
|
||||
|
||||
/**/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -185,6 +185,11 @@
|
||||
"expect"
|
||||
]
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "Execute Code" 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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user