Skip to content

Conversation

@apmcdermott
Copy link
Contributor

@apmcdermott apmcdermott commented Oct 30, 2025

Extracts the paywall from the monolithic legacy package into a standalone @x402/paywall package with modular architecture, builder pattern API, and tree-shakeable network-specific imports.

New Package: @x402/paywall

Location: typescript/packages/http/paywall/

Key Features:

  • Standalone paywall package with EVM and Solana support
  • Builder pattern for flexible configuration
  • Subpath exports for tree shaking: /evm and /svm
  • Backwards compatible with legacy x402/paywall API

Bundle Sizes:

  • Full paywall: 3.5 MB (both networks)
  • EVM only: 3.4 MB (probably due to OnchainKit... we should figure out a way to reduce this)
  • Solana only: 1.0 MB

Core Integration

@x402/core:

  • Added PaywallProvider interface
  • Added setPaywallProvider() method to x402HTTPResourceService
  • Auto-detects and uses @x402/paywall if installed, otherwise falls back to basic HTML

@x402/express:

  • Added optional PaywallProvider parameter to paymentMiddleware()

Architecture

@x402/paywall (main)
├── index.ts          - Legacy getPaywallHtml() + builder exports
├── builder.ts        - PaywallBuilder with withNetwork() and withConfig()
├── types.ts          - PaywallProvider, PaywallNetworkHandler interfaces
├── evm/
│   ├── index.ts      - evmPaywall handler
│   ├── EvmPaywall.tsx
│   └── build.ts      - Separate build for EVM-only bundle
└── svm/
    ├── index.ts      - svmPaywall handler
    ├── SolanaPaywall.tsx
    └── build.ts      - Separate build for Solana-only bundle

API

Legacy (Backwards Compatible)

import { getPaywallHtml } from '@x402/paywall';
const html = getPaywallHtml({...});

Builder Pattern

import { createPaywall } from '@x402/paywall';
import { evmPaywall } from '@x402/paywall/evm';

const paywall = createPaywall()
  .withNetwork(evmPaywall)
  .withConfig({ appName: 'My App', testnet: true })
  .build();

app.use(paymentMiddleware(routes, facs, schemes, undefined, paywall));

Network-Specific Imports

// EVM only - no Solana dependencies
import { evmPaywall } from '@x402/paywall/evm';

// Solana only - no EVM dependencies
import { svmPaywall } from '@x402/paywall/svm';

Migration Path

From legacy x402/paywall:

// Before
import { getPaywallHtml } from 'x402/paywall';

// After
import { getPaywallHtml } from '@x402/paywall';
// Same API - no code changes needed

Future Work

  • Refactor to use x402Client internally for cleaner architecture
  • Investigate OnchainKit bundle size optimizations

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits

0xvsr and others added 30 commits October 29, 2025 13:45
* Add Onchain to x402 Ecosystem - Services/Endpoints

* Add Onchain to x402 Ecosystem - Services/Endpoints
… payer validation tests and fix mocks to Instruction accounts array; chore: align high-level tests with signer-aware verification (#546)
* Improve Solana paywall wallet connection

* feat: prettier

* Feat: cleaaning the bip boop slop into cleaner components
Phase 1: Extract and modularize the x402 paywall

## Changes

- Create new @x402/paywall package at typescript/packages/http/paywall/
- Extract all paywall components from legacy x402 package:
  - EvmPaywall.tsx: EVM wallet connection and payment UI
  - SolanaPaywall.tsx: Solana wallet connection and payment UI
  - PaywallApp.tsx: Main app coordinator
  - Providers.tsx: OnchainKit provider setup
- Copy Solana-specific hooks and utilities to solana/ directory
- Setup esbuild to generate self-contained 3.4MB HTML template
- Export getPaywallHtml() function for generating paywall pages
- Update imports to use x402/types instead of @x402/core
- Configure package with proper TypeScript and ESLint settings

## Usage

```typescript
import { getPaywallHtml } from '@x402/paywall';

const html = getPaywallHtml({
  amount: 0.10,
  paymentRequirements: [...],
  currentUrl: 'https://api.example.com/data',
  testnet: true,
  cdpClientKey: 'your-key',
  appName: 'My App'
});

res.status(402).send(html);
```

## Testing

- Package builds successfully with pnpm build
- Generates 3.4MB self-contained HTML with all dependencies
- No linter errors
- Supports both EVM (Base) and Solana networks

## Next Steps

- Phase 2: Add builder pattern for composability
- Phase 3: Create network-specific subpath exports (evm, svm)
- Integrate with HTTP middleware packages (express, hono, next)
- Update @x402/core to automatically use @x402/paywall if installed
- Add optional peer dependency to @x402/express
- Falls back to basic HTML if paywall not installed
- Update Express README with paywall configuration options

How it works:
- If @x402/paywall is installed: Full wallet UI with EVM/Solana support
- If not installed: Basic HTML with note to install @x402/paywall
- Always backwards compatible with custom HTML templates

Usage:
```typescript
// Install paywall (optional)
pnpm add @x402/paywall

// Configure in middleware
app.use(paymentMiddleware(routes, facilitators, schemes, {
  cdpClientKey: 'key',
  appName: 'My App',
  testnet: true
}));
```

Phase 1 complete - SDKs now support modular paywall
Phase 2: Builder pattern implementation

## Changes

### @x402/paywall
- Add PaywallBuilder class with fluent API
- Add createPaywall() factory function
- Add PaywallProvider and PaywallConfig type definitions
- Export both legacy getPaywallHtml() and new builder API
- Add comprehensive unit tests (32 tests passing)

### @x402/core
- Add PaywallProvider interface to HTTP service
- Add setPaywallProvider() method to x402HTTPResourceService
- Update generatePaywallHTML() to support custom providers
- Export PaywallProvider type from server module
- Priority: Custom provider > @x402/paywall > Basic HTML fallback

### @x402/express
- Add optional PaywallProvider parameter to paymentMiddleware()
- Update to use custom provider if provided
- Export PaywallProvider and PaywallConfig types
- Update README with builder pattern examples

## Usage

### Legacy API (still works)
```typescript
import { getPaywallHtml } from '@x402/paywall';
const html = getPaywallHtml({...});
```

### Builder Pattern API (new)
```typescript
import { createPaywall } from '@x402/paywall';

const paywall = createPaywall()
  .withConfig({
    appName: 'My App',
    cdpClientKey: 'key',
    testnet: true
  })
  .build();

app.use(paymentMiddleware(routes, facilitators, schemes, undefined, paywall));
```

## Testing
- 32 unit tests passing (paywallUtils, builder)
- All packages build successfully
- Builder config merging tested
- Runtime config override tested
- Backwards compatibility verified

## Next Steps
Phase 3: Network-specific modules for bundle size reduction
Phase 2: Builder pattern and testing infrastructure

## Changes

### @x402/paywall
- Add builder.ts with PaywallBuilder class and createPaywall()
- Add types.ts with PaywallProvider, PaywallConfig, PaymentRequired
- Update index.ts to export both legacy and builder APIs
- Add builder.test.ts with 12 tests for builder functionality
- Add paywallUtils.test.ts with 19 tests for utility functions
- Update paywall.ts to use shared types

### @x402/core
- Add PaywallProvider interface to x402HTTPResourceService
- Add setPaywallProvider() method for custom providers
- Update generatePaywallHTML() to use custom provider first
- Export PaywallProvider from http and server modules

### @x402/express
- Update paymentMiddleware to accept optional PaywallProvider param
- Call setPaywallProvider() when custom provider is passed
- Export PaywallProvider and PaywallConfig types
- Update README with builder pattern examples

## API Comparison

### Legacy (still supported)
```typescript
import { getPaywallHtml } from '@x402/paywall';
const html = getPaywallHtml({...});
```

### Builder Pattern (new)
```typescript
import { createPaywall } from '@x402/paywall';
const paywall = createPaywall().withConfig({...}).build();
app.use(paymentMiddleware(routes, facs, schemes, undefined, paywall));
```

## Testing
- 32 unit tests passing (19 utils + 12 builder + 1 placeholder)
- Config merging tested
- Runtime override tested
- V1/V2 payment requirement parsing tested
- Network detection tested (EVM, Solana, CAIP-2)

## Size
Bundle still 3.4MB (both networks included)
Phase 3 will add network-specific imports for tree shaking
Phase 3: Network-specific paywalls with bundle size reduction

- Reorganize into evm/ and svm/ subdirectories
- Move EvmPaywall.tsx -> evm/EvmPaywall.tsx
- Move SolanaPaywall.tsx -> svm/SolanaPaywall.tsx
- Move Solana hooks -> svm/solana/
- Duplicate utilities into evm-utils.ts and svm-utils.ts

- Add evm/build.ts - Generates EVM-only template
- Add svm/build.ts - Generates Solana-only template
- Update main build.ts to orchestrate all three builds
- Add evm/index.tsx and svm/index.tsx entry points
- Configure tsup for multiple entry points with tree shaking

- Add PaywallNetworkHandler interface to types.ts
- Implement evmPaywall handler (supports eip155:* and legacy)
- Implement svmPaywall handler (supports solana:* and legacy)
- Update PaywallBuilder with withNetwork() method
- Add first-match selection strategy

Add package.json exports:
- @x402/paywall (main - full paywall)
- @x402/paywall/evm (EVM only)
- @x402/paywall/svm (Solana only)

- Add network-handlers.test.ts (8 tests)
- Add withNetwork() tests to builder.test.ts (5 tests)
- Test first-match selection
- Test network support detection
- All 45 tests passing

| Import | Size | Reduction |
|--------|------|-----------|
| @x402/paywall | 3.5MB | Baseline |
| @x402/paywall/evm | 3.4MB | ~3% |
| @x402/paywall/svm | 1.0MB | 71% ✅ |

SVM-only apps save 2.5MB!

```typescript
import { createPaywall } from '@x402/paywall';
import { evmPaywall } from '@x402/paywall/evm';

const paywall = createPaywall()
  .withNetwork(evmPaywall)
  .withConfig({ appName: 'My App' })
  .build();
```

```typescript
import { svmPaywall } from '@x402/paywall/svm';

const paywall = createPaywall()
  .withNetwork(svmPaywall)
  .build();
```

```typescript
import { evmPaywall } from '@x402/paywall/evm';
import { svmPaywall } from '@x402/paywall/svm';

const paywall = createPaywall()
  .withNetwork(evmPaywall)
  .withNetwork(svmPaywall)
  .build();
```

EVM bundle is 3.4MB because @coinbase/onchainkit is ~2.5MB.
Tree shaking works correctly - no Solana deps in EVM build.

- Refactor to use x402Client internally (cleaner architecture)
- Investigate OnchainKit bundle size optimizations
- Add more network handlers (Sui, Aptos, etc.)
- Run prettier on all paywall files
- Fix code style across 15 files
- No functional changes
- Remove evm/evm-utils.ts and svm/svm-utils.ts (100% identical duplicates)
- Update imports to use shared src/utils.ts
- Fix eslint config to match @x402/express pattern
- Relax JSDoc requirements for UI code
- Add browser globals and generated file ignores
- Run lint and format

Benefits:
- Saves 238 lines of duplicate code
- Clearer for maintainers (no confusion about differences)
- No impact on tree shaking or bundle sizes

Verified:
- All 45 tests passing
- EVM bundle: 3.38 MB (unchanged)
- SVM bundle: 0.95 MB (unchanged)
@vercel
Copy link

vercel bot commented Oct 30, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
x402 Ready Ready Preview Comment Nov 4, 2025 10:20pm

@apmcdermott apmcdermott changed the title Amanda/extract paywall feat: Extract paywall into modular package Oct 30, 2025
}

// Support v1 legacy EVM networks
const evmNetworks = [

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these networks should be pulled up.

Right now @x402/core has a /types export for v2 types, and /types/v1 for v1.

I know these are constants and not types, but I feel like these should be moved to /types/v1 as EVM_NETWORKS and we do the same for SVM_NETWORKS with solana/solana-devnet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* @param requirement - Payment requirement to check
* @returns True if this handler can process this requirement
*/
supports(requirement: PaymentRequirements): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally supports should take x402Version and PaymentRequirements, so that it can do a check on the version. Shape of PaymentRequirements is fine for now as there's just two versions, but as we improve it'll be much easier to maintain backwards compatibility if we push these v1/v2 scenarios to switch on x402Version rather than shape

* @param provider - PaywallProvider instance
*/
setPaywallProvider(provider: PaywallProvider): void {
this.paywallProvider = provider;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devx question, how about this join the builder pattern?

registerPaywallProvider(provider?: PaywallProvider): x402HTTPResourceService {
  this.paywallProvider = provider;
  return this;
}

This would allow middleware implementers to add it to the builder chain

export function ensureValidAmount(paymentRequirements: PaymentRequirements): PaymentRequirements {
const updatedRequirements = safeClone(paymentRequirements);

if (window.x402?.amount) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One pattern I'm following is to operate as if v2 is the default, and v1 is the branching logic.

For example, this function can be a bit hard to read with the two cases mixed in every step of the way

I would suggest ensureValidAmount and ensureValidAmountV1 are two functions that each do their one thing right, and the caller should check the x402Version to determine which to call.

Either that, or the x402Version should be passed in, and there should be a switch statement about how handling v1 and v2 differ

@apmcdermott
Copy link
Contributor Author

saddest rebase in the universe. new branch here: #600

@apmcdermott apmcdermott closed this Nov 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

6 participants