- )}
-
- );
-}
diff --git a/apps/docs/app/routes.ts b/apps/docs/app/routes.ts
deleted file mode 100644
index 487b9b0..0000000
--- a/apps/docs/app/routes.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { index, route, type RouteConfig } from "@react-router/dev/routes";
-
-export default [
- route("api/search", "docs/search.ts"),
- index("docs/page.tsx", { id: "docs-index" }),
- route("*", "docs/page.tsx", { id: "docs-catchall" }),
-] satisfies RouteConfig;
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
new file mode 100644
index 0000000..07982cb
--- /dev/null
+++ b/apps/docs/astro.config.mjs
@@ -0,0 +1,58 @@
+// @ts-check
+import { defineConfig } from "astro/config";
+import starlight from "@astrojs/starlight";
+
+// https://astro.build/config
+export default defineConfig({
+ srcDir: ".", // Use docs/ as src root instead of docs/src/
+ integrations: [
+ starlight({
+ title: "Cascade Splits",
+ description:
+ "Permissionless payment splitter for Solana and Base. Distributes tokens from vault to recipients by percentage.",
+ social: [
+ {
+ icon: "github",
+ label: "GitHub",
+ href: "https://github.com/cascade-protocol/splits",
+ },
+ {
+ icon: "x.com",
+ label: "X",
+ href: "https://x.com/cascade_fyi",
+ },
+ ],
+ sidebar: [
+ {
+ label: "Getting Started",
+ items: [{ label: "Introduction", slug: "index" }],
+ },
+ {
+ label: "Specification",
+ items: [
+ { label: "Solana", slug: "specification/solana" },
+ { label: "EVM (Base)", slug: "specification/evm" },
+ { label: "Glossary", slug: "specification/glossary" },
+ ],
+ },
+ {
+ label: "Architecture Decision Records",
+ autogenerate: { directory: "adr" },
+ },
+ {
+ label: "Benchmarks",
+ autogenerate: { directory: "benchmarks" },
+ },
+ {
+ label: "Reference",
+ autogenerate: { directory: "reference" },
+ },
+ ],
+ customCss: [],
+ editLink: {
+ baseUrl:
+ "https://github.com/cascade-protocol/splits/edit/main/apps/docs/content/docs/",
+ },
+ }),
+ ],
+});
diff --git a/apps/docs/content.config.ts b/apps/docs/content.config.ts
new file mode 100644
index 0000000..7fbcf2c
--- /dev/null
+++ b/apps/docs/content.config.ts
@@ -0,0 +1,7 @@
+import { defineCollection } from "astro:content";
+import { docsLoader } from "@astrojs/starlight/loaders";
+import { docsSchema } from "@astrojs/starlight/schema";
+
+export const collections = {
+ docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+};
diff --git a/apps/docs/content/docs/how-it-works.mdx b/apps/docs/content/docs/how-it-works.mdx
deleted file mode 100644
index 34d52c0..0000000
--- a/apps/docs/content/docs/how-it-works.mdx
+++ /dev/null
@@ -1,77 +0,0 @@
----
-title: How It Works
-description: Revenue sharing in 5 minutes - understand the payment flow
----
-
-Cascade Splits is the first x402-native splitter on Solana. It's **permissionless**, **non-custodial**, and charges just **1% protocol fee**.
-
-## The Simple Flow
-
-1. **Create a split** with your recipients and percentages
-2. **Share your vault address** to receive payments
-3. **Anyone can execute** the split to distribute funds
-
-That's it. No custody, no trust assumptions.
-
-## Your Split Address
-
-When you create a split, you get a **vault address** - a standard Solana token account. Share this address anywhere you'd share a wallet address:
-
-```
-7xKpQ9Lm2Rn3Wp4Ys5Zt6Au7Bv8Cw9Dx1Ey2Fz3mNq
-```
-
-Payments sent here are automatically split to your recipients when executed.
-
-## Automatic Distribution
-
-Define who gets what when you create the split:
-
-| Recipient | Share |
-|-----------|-------|
-| Agent | 90% |
-| Marketplace | 10% |
-
-The remaining 1% is the protocol fee.
-
-## Use Cases
-
-- **Marketplaces** - Split fees between platform and sellers
-- **API Monetization** - Revenue share with infrastructure providers
-- **Agent Payments** - Distribute earnings across collaborators
-
-## SDK Integration
-
-```ts
-import { createSplitConfig } from "@cascade-fyi/splits-sdk/solana";
-
-const { vault } = await createSplitConfig({
- authority: wallet,
- recipients: [
- { address: "Agent111111111111111111111111111111111111111", share: 90 },
- { address: "Marketplace1111111111111111111111111111111", share: 10 },
- ],
-});
-```
-
-Install with:
-
-```bash
-npm install @cascade-fyi/splits-sdk
-```
-
-## Technical Details
-
-### Permissionless Execution
-
-Anyone can call `execute_split` to distribute the vault balance. You don't need to be the creator or a recipient - this enables automated distribution via bots or any third party.
-
-### Unclaimed Funds
-
-If a recipient doesn't have a token account, their share is held as **unclaimed** in the split state. It will be delivered on the next execution once their account exists.
-
-### Updating & Closing
-
-The split **authority** can update recipients or close the split. Both require:
-- Vault must be empty (execute first)
-- All unclaimed amounts must be zero
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index fced211..c9b1540 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -1,38 +1,89 @@
---
-title: Getting Started
-description: Learn how to use Cascade Splits for permissionless payment splitting on Solana
+title: Cascade Splits
+description: Permissionless payment splitter for Solana and Base
+template: splash
+hero:
+ tagline: Non-custodial payment splitting protocol. Distribute tokens to multiple recipients by percentage.
+ actions:
+ - text: Get Started
+ link: /specification/solana/
+ icon: right-arrow
+ - text: View on GitHub
+ link: https://github.com/cascade-protocol/splits
+ icon: external
+ variant: minimal
---
-Cascade Splits is a permissionless payment splitter on Solana. It allows you to distribute tokens from a vault to multiple recipients based on percentage allocations.
+import { Card, CardGrid } from "@astrojs/starlight/components";
-## What is Cascade Splits?
+## Features
-Cascade Splits lets you create a **vault** that automatically distributes incoming tokens to a predefined list of recipients. Each recipient receives their share based on a percentage you set when creating the split.
+
+
+ Deploy on Solana (SPL Token & Token-2022) or Base (ERC20). Same SDK
+ interface across chains.
+
+
+ Anyone can trigger distributions. No admin keys required for execution.
+
+
+ Failed transfers are stored as unclaimed and automatically retried on next
+ execution.
+
+
+ Transparent, on-chain enforced. Recipients control the remaining 99%.
+
+
-### Key Features
+## Quick Start
-- **Permissionless execution** - Anyone can trigger the split distribution
-- **On-chain configuration** - All split settings are stored on Solana
-- **SPL Token support** - Works with any SPL token (USDC, etc.)
-- **Transparent fees** - 1% protocol fee on distributions
+### Solana SDK
-## Quick Start
+```bash
+pnpm add @cascade-fyi/splits-sdk
+```
-1. **Connect your wallet** at [cascade.fyi](https://cascade.fyi)
-2. **Create a split** with your recipient addresses and percentages
-3. **Share your vault address** to receive payments
-4. **Execute the split** to distribute funds (anyone can do this)
+```typescript
+import { createSplitConfig, executeSplit } from "@cascade-fyi/splits-sdk";
-
-
-
+// Create a split
+const { instruction, vault } = await createSplitConfig({
+ authority: wallet.publicKey,
+ mint: USDC_MINT,
+ recipients: [
+ { address: "Agent...", share: 90 },
+ { address: "Platform...", share: 9 },
+ ],
+});
-## Program Address
+// Execute distribution (permissionless)
+const executeIx = await executeSplit({ vault });
+```
-The Cascade Splits program is deployed on Solana mainnet:
+### EVM SDK (Base)
+```bash
+pnpm add @cascade-fyi/splits-sdk-evm
```
-SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB
+
+```typescript
+import { createEvmSplitsClient } from "@cascade-fyi/splits-sdk-evm/client";
+import { base } from "viem/chains";
+
+const client = createEvmSplitsClient(base, { account });
+
+const result = await client.ensureSplit({
+ uniqueId: "0x...",
+ recipients: [
+ { address: "0xAlice...", share: 90 },
+ { address: "0xBob...", share: 9 },
+ ],
+});
```
-View on [Solscan](https://solscan.io/account/SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB).
+## Program Addresses
+
+| Chain | Address |
+| ------ | ---------------------------------------------- |
+| Solana | `SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB` |
+| Base | `0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7` |
diff --git a/apps/docs/content/docs/reference/index.md b/apps/docs/content/docs/reference/index.md
new file mode 100644
index 0000000..a99a065
--- /dev/null
+++ b/apps/docs/content/docs/reference/index.md
@@ -0,0 +1,16 @@
+---
+title: API Reference
+description: SDK and program reference documentation
+---
+
+API reference documentation will be generated from code and added here.
+
+## SDKs
+
+- **@cascade-fyi/splits-sdk** - Solana SDK
+- **@cascade-fyi/splits-sdk-evm** - EVM (Base) SDK
+
+## Program
+
+- **SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB** - Solana program
+- **0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7** - EVM factory
diff --git a/apps/docs/content/docs/specification/evm.md b/apps/docs/content/docs/specification/evm.md
new file mode 100644
index 0000000..bdf1681
--- /dev/null
+++ b/apps/docs/content/docs/specification/evm.md
@@ -0,0 +1,527 @@
+---
+title: EVM Specification
+description: Complete technical specification for Cascade Splits on Base (EVM)
+sidebar:
+ order: 2
+ badge:
+ text: Draft
+ variant: caution
+---
+
+**Version:** 1.0
+**Factory Address:** `0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7`
+**Pattern:** Clone Factory (EIP-1167)
+**Terminology:** [Glossary](/specification/glossary/)
+
+## Overview
+
+Cascade Splits EVM is a non-custodial payment splitting protocol for EVM chains that automatically distributes incoming payments to multiple recipients based on pre-configured percentages.
+
+**Design Goals:**
+- High-throughput micropayments (API calls, streaming payments)
+- Minimal gas cost per execution
+- Simple, idempotent interface for facilitators
+- Permissionless operation
+- Cross-chain parity with Solana implementation
+
+**Key Features:**
+- Accept payments to a single split address
+- Automatically split funds to 1-20 recipients
+- Mandatory 1% protocol fee (transparent, on-chain enforced)
+- ERC20 token support (USDC on Base)
+- Idempotent execution with self-healing unclaimed recovery
+- Multiple configs per authority/token via unique identifiers
+- Integration with x402 payment facilitators
+
+## Terminology
+
+This spec follows the [canonical glossary](/specification/glossary/). Key EVM-specific mappings:
+
+| Glossary Term | EVM Implementation | Notes |
+|---------------|-------------------|-------|
+| **ProtocolConfig** | `SplitFactory` contract | Factory IS the protocol config singleton |
+| **SplitConfig** | `SplitConfigImpl` clone | Each split is a minimal proxy clone |
+| **Vault** | SplitConfig contract balance | On EVM, vault = split address (same contract) |
+| **initialize_protocol** | Constructor | Factory constructor handles initialization |
+| **percentage_bps** | `percentageBps` | camelCase per EVM convention |
+
+**Vault as Primary Identifier:** The vault address (where users deposit) IS the SplitConfig address on EVM. Unlike Solana where vault is a separate ATA, EVM splits hold funds directly. SDK functions accept this address:
+
+```typescript
+getSplit(vault) // Returns SplitConfig data
+executeSplit(vault) // Distributes vault balance
+```
+
+## How It Works
+
+### 1. Setup
+
+Authority creates a **split config** via the factory defining:
+- Token address (USDC, etc.)
+- Recipients and their percentages (must total 99%)
+- Unique identifier (enables multiple configs per authority/token)
+
+The factory deploys a minimal proxy clone with a deterministic address.
+
+### 2. Payment Flow
+
+```
+Payment → SplitConfig → executeSplit() → Recipients (99%) + Protocol (1%)
+```
+
+**Without Facilitator:**
+1. Payment sent to split address
+2. Anyone calls `executeSplit()`
+3. Funds distributed
+
+**With x402 Facilitator:**
+1. Facilitator sends payment via EIP-3009 `transferWithAuthorization`
+2. Anyone calls `executeSplit()` to distribute
+3. Recipients receive their shares
+
+### 3. Idempotent Execution
+
+`executeSplit` is designed to be idempotent and self-healing:
+- Multiple calls on the same state produce the same result
+- Only new funds (balance minus unclaimed) are split
+- Previously unclaimed amounts are automatically delivered when transfers succeed
+- Facilitators can safely retry without risk of double-distribution
+
+## Core Concepts
+
+### EIP-1167 Clone Pattern with Immutable Args
+
+- SplitConfig contracts are minimal proxy clones with appended data (~45 bytes + immutable args)
+- Single implementation contract, many lightweight clones
+- ~83k gas deployment with immutable args (vs ~290k baseline, vs ~500k full contract)
+- Deterministic addresses via CREATE2 (salt includes authority, token, uniqueId)
+- Recipients and configuration encoded in clone bytecode—no storage initialization needed
+- No initializer function—all data read from bytecode via CODECOPY
+
+### Self-Healing Unclaimed Recovery
+
+If a recipient transfer fails during execution:
+1. Their share is recorded as "unclaimed" and stays in contract
+2. Unclaimed funds are protected from re-splitting
+3. On subsequent `executeSplit` calls, system auto-retries failed transfers
+4. Once transfer succeeds, funds are delivered
+5. No separate claim instruction needed
+
+### Protocol Fee
+
+- **Fixed 1%** enforced by contract (transparent, cannot be bypassed)
+- Recipients control the remaining 99%
+- Example: `[90%, 9%]` = 99% total ✓
+- Invalid: `[90%, 10%]` = 100% total ✗
+
+### Execution Behavior
+
+**Zero-balance execution:** Calling `executeSplit()` with no new funds is a no-op for distribution but still attempts to clear any pending unclaimed amounts. No revert, emits event with zero amounts.
+
+**Rounding:** Recipient shares use floor division. Dust (remainder from rounding) goes to protocol fee. Example with 100 tokens and 3 recipients at 33% each:
+- Recipients: 33 + 33 + 33 = 99 tokens
+- Protocol: 1 token (fee) + 0 tokens (dust in this case)
+
+:::note
+With very small distributions or low-decimal tokens, small-percentage recipients may receive 0 due to floor division. This is expected behavior.
+:::
+
+### Dust and Minimum Amounts
+
+There is no minimum execution amount. For very small distributions, floor division may result in some recipients receiving 0:
+
+```
+Example: 4 wei split among 5 recipients at 19.8% each
+- Each recipient: floor(4 × 1980 / 10000) = 0 wei
+- Protocol receives: 4 wei (entire amount as remainder)
+```
+
+This is intentional—the protocol collects dust that would otherwise be unallocatable. For practical use with USDC (6 decimals), amounts below ~$0.01 may result in some recipients receiving 0.
+
+**Integrator guidance:** Avoid sending amounts smaller than `recipientCount × 100` base units to ensure all recipients receive non-zero shares.
+
+### Naming Parity
+
+All function names aligned with Solana implementation (camelCase for EVM):
+
+| Solana (snake_case) | EVM (camelCase) | Notes |
+|---------------------|-----------------|-------|
+| `create_split_config` | `createSplitConfig` | |
+| `execute_split` | `executeSplit` | |
+| `update_split_config` | — | EVM splits are immutable (gas optimization) |
+
+**Terminology adaptations:**
+- `mint` → `token` (EVM: "mint" means creating tokens)
+- `authority` retained (EVM equivalent: "owner"). Kept for cross-chain consistency.
+
+**Divergence from Solana:** EVM implementation does not support `updateSplitConfig` due to architectural optimization (immutable args pattern). This is justified by different cost models—EVM storage reads are expensive, Solana account reads are cheap.
+
+## Regulated Token & Smart Wallet Support
+
+Cascade Splits is designed for the x402 ecosystem where **transfer failures are a normal operating condition**, not an edge case.
+
+### Why Transfers Fail
+
+| Scenario | Cause | Frequency |
+|----------|-------|-----------|
+| **Smart Wallet Recipients** | EIP-4337/6492 wallets may not be deployed yet | Growing (x402 direction) |
+| **Blocklisted Addresses** | Circle/Tether compliance (USDC, USDT) | Occasional |
+| **Allowlist Tokens** | Future KYC-enabled tokens reject non-approved recipients | Coming |
+| **Paused Tokens** | Token operations temporarily suspended | Rare |
+
+### Self-Healing as Infrastructure
+
+Traditional splits revert when any transfer fails—one bad recipient blocks all distributions. Cascade Splits treats failed transfers as **recoverable state**:
+
+```
+Payment arrives → executeSplit() called
+ ├─ Recipient A: ✓ Transfer succeeds → funds delivered
+ ├─ Recipient B: ✗ Transfer fails → stored as unclaimed
+ └─ Protocol: ✓ Transfer succeeds → fee delivered
+
+Later: executeSplit() called again
+ └─ Recipient B: ✓ Transfer succeeds → unclaimed cleared
+```
+
+**Key behaviors:**
+- Failed transfers don't revert the transaction
+- Unclaimed funds are protected from re-splitting (balance accounting)
+- Every `executeSplit()` retries all pending unclaimed amounts
+- No separate claim function needed—recipients receive automatically when conditions clear
+
+### Smart Wallet Recipients (EIP-4337/6492)
+
+x402 is moving toward smart wallet support. Smart wallets present a unique challenge:
+
+- **Counterfactual addresses**: Wallet address is known before deployment
+- **EIP-3009 limitation**: `transferWithAuthorization` may fail if wallet has no code
+- **Coinbase Smart Wallet**: Users already encountering failures
+
+Self-healing handles this gracefully:
+1. Payment lands in split (works—split is deployed)
+2. `executeSplit()` attempts transfer to smart wallet
+3. Transfer fails (no code at address)
+4. Amount stored as unclaimed
+5. User deploys their smart wallet
+6. Next `executeSplit()` succeeds—funds delivered
+
+### Permanent Blocklist Behavior
+
+If a recipient is **permanently** blocklisted (e.g., OFAC sanctions):
+
+- Funds remain in the split contract forever
+- No backdoor to redirect funds (by design)
+- Funds belong to the recipient, not the authority
+- Authority cannot reclaim or reassign
+
+This is intentional. The alternative—allowing authority to redirect funds—creates a trust assumption that contradicts the permissionless design.
+
+## Contract Structure
+
+### SplitFactory
+
+Global factory for deploying splits. Supports versioned implementations for safe iteration during active development.
+
+```solidity
+contract SplitFactory {
+ // Versioned implementation pattern
+ address public immutable initialImplementation; // V1, never changes
+ address public currentImplementation; // Latest version for new splits
+
+ // Protocol configuration
+ address public feeWallet;
+ address public authority;
+ address public pendingAuthority;
+
+ constructor(address initialImplementation_, address feeWallet_, address authority_) {
+ if (initialImplementation_ == address(0)) revert ZeroAddress(0);
+ if (initialImplementation_.code.length == 0) revert InvalidImplementation(initialImplementation_);
+ if (feeWallet_ == address(0)) revert ZeroAddress(1);
+ if (authority_ == address(0)) revert ZeroAddress(2);
+
+ initialImplementation = initialImplementation_;
+ currentImplementation = initialImplementation_;
+ feeWallet = feeWallet_;
+ authority = authority_;
+ emit ProtocolConfigCreated(authority_, feeWallet_);
+ }
+}
+```
+
+**Versioned implementations:**
+- `initialImplementation`: Set at factory deployment, immutable (for historical reference)
+- `currentImplementation`: Used for new splits, can be upgraded by protocol authority
+- Existing splits are unaffected by upgrades (their implementation is baked into clone bytecode)
+- Enables safe bug fixes: deploy new implementation, new splits use it, old splits unchanged
+
+### SplitConfig
+
+Per-split configuration deployed as EIP-1167 clone with immutable args.
+
+```solidity
+contract SplitConfig {
+ // === IMMUTABLE (encoded in clone bytecode, read via EXTCODECOPY) ===
+ // address public factory; // Read via extcodecopy at offset 0x2d + 0
+ // address public authority; // Read via extcodecopy at offset 0x2d + 20
+ // address public token; // Read via extcodecopy at offset 0x2d + 40
+ // bytes32 public uniqueId; // Read via extcodecopy at offset 0x2d + 60
+ // Recipient[] recipients; // Read via extcodecopy at offset 0x2d + 92
+
+ // === STORAGE (only for unclaimed tracking) ===
+ uint256 private _unclaimedBitmap; // Bits 0-19: recipients, bit 20: protocol
+ mapping(uint256 => uint256) private _unclaimedByIndex; // index => amount
+}
+
+struct Recipient {
+ address addr;
+ uint16 percentageBps; // 1-9900 (0.01%-99%)
+}
+```
+
+**Immutable args byte layout:**
+
+| Offset | Size | Field | Clone Bytecode Offset |
+|--------|------|-------|----------------------|
+| 0 | 20 | factory | `0x2d + 0` |
+| 20 | 20 | authority | `0x2d + 20` |
+| 40 | 20 | token | `0x2d + 40` |
+| 60 | 32 | uniqueId | `0x2d + 60` |
+| 92 | 22×N | recipients[N] | `0x2d + 92 + i*22` |
+
+Each recipient is packed as `address (20 bytes) + uint16 percentageBps (2 bytes) = 22 bytes`.
+
+### Invariants
+
+The following properties must always hold:
+
+| Invariant | Description |
+|-----------|-------------|
+| `popcount(_unclaimedBitmap) <= 21` | Max 20 recipients + 1 protocol with unclaimed |
+| `balance >= totalUnclaimed()` | Contract holds at least enough for all unclaimed |
+| `sum(percentageBps) == 9900` | Recipients always total 99% (immutable in bytecode) |
+| `recipientCount >= 1 && <= 20` | Always 1-20 recipients (immutable in bytecode) |
+
+## Instructions
+
+### Factory Instructions
+
+| Instruction | Description | Authorization |
+|-------------|-------------|---------------|
+| `createSplitConfig` | Deploy new split clone | Anyone |
+| `updateProtocolConfig` | Update fee wallet | Protocol authority |
+| `upgradeImplementation` | Set new implementation for future splits | Protocol authority |
+| `transferProtocolAuthority` | Propose authority transfer | Protocol authority |
+| `acceptProtocolAuthority` | Accept authority transfer | Pending authority |
+
+#### createSplitConfig
+
+```solidity
+function createSplitConfig(
+ address authority,
+ address token,
+ bytes32 uniqueId,
+ Recipient[] calldata recipients
+) external returns (address split);
+```
+
+**Parameters:**
+- `authority`: Creator/namespace address for the split
+- `token`: ERC20 token address (e.g., USDC)
+- `uniqueId`: Unique identifier (enables multiple splits per authority/token pair)
+- `recipients`: Array of recipients with percentage allocations (must sum to 9900 bps)
+
+**Returns:** Deployed split clone address
+
+**Validation:**
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses (for recipients)
+- No zero percentages
+- Split with same params must not already exist
+
+### Split Instructions
+
+| Instruction | Description | Authorization |
+|-------------|-------------|---------------|
+| `executeSplit` | Distribute balance to recipients | Permissionless |
+
+#### executeSplit
+
+```solidity
+function executeSplit() external nonReentrant;
+```
+
+Distributes available balance to recipients and protocol. Automatically retries any pending unclaimed transfers.
+
+**No `updateSplitConfig`:** Splits are immutable by design. To change recipients, deploy a new split and update your `payTo` address.
+
+### executeSplit Algorithm
+
+```
+1. Load _unclaimedBitmap (1 SLOAD)
+2. If bitmap != 0:
+ - For each set bit i in bitmap:
+ - Attempt transfer of _unclaimedByIndex[i] to recipient[i] (or feeWallet if i == 20)
+ - If success: clear bit and mapping
+ - If fail: keep as unclaimed
+3. Calculate available = token.balanceOf(this) - totalUnclaimed()
+4. If available > 0:
+ a. For each recipient i:
+ - amount[i] = available * percentageBps[i] / 10000
+ - Attempt transfer, record as unclaimed on failure
+ b. protocolFee = available - sum(amount[i]) // Includes 1% + dust
+ - Attempt transfer to feeWallet, record as unclaimed on failure
+5. Emit SplitExecuted(totalDistributed, protocolFee, unclaimedCleared, newUnclaimed)
+```
+
+## x402 Integration
+
+Cascade Splits integrates with the [x402 protocol](https://github.com/coinbase/x402) for internet-native payments. When a resource server sets `payTo` to a split address, funds land via EIP-3009 and can be distributed via `executeSplit`.
+
+### Payment Flow
+
+```
+x402 Payment (EIP-3009):
+ Client signs transferWithAuthorization (to: split address)
+ → Facilitator submits to token contract
+ → Funds land in split
+
+Async Distribution:
+ Keeper/Anyone calls executeSplit()
+ → Recipients receive their shares
+ → Protocol receives 1% fee
+```
+
+### Token Compatibility
+
+| Token | EIP-3009 | x402 Compatible |
+|-------|----------|-----------------|
+| USDC (Base) | ✓ | ✓ |
+| USDT | ✗ | ✗ |
+| DAI | ✗ (EIP-2612) | ✗ |
+
+## Events
+
+| Event | Description |
+|-------|-------------|
+| `ProtocolConfigCreated` | Factory deployed |
+| `ProtocolConfigUpdated` | Fee wallet changed |
+| `ProtocolAuthorityTransferProposed` | Authority transfer initiated |
+| `ProtocolAuthorityTransferAccepted` | Authority transfer completed |
+| `ImplementationUpgraded` | New implementation set for future splits |
+| `SplitConfigCreated` | New split deployed |
+| `SplitExecuted` | Funds distributed |
+| `TransferFailed` | Individual transfer failed |
+| `UnclaimedCleared` | Previously unclaimed funds successfully delivered |
+
+### SplitExecuted Details
+
+```solidity
+event SplitExecuted(
+ uint256 totalAmount, // Total distributed this execution
+ uint256 protocolFee, // Protocol's 1% share
+ uint256 unclaimedCleared, // Previously unclaimed now delivered
+ uint256 newUnclaimed // New transfers that failed
+);
+```
+
+## Error Codes
+
+| Error | Description |
+|-------|-------------|
+| `InvalidRecipientCount` | Recipients count not in 1-20 range |
+| `InvalidSplitTotal` | Percentages don't sum to 9900 bps |
+| `DuplicateRecipient` | Same address appears twice |
+| `ZeroAddress` | Recipient or feeWallet address is zero |
+| `ZeroPercentage` | Recipient has 0 bps allocation |
+| `Unauthorized` | Caller not authorized |
+| `NoPendingTransfer` | No pending authority transfer to accept |
+| `SplitAlreadyExists` | Split with identical params already deployed |
+| `InvalidImplementation` | Implementation address has no deployed code |
+| `Reentrancy` | Reentrant call detected |
+
+## Security
+
+### Implemented Protections
+
+- ReentrancyGuard on `executeSplit` (Solady's `ReentrancyGuardTransient` via EIP-1153)
+- Self-healing transfer wrapper (catches failures, records as unclaimed)
+- Overflow protection (Solidity 0.8+)
+- Two-step protocol authority transfer
+- Duplicate recipient validation at creation
+- Bounded recipient count (max 20)
+- Zero-address validation on feeWallet updates
+- Implementation code-length validation on upgrades
+
+### Not Implemented (by design)
+
+- Pausability (trust minimization)
+- Per-split upgrades (existing splits use fixed implementation)
+- Close/reclaim (no rent on EVM)
+- Native ETH support (ERC20 only)
+
+## Gas Optimization
+
+Optimized for high-throughput micropayments where `executeSplit` is called frequently.
+
+### Measured Gas Costs
+
+| Recipients | `createSplitConfig` | `executeSplit` |
+|------------|---------------------|----------------|
+| 2 | 93k | 91k |
+| 5 | 117k | 170k |
+| 10 | 163k | 303k |
+| 20 | 276k | 567k |
+
+Gas scales linearly with recipient count due to ERC20 transfers and bytecode encoding.
+
+### Key Optimizations
+
+| Optimization | Creation Impact | Execution Impact |
+|--------------|-----------------|------------------|
+| **Immutable args** | -65% | -78% |
+| **No factory registry** | -7% | None |
+| **Lazy unclaimed bitmap** | None | -11% |
+
+## Contract Addresses
+
+**Deterministic addresses (same on ALL EVM chains):**
+
+| Contract | Address |
+|----------|---------|
+| SplitConfigImpl | `0xF9ad695ecc76c4b8E13655365b318d54E4131EA6` |
+| SplitFactory | `0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7` |
+
+### Deployment Status
+
+| Network | Status |
+|---------|--------|
+| Base Mainnet | ✅ Deployed |
+| Base Sepolia | ✅ Deployed |
+
+## Constants
+
+```solidity
+uint16 public constant PROTOCOL_FEE_BPS = 100; // 1%
+uint16 public constant REQUIRED_SPLIT_TOTAL = 9900; // 99%
+uint8 public constant MIN_RECIPIENTS = 1;
+uint8 public constant MAX_RECIPIENTS = 20;
+uint256 public constant PROTOCOL_INDEX = MAX_RECIPIENTS; // Bitmap index for protocol fee (20)
+```
+
+## Design Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| **Hardcoded 1% fee** | Transparency. Anyone can verify on-chain. |
+| **Immutable splits** | Trustless verification—payers can verify recipients on-chain. |
+| **Immutable args in bytecode** | 88% gas savings vs storage. |
+| **Versioned implementations** | Safe iteration during development. |
+| **No factory registry** | Events + CREATE2 sufficient. Saves 22k gas per creation. |
+| **Lazy unclaimed bitmap** | Only write storage on failure. 11% execution savings. |
+| **`token` not `mint`** | "Mint" means creating tokens in EVM. |
+| **No close instruction** | EVM has no rent. Contracts persist forever. |
+| **Self-healing over claim** | Single idempotent interface. |
+| **Clone pattern** | ~83k gas deploy. Critical for high-throughput. |
+| **ERC20 only, no native ETH** | Simplifies implementation. USDC is primary use case. |
diff --git a/apps/docs/content/docs/specification/glossary.md b/apps/docs/content/docs/specification/glossary.md
new file mode 100644
index 0000000..77b606f
--- /dev/null
+++ b/apps/docs/content/docs/specification/glossary.md
@@ -0,0 +1,99 @@
+---
+title: Glossary
+description: Canonical terminology for Cascade Splits protocol
+sidebar:
+ order: 3
+---
+
+Canonical terminology for Cascade Splits. **Solana program is the source of truth** - EVM adapts where platform requires.
+
+## Accounts & Contracts
+
+| Term | Definition |
+|------|------------|
+| **ProtocolConfig** | Global singleton storing protocol settings: authority, pending authority, fee wallet. |
+| **SplitConfig** | Per-split configuration storing: authority, token, vault, recipients, unclaimed amounts. |
+| **Vault** | Token account holding funds pending distribution. On Solana: ATA owned by SplitConfig PDA. On EVM: balance held by SplitConfig contract itself. |
+
+## Data Structures
+
+| Term | Definition |
+|------|------------|
+| **Recipient** | Entry in SplitConfig: wallet address + percentage in basis points. |
+| **UnclaimedAmount** | Funds held when recipient transfer fails. Automatically retried on next execution. |
+
+## Values
+
+| Term | Definition |
+|------|------------|
+| **percentage_bps** | On-chain recipient percentage in basis points. 99 bps = 1%. Recipients must sum to 9900 bps (99%). |
+| **share** | SDK/UI percentage (1-100). Converted to bps via `share × 99`. |
+| **protocol_fee** | Fixed 1% (100 bps) taken from each distribution. |
+
+## Instructions
+
+| Instruction | Description |
+|-------------|-------------|
+| **create_split_config** | Create new SplitConfig with recipients. |
+| **execute_split** | Distribute vault balance to recipients. Permissionless. |
+| **update_split_config** | Change recipients. Requires empty vault, no unclaimed. *(Solana only)* |
+| **close_split_config** | Delete SplitConfig, recover rent. *(Solana only)* |
+
+## Protocol Instructions
+
+| Instruction | Description |
+|-------------|-------------|
+| **initialize_protocol** | Create ProtocolConfig. One-time setup. |
+| **update_protocol_config** | Change fee wallet. |
+| **transfer_protocol_authority** | Start two-step authority transfer. |
+| **accept_protocol_authority** | Complete authority transfer. |
+
+## Platform Adaptations
+
+Where platforms diverge from canonical terms:
+
+| Concept | Solana | EVM | Reason |
+|---------|--------|-----|--------|
+| Token reference | `mint` | `token` | "Mint" means creating tokens in EVM |
+| Case convention | `snake_case` | `camelCase` | Platform convention |
+| Recipient updates | Supported | Not supported | EVM uses immutable args for gas optimization |
+| Close/reclaim | Supported | Not applicable | EVM has no rent model |
+| Vault location | Separate ATA | Contract balance | Platform architecture |
+| Vault address | Different from SplitConfig PDA | Same as SplitConfig address | EVM splits hold funds directly |
+| ProtocolConfig | Separate account | Embedded in SplitFactory | Factory IS the protocol config |
+| initialize_protocol | Explicit instruction | Constructor | One-time at factory deploy |
+
+## SDK Naming
+
+SDK follows on-chain names, adapting case per platform:
+
+```typescript
+// Solana SDK
+createSplitConfig(...)
+executeSplit(...)
+updateSplitConfig(...)
+closeSplitConfig(...)
+
+// EVM SDK
+createSplitConfig(...)
+executeSplit(...)
+// No update/close on EVM
+```
+
+**Types use PascalCase on both platforms:**
+- `SplitConfig`
+- `ProtocolConfig`
+- `Recipient`
+
+## Identifier Convention
+
+The **vault address** is the primary user-facing identifier for a split:
+- It's what users deposit to
+- It's what appears in block explorers
+- SDK functions accept vault to look up SplitConfig
+
+```typescript
+// Both platforms: vault is the deposit address
+getSplit(vault) // Returns SplitConfig
+executeSplit(vault) // Distributes vault balance
+```
diff --git a/apps/docs/content/docs/specification/solana.md b/apps/docs/content/docs/specification/solana.md
new file mode 100644
index 0000000..55a9a55
--- /dev/null
+++ b/apps/docs/content/docs/specification/solana.md
@@ -0,0 +1,593 @@
+---
+title: Solana Specification
+description: Complete technical specification for Cascade Splits on Solana
+sidebar:
+ order: 1
+---
+
+**Version:** 1.1
+**Program ID:** `SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB`
+**Terminology:** [Glossary](/specification/glossary/)
+
+## Overview
+
+Cascade Splits is a non-custodial payment splitting protocol for Solana that automatically distributes incoming payments to multiple recipients based on pre-configured percentages.
+
+**Design Goals:**
+- High-throughput micropayments (API calls, streaming payments)
+- Minimal compute cost per execution
+- Simple, idempotent interface for facilitators
+- Permissionless operation
+
+**Key Features:**
+- Accept payments to a single vault address
+- Automatically split funds to 1-20 recipients
+- Mandatory 1% protocol fee (transparent, on-chain enforced)
+- Supports SPL Token and Token-2022
+- Idempotent execution with self-healing unclaimed recovery
+- Multiple configs per authority/mint via unique identifiers
+- Integration with x402 payment facilitators
+
+## How It Works
+
+### 1. Setup
+
+Authority creates a **split config** defining:
+- Token mint (USDC, USDT, etc.)
+- Recipients and their percentages (must total 99%)
+- Unique identifier (enables multiple configs per authority/mint)
+
+The protocol automatically creates a vault (PDA-owned ATA) to receive payments.
+
+### 2. Payment Flow
+
+```
+Payment → Vault (PDA-owned) → execute_split() → Recipients
+```
+
+**Without Facilitator:**
+1. Payment sent to vault
+2. Anyone calls `execute_split()`
+3. Funds distributed
+
+**With x402 Facilitator (e.g., PayAI):**
+1. Facilitator sends payment to vault address
+2. Anyone can call `execute_split` to distribute funds
+3. Recipients receive their shares
+
+### 3. Idempotent Execution
+
+`execute_split` is designed to be idempotent and self-healing:
+- Multiple calls on the same vault state produce the same result
+- Only new funds (vault balance minus unclaimed) are split
+- Previously unclaimed amounts are automatically delivered when recipient ATAs become valid
+- Facilitators can safely retry without risk of double-distribution
+
+## Core Concepts
+
+### PDA Vault Pattern
+
+- Vault is an Associated Token Account owned by a Program Derived Address (PDA)
+- No private keys = truly non-custodial
+- Funds can only be moved by program instructions
+
+### Self-Healing Unclaimed Recovery
+
+If a recipient's ATA is missing, invalid, or frozen during execution:
+1. Their share is recorded as "unclaimed" and stays in vault
+2. Unclaimed funds are protected from re-splitting
+3. On subsequent `execute_split` calls, the system automatically attempts to clear unclaimed
+4. Once recipient creates their ATA (or account is thawed if frozen), funds are delivered on the next execution
+5. No separate claim instruction needed - single interface for all operations
+
+**Frozen Accounts**: Token-2022 tokens using sRFC-37 DefaultAccountState::Frozen are supported. Frozen recipient accounts trigger the same unclaimed flow as missing accounts.
+
+Recipients can trigger `execute_split` themselves to retrieve unclaimed funds, even when no new payments exist. This gives recipients agency over their funds without depending on facilitators.
+
+### Protocol Fee
+
+- **Fixed 1%** enforced by program (transparent, cannot be bypassed)
+- Recipients control the remaining 99%
+- Example: `[90%, 9%]` = 99% total ✅
+- Invalid: `[90%, 10%]` = 100% total ❌
+
+**Design Decision:** Fee percentage is hardcoded for transparency. Integrators can verify the exact fee on-chain. If fee changes are needed, protocol will redeploy.
+
+### Multiple Configs per Authority
+
+Each split config includes a `unique_id` allowing an authority to create multiple configurations for the same token:
+- Facilitator managing multiple merchants
+- Different split ratios for different products
+- Parallel config creation without contention
+
+### ATA Lifecycle Strategy
+
+**At config creation:** All recipient ATAs must exist. This:
+- Ensures recipients are ready to receive funds
+- Protects facilitators from ATA creation costs (0.002 SOL × recipients)
+- Prevents malicious configs designed to drain facilitators
+
+**During execution:** Missing ATAs are handled gracefully. If a recipient accidentally closes their ATA:
+- Their share goes to unclaimed (protected from re-splitting)
+- Other recipients still receive funds
+- Funds auto-deliver when ATA is recreated
+
+This design optimizes for both security (creation) and reliability (execution).
+
+## Account Structure
+
+### ProtocolConfig (PDA)
+
+Global protocol configuration (single instance).
+
+```rust
+#[account(zero_copy)]
+pub struct ProtocolConfig {
+ pub authority: Pubkey, // Can update config
+ pub pending_authority: Pubkey, // Pending authority for two-step transfer
+ pub fee_wallet: Pubkey, // Receives protocol fees
+ pub bump: u8, // Stored for CU optimization
+}
+```
+
+**Seeds:** `[b"protocol_config"]`
+
+**Usage:** Constraints use `bump = protocol_config.bump` to avoid on-chain PDA derivation.
+
+**Two-Step Authority Transfer:** To prevent accidental irreversible transfers, authority changes require:
+1. Current authority calls `transfer_protocol_authority` → sets `pending_authority`
+2. New authority calls `accept_protocol_authority` → completes transfer
+
+The pending transfer can be cancelled by calling `transfer_protocol_authority` with `Pubkey::default()`.
+
+### SplitConfig (PDA)
+
+Per-split configuration. Uses zero-copy for optimal compute efficiency.
+
+```rust
+#[account(zero_copy)]
+#[repr(C)]
+pub struct SplitConfig {
+ pub version: u8, // Schema version
+ pub authority: Pubkey, // Can update/close config
+ pub mint: Pubkey, // Token mint
+ pub vault: Pubkey, // Payment destination
+ pub unique_id: Pubkey, // Enables multiple configs
+ pub bump: u8, // Stored for CU optimization
+ pub recipient_count: u8, // Active recipients (1-20)
+ pub recipients: [Recipient; 20], // Fixed array, use recipient_count
+ pub unclaimed_amounts: [UnclaimedAmount; 20], // Fixed array
+ pub protocol_unclaimed: u64, // Protocol fees awaiting claim
+ pub last_activity: i64, // Timestamp of last execution
+ pub rent_payer: Pubkey, // Who paid rent (for refund on close)
+}
+
+#[repr(C)]
+pub struct Recipient {
+ pub address: Pubkey,
+ pub percentage_bps: u16, // 1-9900 (0.01%-99%)
+}
+
+#[repr(C)]
+pub struct UnclaimedAmount {
+ pub recipient: Pubkey,
+ pub amount: u64,
+ pub timestamp: i64,
+}
+```
+
+**Seeds:** `[b"split_config", authority, mint, unique_id]`
+
+**Space Allocation:** Fixed size for all configs (1,832 bytes). Zero-copy provides ~50% serialization CU savings, critical for high-throughput micropayments. The fixed rent (~0.015 SOL) is negligible compared to cumulative compute savings.
+
+**Payer Separation:** The `rent_payer` field tracks who paid rent for the account, enabling:
+- **Sponsored rent:** Protocol or third party pays rent on behalf of user
+- **Proper refunds:** On close, rent returns to original payer, not authority
+
+The `authority` controls the config (update, close), while `rent_payer` receives the rent refund. These can be the same address (user pays own rent) or different (sponsored).
+
+**Activity Tracking:** The `last_activity` timestamp is updated on every `execute_split`. This enables future capability for:
+- Stale account cleanup (recover rent from abandoned accounts after inactivity period)
+
+Currently, only the authority can close accounts. The activity tracking reserves the option to add permissionless cleanup of inactive accounts in a future version without breaking changes.
+
+## Instructions
+
+### initialize_protocol
+
+One-time protocol initialization.
+
+**Authorization:** Deployer (first call only)
+
+**Parameters:**
+- `fee_wallet`: Address to receive protocol fees
+
+### update_protocol_config
+
+Updates protocol fee wallet.
+
+**Authorization:** Protocol authority
+
+**Parameters:**
+- `new_fee_wallet`: New address for protocol fees
+
+### transfer_protocol_authority
+
+Proposes transfer of protocol authority to a new address.
+
+**Authorization:** Protocol authority
+
+**Parameters:**
+- `new_authority`: Address to receive authority (or `Pubkey::default()` to cancel)
+
+**Note:** This only sets `pending_authority`. The new authority must call `accept_protocol_authority` to complete the transfer.
+
+### accept_protocol_authority
+
+Accepts a pending protocol authority transfer.
+
+**Authorization:** Pending authority (must match `pending_authority` in config)
+
+**Parameters:** None
+
+**Note:** Completes the two-step transfer and clears `pending_authority`.
+
+### create_split_config
+
+Creates a new payment split configuration.
+
+**Authorization:** Anyone (becomes authority)
+
+**Accounts:**
+- `payer` - Pays rent for account creation (recorded as `rent_payer`)
+- `authority` - Controls the config (update, close)
+
+The payer and authority can be the same address (user pays own rent) or different (sponsored rent).
+
+**Validation:**
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses
+- No zero percentages
+- All recipient ATAs must exist
+
+*Note: Requiring pre-existing ATAs protects payment facilitators from ATA creation costs (0.002 SOL × recipients). Config creators ensure their recipients are ready before setup.*
+
+*Note: Recipients can be PDAs (multisig vaults, DAO treasuries, other protocols). The controlling program must have logic to withdraw from the ATA - the protocol only transfers to the ATA, not beyond.*
+
+**Example:**
+```typescript
+import { createSplitConfig } from "@cascade-fyi/splits-sdk/solana";
+
+const { instruction, vault } = await createSplitConfig({
+ authority: wallet,
+ recipients: [
+ { address: "Agent111111111111111111111111111111111111111", share: 90 },
+ { address: "Marketplace1111111111111111111111111111111", share: 10 },
+ ],
+});
+```
+
+### execute_split
+
+Distributes vault balance to recipients. Self-healing: also clears any pending unclaimed amounts.
+
+**Authorization:** Permissionless (anyone can trigger)
+
+**Required Accounts:**
+```typescript
+remaining_accounts: [
+ recipient_1_ata, // Canonical ATA for first recipient
+ recipient_2_ata, // Canonical ATA for second recipient
+ // ... one per recipient in config order
+ protocol_ata // Protocol fee wallet canonical ATA (last)
+]
+```
+
+**Important**: All ATAs must be canonical Associated Token Accounts derived via `get_associated_token_address_with_program_id()`. Non-canonical token accounts are rejected to prevent UX issues where recipients don't monitor non-standard accounts.
+
+The instruction validates that `remaining_accounts.len() >= recipient_count + 1`.
+
+**Logic:**
+1. Calculate available funds: `vault_balance - total_unclaimed - protocol_unclaimed`
+2. If available > 0:
+ - Calculate each recipient's share (floor division)
+ - Attempt transfer to each recipient
+ - If transfer fails → record as unclaimed (protected)
+ - Calculate protocol fee (1% + rounding dust)
+ - Attempt transfer to protocol
+ - If protocol transfer fails → add to `protocol_unclaimed`
+3. Attempt to clear all unclaimed amounts:
+ - For each recipient entry, check if ATA now exists
+ - If valid → transfer exact recorded amount, remove entry
+ - If still invalid → keep in unclaimed
+4. Attempt to clear protocol unclaimed:
+ - If protocol ATA exists → transfer `protocol_unclaimed`, reset to 0
+ - No additional fee charged on clearing (fee was calculated on original split)
+
+**Idempotency:** Safe to call multiple times. Only new funds are split. Unclaimed funds cannot be redistributed to other recipients.
+
+### update_split_config
+
+Authority updates recipient list while preserving the vault address.
+
+**Authorization:** Config authority
+
+**Requirements:**
+- Vault must be empty (execute pending splits first)
+- All `unclaimed_amounts` must be zero
+- `protocol_unclaimed` must be zero
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses
+- No zero percentages
+- All recipient ATAs must exist
+
+**Use Case:** The splitConfig address (PDA) is the stable public interface for x402 payments—facilitators derive the vault ATA automatically. When business arrangements change (new partners, revised percentages), the authority can update the split without requiring payers to change their payment destination.
+
+**Design Decision:** Vault must be empty to ensure funds are always split according to the rules active when they were received.
+
+### close_split_config
+
+Closes config and vault, reclaiming all rent.
+
+**Authorization:** Config authority
+
+**Accounts:**
+- `authority` - Must match config authority (authorizes close)
+- `rent_destination` - Must match config `rent_payer` (receives rent refund)
+- `vault` - Vault ATA (closed via CPI to token program)
+- `token_program` - Token program owning the vault
+
+**Requirements:**
+- Vault must be empty (balance = 0)
+- All unclaimed amounts must be zero
+- Protocol unclaimed must be zero
+
+**Rent Recovery:**
+- Config account rent: ~0.015 SOL (1,832 bytes)
+- Vault ATA rent: ~0.002 SOL (165 bytes)
+- **Total recovered**: ~0.017 SOL
+
+The rent is refunded to the original `rent_payer`, not necessarily the authority. This enables sponsored rent where a third party pays rent but the user controls the config.
+
+## x402 Integration
+
+### Merchant Configuration
+
+Set `payTo` to the **splitConfig address** (PDA), not the vault. Per [x402 SVM spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_svm.md), facilitators derive the destination: `ATA(owner=payTo, mint=asset)`.
+
+This makes `payTo` token-agnostic—same address works for USDC, USDT, or any supported token.
+
+### Automatic Detection
+
+After payment, facilitators can detect split vaults by checking if the derived destination is a token account owned by a SplitConfig PDA:
+
+```typescript
+async function detectSplitVault(destination: PublicKey): Promise {
+ const accountInfo = await connection.getAccountInfo(destination);
+ if (!accountInfo) return null;
+
+ const tokenAccount = decodeTokenAccount(accountInfo.data);
+
+ try {
+ const splitConfig = await program.account.splitConfig.fetch(
+ tokenAccount.owner // PDA that owns the vault
+ );
+
+ if (splitConfig.vault.equals(destination)) {
+ return splitConfig;
+ }
+ } catch {
+ // Not a split vault
+ }
+
+ return null;
+}
+```
+
+### Facilitator Benefits
+
+- **Single interface:** Only `execute_split` needed (self-healing handles unclaimed)
+- **Idempotent:** Safe to retry on network failures
+- **No ATA creation costs:** Protocol holds funds for missing ATAs, doesn't require facilitator to create them
+- **Multiple merchants:** Use `unique_id` to manage many configs with same token
+
+## Token Support
+
+| Token Type | Support | Notes |
+|------------|---------|-------|
+| SPL Token | ✅ Full | Standard tokens |
+| Token-2022 | ✅ Full | See extensions below |
+| Native SOL | ❌ No | Use wrapped SOL |
+
+**Token-2022 Extensions:**
+- ✅ **Transfer Fees**: Recipients receive net amounts after token's fees. Transfer fee is separate from 1% protocol fee.
+- ✅ **sRFC-37 (Frozen Accounts)**: Frozen accounts automatically trigger unclaimed flow. Funds held until account is thawed. See [sRFC-37](https://forum.solana.com/t/srfc-37-efficient-block-allow-list-token-standard/4036).
+- ✅ **Transfer Hooks**: Program invokes transfer hooks per Token-2022 spec. Hook failures revert the transaction.
+- ✅ **Interest-Bearing**: Supported. Interest accrues to vault before distribution.
+- ⚠️ **Confidential Transfer**: Supported but requires proper account setup by recipients.
+
+**Note on Frozen Accounts**: Tokens using sRFC-37 DefaultAccountState::Frozen (e.g., tokens with allowlists/blocklists) are supported. If a recipient's account is frozen during execution, their share is held as unclaimed until the account is thawed by the Gate Program.
+
+:::caution[Vault Freeze Warning]
+Token issuers with freeze authority can freeze the vault account itself, not just recipient accounts. If the vault is frozen, all funds are locked and no distributions can occur. There is no protocol-level recovery mechanism. When using tokens with freeze authority (e.g., regulated stablecoins), users accept that the token issuer has ultimate control over fund movement.
+:::
+
+## Events
+
+All operations emit events for indexing:
+
+| Event | Description |
+|-------|-------------|
+| `ProtocolConfigCreated` | Protocol initialized |
+| `ProtocolConfigUpdated` | Fee wallet changed |
+| `ProtocolAuthorityTransferProposed` | Authority transfer proposed |
+| `ProtocolAuthorityTransferAccepted` | Authority transfer completed |
+| `SplitConfigCreated` | New split config created |
+| `SplitExecuted` | Payment distributed (includes `held_as_unclaimed` field) |
+| `SplitConfigUpdated` | Config recipients modified |
+| `SplitConfigClosed` | Config deleted, rent reclaimed |
+
+**SplitExecuted Event Details:**
+```rust
+pub struct SplitExecuted {
+ pub config: Pubkey,
+ pub vault: Pubkey,
+ pub total_amount: u64, // Total vault balance processed
+ pub recipients_distributed: u64, // Amount sent to recipients
+ pub protocol_fee: u64, // Amount sent to protocol
+ pub held_as_unclaimed: u64, // Amount added to unclaimed
+ pub unclaimed_cleared: u64, // Amount cleared from previous unclaimed
+ pub protocol_unclaimed_cleared: u64, // Protocol fees cleared
+ pub executor: Pubkey,
+ pub timestamp: i64,
+}
+```
+
+**Use Case:** Build indexer to track all configs, executions, and analytics.
+
+## Error Codes
+
+| Code | Description |
+|------|-------------|
+| `InvalidRecipientCount` | Recipients count not in 1-20 range |
+| `InvalidSplitTotal` | Percentages don't sum to 9900 bps |
+| `DuplicateRecipient` | Same address appears twice |
+| `ZeroAddress` | Recipient address is zero |
+| `ZeroPercentage` | Recipient percentage is zero |
+| `RecipientATADoesNotExist` | Required ATA not found |
+| `RecipientATAInvalid` | ATA is not the canonical derived address |
+| `RecipientATAWrongOwner` | ATA owner doesn't match recipient |
+| `RecipientATAWrongMint` | ATA mint doesn't match config |
+| `VaultNotEmpty` | Vault must be empty for this operation |
+| `InvalidVault` | Vault doesn't match config |
+| `InsufficientRemainingAccounts` | Not enough accounts provided |
+| `MathOverflow` | Arithmetic overflow |
+| `MathUnderflow` | Arithmetic underflow |
+| `InvalidProtocolFeeRecipient` | Protocol ATA validation failed |
+| `Unauthorized` | Signer not authorized |
+| `AlreadyInitialized` | Protocol already initialized |
+| `UnclaimedNotEmpty` | Unclaimed amounts must be cleared first |
+| `InvalidTokenProgram` | Token account owned by wrong program |
+| `NoPendingTransfer` | No pending authority transfer to accept |
+| `InvalidRentDestination` | Rent destination doesn't match original payer |
+
+## Security
+
+### Implemented Protections
+
+- ✅ Non-custodial (PDA-owned vaults)
+- ✅ Idempotent execution (unclaimed funds protected from re-splitting)
+- ✅ Overflow/underflow checks (all math uses `checked_*`)
+- ✅ Duplicate recipient validation
+- ✅ Bounded account size (max 20 recipients)
+- ✅ Protocol fee enforcement (cannot be bypassed)
+- ✅ Configurable protocol wallet
+- ✅ Fixed space allocation (zero-copy)
+
+### Known Limitations
+
+- No pause mechanism (redeploy if critical issue found)
+- Single authority per config (use Squads multisig as authority for multi-sig control)
+- Unclaimed funds never expire
+- Vault freeze risk: Token-2022 issuers with freeze authority can freeze the vault directly, locking all funds with no protocol-level recovery (see Token Support section)
+
+## Design Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| **Hardcoded 1% fee** | Transparency for integrators. Anyone can verify on-chain. Avoids calculation complexity and potential bugs. Protocol redeploys if fee change needed. |
+| **Empty vault for updates** | Ensures funds are split according to rules active when received. Prevents race conditions. |
+| **Update preserves vault address** | Vault address is the stable public interface. Payers shouldn't need to update their systems when business arrangements change. |
+| **unique_id over counter** | Client generates (no on-chain state management). Enables parallel creation without contention. Simple implementation. |
+| **Self-healing over separate claim** | Single idempotent interface for facilitators. Simplifies integration. Recipients auto-receive on next execution. No additional flow to maintain. |
+| **Protocol unclaimed tracking** | Enables permissionless support for any token. Protocol doesn't need to pre-create ATAs for every possible token. Fees are preserved until protocol ATA exists. |
+| **Zero-copy with fixed arrays** | ~50% serialization CU savings. Fixed rent (~0.015 SOL) is negligible vs cumulative compute savings across thousands of transactions. Critical for high-throughput micropayments. |
+| **Stored bumps** | All PDAs store their bump. Constraints use stored bump instead of deriving, saving ~1,300 CU per account validation. |
+| **remaining_accounts pattern** | Recipient count is variable (1-20). Anchor requires dynamic account lists via remaining_accounts. Accounts in config order with protocol ATA last. |
+| **Minimal logging** | Production builds avoid `msg!` statements. Each costs ~100-200 CU. Debug logging via feature flag. |
+| **No streaming/partial splits** | Different product category (see Streamflow, Zebec). Cascade Splits is for instant one-time splits. |
+| **No native SOL** | Adds complexity. Use wrapped SOL instead. |
+| **No built-in multi-sig** | Use Squads/Realms as authority. Works with current design without added complexity. |
+| **Pre-existing ATAs required** | Protects facilitators from being drained by forced ATA creation (0.002 SOL each). Config creators responsible for recipient readiness. |
+| **Two-step authority transfer** | Prevents accidental irreversible authority transfers. Current authority proposes, new authority accepts. Can be cancelled before acceptance. |
+| **Payer separation** | Separates rent payer from authority. Enables sponsored rent (protocol/third party pays) while user retains control. Rent refunds go to original payer, not authority. |
+| **Activity timestamp tracking** | Enables future stale account cleanup without breaking changes. Updated on every execution. |
+| **Frozen account detection** | sRFC-37 tokens with DefaultAccountState::Frozen are detected before transfer attempts (~300 CU per recipient). Frozen accounts trigger unclaimed flow rather than transaction failure. Minimal overhead for compatibility with allowlist/blocklist tokens. |
+| **Vault rent recovery on close** | Close instruction closes both config and vault via CPI, recovering all rent (~0.017 SOL total). Adds ~5,000 CU to close operation but ensures no rent is left behind. |
+| **Canonical ATA enforcement** | All recipient and protocol ATAs must be canonical derived addresses. Prevents funds from being sent to non-standard accounts that recipients may not monitor. Consistent with security best practices. |
+
+## Technical Details
+
+**Dependencies:**
+```toml
+anchor-lang = "0.32.1"
+anchor-spl = "0.32.1"
+```
+
+**Constants:**
+```rust
+PROTOCOL_FEE_BPS: u16 = 100; // 1%
+REQUIRED_SPLIT_TOTAL: u16 = 9900; // Recipients must total 99%
+MIN_RECIPIENTS: usize = 1;
+MAX_RECIPIENTS: usize = 20;
+```
+
+**Fixed Space (Zero-Copy):**
+```rust
+// SplitConfig size (fixed for all configs)
+pub const SPLIT_CONFIG_SIZE: usize =
+ 8 + // discriminator
+ 1 + // version
+ 32 + // authority
+ 32 + // mint
+ 32 + // vault
+ 32 + // unique_id
+ 1 + // bump
+ 1 + // recipient_count
+ (34 * 20) + // recipients [Recipient; 20]
+ (48 * 20) + // unclaimed_amounts [UnclaimedAmount; 20]
+ 8 + // protocol_unclaimed
+ 8 + // last_activity
+ 32; // rent_payer
+ // Total: 1,832 bytes
+
+// ProtocolConfig size
+pub const PROTOCOL_CONFIG_SIZE: usize =
+ 8 + // discriminator
+ 32 + // authority
+ 32 + // pending_authority
+ 32 + // fee_wallet
+ 1; // bump
+ // Total: 105 bytes
+```
+
+**Compute Budget:**
+Current compute unit consumption (as of 2025-11-26):
+
+| Instruction | 1 recipient | 5 recipients | 20 recipients |
+|-------------|-------------|--------------|---------------|
+| execute_split | 28,505 CU | 68,573 CU | 211,703 CU |
+| create_split_config | 36,590 CU | 40,024 CU | N/A |
+| close_split_config | 10,168 CU | N/A | N/A |
+| update_split_config | N/A | 7,424 CU (to 2) | 14,032 CU (to 10) |
+
+For high-throughput micropayments, set explicit CU limits based on recipient count:
+```typescript
+// Conservative estimate: 30,000 base + (3,500 * recipient_count)
+const computeUnits = 30_000 + (recipientCount * 3_500);
+
+transaction.add(
+ ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits })
+);
+```
+
+**Logging:**
+Production builds use minimal logging to save compute. Debug logging available via feature flag:
+```rust
+#[cfg(feature = "verbose")]
+msg!("Debug: {}", value);
+```
diff --git a/apps/docs/glossary.md b/apps/docs/glossary.md
new file mode 100644
index 0000000..95f1d5e
--- /dev/null
+++ b/apps/docs/glossary.md
@@ -0,0 +1,102 @@
+# Glossary
+
+Canonical terminology for Cascade Splits. **Solana program is the source of truth** - EVM adapts where platform requires.
+
+---
+
+## Accounts & Contracts
+
+| Term | Definition |
+|------|------------|
+| **ProtocolConfig** | Global singleton storing protocol settings: authority, pending authority, fee wallet. |
+| **SplitConfig** | Per-split configuration storing: authority, token, vault, recipients, unclaimed amounts. |
+| **Vault** | Token account holding funds pending distribution. On Solana: ATA owned by SplitConfig PDA. On EVM: balance held by SplitConfig contract itself. |
+
+## Data Structures
+
+| Term | Definition |
+|------|------------|
+| **Recipient** | Entry in SplitConfig: wallet address + percentage in basis points. |
+| **UnclaimedAmount** | Funds held when recipient transfer fails. Automatically retried on next execution. |
+
+## Values
+
+| Term | Definition |
+|------|------------|
+| **percentage_bps** | On-chain recipient percentage in basis points. 99 bps = 1%. Recipients must sum to 9900 bps (99%). |
+| **share** | SDK/UI percentage (1-100). Converted to bps via `share × 99`. |
+| **protocol_fee** | Fixed 1% (100 bps) taken from each distribution. |
+
+## Instructions
+
+| Instruction | Description |
+|-------------|-------------|
+| **create_split_config** | Create new SplitConfig with recipients. |
+| **execute_split** | Distribute vault balance to recipients. Permissionless. |
+| **update_split_config** | Change recipients. Requires empty vault, no unclaimed. *(Solana only)* |
+| **close_split_config** | Delete SplitConfig, recover rent. *(Solana only)* |
+
+## Protocol Instructions
+
+| Instruction | Description |
+|-------------|-------------|
+| **initialize_protocol** | Create ProtocolConfig. One-time setup. |
+| **update_protocol_config** | Change fee wallet. |
+| **transfer_protocol_authority** | Start two-step authority transfer. |
+| **accept_protocol_authority** | Complete authority transfer. |
+
+---
+
+## Platform Adaptations
+
+Where platforms diverge from canonical terms:
+
+| Concept | Solana | EVM | Reason |
+|---------|--------|-----|--------|
+| Token reference | `mint` | `token` | "Mint" means creating tokens in EVM |
+| Case convention | `snake_case` | `camelCase` | Platform convention |
+| Recipient updates | Supported | Not supported | EVM uses immutable args for gas optimization |
+| Close/reclaim | Supported | Not applicable | EVM has no rent model |
+| Vault location | Separate ATA | Contract balance | Platform architecture |
+| Vault address | Different from SplitConfig PDA | Same as SplitConfig address | EVM splits hold funds directly |
+| ProtocolConfig | Separate account | Embedded in SplitFactory | Factory IS the protocol config |
+| initialize_protocol | Explicit instruction | Constructor | One-time at factory deploy |
+
+---
+
+## SDK Naming
+
+SDK follows on-chain names, adapting case per platform:
+
+```typescript
+// Solana SDK
+createSplitConfig(...)
+executeSplit(...)
+updateSplitConfig(...)
+closeSplitConfig(...)
+
+// EVM SDK
+createSplitConfig(...)
+executeSplit(...)
+// No update/close on EVM
+```
+
+**Types use PascalCase on both platforms:**
+- `SplitConfig`
+- `ProtocolConfig`
+- `Recipient`
+
+---
+
+## Identifier Convention
+
+The **vault address** is the primary user-facing identifier for a split:
+- It's what users deposit to
+- It's what appears in block explorers
+- SDK functions accept vault to look up SplitConfig
+
+```typescript
+// Both platforms: vault is the deposit address
+getSplit(vault) // Returns SplitConfig
+executeSplit(vault) // Distributes vault balance
+```
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 731f5a3..dbef974 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -1,42 +1,17 @@
{
- "name": "docs",
- "private": true,
+ "name": "@cascade-fyi/docs",
"type": "module",
+ "version": "0.0.1",
+ "private": true,
"scripts": {
- "build": "react-router build",
- "dev": "react-router dev",
- "start": "serve ./build/client",
- "types:check": "react-router typegen && fumadocs-mdx && tsc --noEmit",
- "postinstall": "fumadocs-mdx",
- "type-check": "pnpm types:check",
- "lint": "biome check",
- "format": "biome format --write",
- "check": "pnpm type-check && biome check --write"
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro"
},
"dependencies": {
- "@orama/orama": "^3.1.16",
- "@react-router/node": "^7.9.5",
- "fumadocs-core": "16.2.1",
- "fumadocs-mdx": "14.0.4",
- "fumadocs-ui": "16.2.1",
- "isbot": "^5.1.32",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-router": "^7.9.5"
- },
- "devDependencies": {
- "@react-router/dev": "^7.9.5",
- "@tailwindcss/vite": "^4.1.16",
- "@types/mdx": "^2.0.13",
- "@types/node": "^24.10.0",
- "@types/react": "^19.2.2",
- "@types/react-dom": "^19.2.2",
- "react-router-devtools": "^5.1.3",
- "serve": "^14.2.5",
- "tailwindcss": "^4.1.16",
- "typescript": "^5.9.3",
- "vite": "^7.2.0",
- "vite-tsconfig-paths": "^5.1.4",
- "@biomejs/biome": "^2.3.5"
+ "@astrojs/starlight": "^0.37.1",
+ "astro": "^5.16.5",
+ "sharp": "^0.34.5"
}
}
diff --git a/apps/docs/public/_redirects b/apps/docs/public/_redirects
deleted file mode 100644
index 66ecea6..0000000
--- a/apps/docs/public/_redirects
+++ /dev/null
@@ -1,2 +0,0 @@
-# SPA fallback for client-side routing
-/* /index.html 200
diff --git a/apps/docs/react-router.config.ts b/apps/docs/react-router.config.ts
deleted file mode 100644
index 9710b8c..0000000
--- a/apps/docs/react-router.config.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { Config } from "@react-router/dev/config";
-import { glob } from "node:fs/promises";
-import { createGetUrl, getSlugs } from "fumadocs-core/source";
-
-const getUrl = createGetUrl("/");
-
-export default {
- ssr: true,
- async prerender({ getStaticPaths }) {
- const paths: string[] = [];
- const excluded: string[] = [];
-
- for (const path of getStaticPaths()) {
- if (!excluded.includes(path)) paths.push(path);
- }
-
- for await (const entry of glob("**/*.mdx", { cwd: "content/docs" })) {
- paths.push(getUrl(getSlugs(entry)));
- }
-
- return paths;
- },
-} satisfies Config;
diff --git a/apps/docs/serve.json b/apps/docs/serve.json
deleted file mode 100644
index 47a1ad9..0000000
--- a/apps/docs/serve.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "rewrites": [{ "source": "/**", "destination": "/index.html" }]
-}
diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts
deleted file mode 100644
index 8905099..0000000
--- a/apps/docs/source.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig, defineDocs } from "fumadocs-mdx/config";
-
-export const docs = defineDocs({
- dir: "content/docs",
-});
-
-export default defineConfig();
diff --git a/apps/docs/specification-evm.md b/apps/docs/specification-evm.md
new file mode 100644
index 0000000..ddae740
--- /dev/null
+++ b/apps/docs/specification-evm.md
@@ -0,0 +1,1584 @@
+# [DRAFT] Cascade Splits EVM Specification
+
+**Version:** 1.0
+**Target:** Base (EVM-compatible L2)
+**Pattern:** Clone Factory (EIP-1167)
+
+---
+
+## Overview
+
+Cascade Splits EVM is a non-custodial payment splitting protocol for EVM chains that automatically distributes incoming payments to multiple recipients based on pre-configured percentages.
+
+**Design Goals:**
+- High-throughput micropayments (API calls, streaming payments)
+- Minimal gas cost per execution
+- Simple, idempotent interface for facilitators
+- Permissionless operation
+- Cross-chain parity with Solana implementation
+
+**Key Features:**
+- Accept payments to a single split address
+- Automatically split funds to 1-20 recipients
+- Mandatory 1% protocol fee (transparent, on-chain enforced)
+- ERC20 token support (USDC on Base)
+- Idempotent execution with self-healing unclaimed recovery
+- Multiple configs per authority/token via unique identifiers
+- Integration with x402 payment facilitators
+
+---
+
+## Terminology
+
+This spec follows the [canonical glossary](./glossary.md). Key EVM-specific mappings:
+
+| Glossary Term | EVM Implementation | Notes |
+|---------------|-------------------|-------|
+| **ProtocolConfig** | `SplitFactory` contract | Factory IS the protocol config singleton |
+| **SplitConfig** | `SplitConfigImpl` clone | Each split is a minimal proxy clone |
+| **Vault** | SplitConfig contract balance | On EVM, vault = split address (same contract) |
+| **initialize_protocol** | Constructor | Factory constructor handles initialization |
+| **percentage_bps** | `percentageBps` | camelCase per EVM convention |
+
+**Vault as Primary Identifier:** The vault address (where users deposit) IS the SplitConfig address on EVM. Unlike Solana where vault is a separate ATA, EVM splits hold funds directly. SDK functions accept this address:
+
+```typescript
+getSplit(vault) // Returns SplitConfig data
+executeSplit(vault) // Distributes vault balance
+```
+
+---
+
+## How It Works
+
+### 1. Setup
+
+Authority creates a **split config** via the factory defining:
+- Token address (USDC, etc.)
+- Recipients and their percentages (must total 99%)
+- Unique identifier (enables multiple configs per authority/token)
+
+The factory deploys a minimal proxy clone with a deterministic address.
+
+### 2. Payment Flow
+
+```
+Payment → SplitConfig → executeSplit() → Recipients (99%) + Protocol (1%)
+```
+
+**Without Facilitator:**
+1. Payment sent to split address
+2. Anyone calls `executeSplit()`
+3. Funds distributed
+
+**With x402 Facilitator:**
+1. Facilitator sends payment via EIP-3009 `transferWithAuthorization`
+2. Anyone calls `executeSplit()` to distribute
+3. Recipients receive their shares
+
+### 3. Idempotent Execution
+
+`executeSplit` is designed to be idempotent and self-healing:
+- Multiple calls on the same state produce the same result
+- Only new funds (balance minus unclaimed) are split
+- Previously unclaimed amounts are automatically delivered when transfers succeed
+- Facilitators can safely retry without risk of double-distribution
+
+---
+
+## Core Concepts
+
+### EIP-1167 Clone Pattern with Immutable Args
+
+- SplitConfig contracts are minimal proxy clones with appended data (~45 bytes + immutable args)
+- Single implementation contract, many lightweight clones
+- ~83k gas deployment with immutable args (vs ~290k baseline, vs ~500k full contract)
+- Deterministic addresses via CREATE2 (salt includes authority, token, uniqueId)
+- Recipients and configuration encoded in clone bytecode—no storage initialization needed
+- No initializer function—all data read from bytecode via CODECOPY
+
+### Self-Healing Unclaimed Recovery
+
+If a recipient transfer fails during execution:
+1. Their share is recorded as "unclaimed" and stays in contract
+2. Unclaimed funds are protected from re-splitting
+3. On subsequent `executeSplit` calls, system auto-retries failed transfers
+4. Once transfer succeeds, funds are delivered
+5. No separate claim instruction needed
+
+### Protocol Fee
+
+- **Fixed 1%** enforced by contract (transparent, cannot be bypassed)
+- Recipients control the remaining 99%
+- Example: `[90%, 9%]` = 99% total ✓
+- Invalid: `[90%, 10%]` = 100% total ✗
+
+### Execution Behavior
+
+**Zero-balance execution:** Calling `executeSplit()` with no new funds is a no-op for distribution but still attempts to clear any pending unclaimed amounts. No revert, emits event with zero amounts.
+
+**Rounding:** Recipient shares use floor division. Dust (remainder from rounding) goes to protocol fee. Example with 100 tokens and 3 recipients at 33% each:
+- Recipients: 33 + 33 + 33 = 99 tokens
+- Protocol: 1 token (fee) + 0 tokens (dust in this case)
+
+**Note:** With very small distributions or low-decimal tokens, small-percentage recipients may receive 0 due to floor division. This is expected behavior.
+
+### Dust and Minimum Amounts
+
+There is no minimum execution amount. For very small distributions, floor division may result in some recipients receiving 0:
+
+```
+Example: 4 wei split among 5 recipients at 19.8% each
+- Each recipient: floor(4 × 1980 / 10000) = 0 wei
+- Protocol receives: 4 wei (entire amount as remainder)
+```
+
+This is intentional—the protocol collects dust that would otherwise be unallocatable. For practical use with USDC (6 decimals), amounts below ~$0.01 may result in some recipients receiving 0.
+
+**Integrator guidance:** Avoid sending amounts smaller than `recipientCount × 100` base units to ensure all recipients receive non-zero shares.
+
+### Naming Parity
+
+All function names aligned with Solana implementation (camelCase for EVM):
+
+| Solana (snake_case) | EVM (camelCase) | Notes |
+|---------------------|-----------------|-------|
+| `create_split_config` | `createSplitConfig` | |
+| `execute_split` | `executeSplit` | |
+| `update_split_config` | — | EVM splits are immutable (gas optimization) |
+
+**Terminology adaptations:**
+- `mint` → `token` (EVM: "mint" means creating tokens)
+- `authority` retained (EVM equivalent: "owner"). Kept for cross-chain consistency.
+
+**Divergence from Solana:** EVM implementation does not support `updateSplitConfig` due to architectural optimization (immutable args pattern). This is justified by different cost models—EVM storage reads are expensive, Solana account reads are cheap.
+
+---
+
+## Regulated Token & Smart Wallet Support
+
+Cascade Splits is designed for the x402 ecosystem where **transfer failures are a normal operating condition**, not an edge case.
+
+### Why Transfers Fail
+
+| Scenario | Cause | Frequency |
+|----------|-------|-----------|
+| **Smart Wallet Recipients** | EIP-4337/6492 wallets may not be deployed yet | Growing (x402 direction) |
+| **Blocklisted Addresses** | Circle/Tether compliance (USDC, USDT) | Occasional |
+| **Allowlist Tokens** | Future KYC-enabled tokens reject non-approved recipients | Coming |
+| **Paused Tokens** | Token operations temporarily suspended | Rare |
+
+### Self-Healing as Infrastructure
+
+Traditional splits revert when any transfer fails—one bad recipient blocks all distributions. Cascade Splits treats failed transfers as **recoverable state**:
+
+```
+Payment arrives → executeSplit() called
+ ├─ Recipient A: ✓ Transfer succeeds → funds delivered
+ ├─ Recipient B: ✗ Transfer fails → stored as unclaimed
+ └─ Protocol: ✓ Transfer succeeds → fee delivered
+
+Later: executeSplit() called again
+ └─ Recipient B: ✓ Transfer succeeds → unclaimed cleared
+```
+
+**Key behaviors:**
+- Failed transfers don't revert the transaction
+- Unclaimed funds are protected from re-splitting (balance accounting)
+- Every `executeSplit()` retries all pending unclaimed amounts
+- No separate claim function needed—recipients receive automatically when conditions clear
+
+### Smart Wallet Recipients (EIP-4337/6492)
+
+x402 is moving toward smart wallet support ([Issue #639](https://github.com/coinbase/x402/issues/639), [Issue #646](https://github.com/coinbase/x402/issues/646)). Smart wallets present a unique challenge:
+
+- **Counterfactual addresses**: Wallet address is known before deployment
+- **EIP-3009 limitation**: `transferWithAuthorization` may fail if wallet has no code
+- **Coinbase Smart Wallet**: Users already encountering failures ([Issue #623](https://github.com/coinbase/x402/issues/623))
+
+Self-healing handles this gracefully:
+1. Payment lands in split (works—split is deployed)
+2. `executeSplit()` attempts transfer to smart wallet
+3. Transfer fails (no code at address)
+4. Amount stored as unclaimed
+5. User deploys their smart wallet
+6. Next `executeSplit()` succeeds—funds delivered
+
+### Permanent Blocklist Behavior
+
+If a recipient is **permanently** blocklisted (e.g., OFAC sanctions):
+
+- Funds remain in the split contract forever
+- No backdoor to redirect funds (by design)
+- Funds belong to the recipient, not the authority
+- Authority cannot reclaim or reassign
+
+This is intentional. The alternative—allowing authority to redirect funds—creates a trust assumption that contradicts the permissionless design.
+
+### Handling Problematic Recipients
+
+If a recipient becomes permanently unable to receive (blocklisted, lost keys, etc.), the recommended recovery pattern is:
+
+1. **Existing split:** Continue operating. Other recipients receive their shares normally. Problematic recipient's share accumulates as unclaimed.
+
+2. **Migration:** Authority creates new split with corrected recipients.
+
+3. **Update integration:** Change `payTo` address in x402 resource server configuration.
+
+4. **Old split:** Funds remain indefinitely. No recovery mechanism by design—this prevents authority from redirecting funds that belong to the original recipient.
+
+Adding complexity for recipient removal would introduce trust assumptions that contradict the permissionless design. The migration pattern is simpler and maintains the immutability guarantee.
+
+### Monitoring Transfer Failures
+
+The `SplitExecuted` event includes transfer status for each recipient. Integrators should monitor for:
+- Repeated failures to the same address (potential blocklist)
+- Smart wallet addresses with no code (prompt user to deploy)
+
+---
+
+## Contract Structure
+
+### SplitFactory
+
+Global factory for deploying splits. Supports versioned implementations for safe iteration during active development.
+
+```solidity
+contract SplitFactory {
+ // Versioned implementation pattern
+ address public immutable initialImplementation; // V1, never changes
+ address public currentImplementation; // Latest version for new splits
+
+ // Protocol configuration
+ address public feeWallet;
+ address public authority;
+ address public pendingAuthority;
+
+ constructor(address initialImplementation_, address feeWallet_, address authority_) {
+ if (initialImplementation_ == address(0)) revert ZeroAddress(0);
+ if (initialImplementation_.code.length == 0) revert InvalidImplementation(initialImplementation_);
+ if (feeWallet_ == address(0)) revert ZeroAddress(1);
+ if (authority_ == address(0)) revert ZeroAddress(2);
+
+ initialImplementation = initialImplementation_;
+ currentImplementation = initialImplementation_;
+ feeWallet = feeWallet_;
+ authority = authority_; // Explicit authority for CREATE2 determinism
+ emit ProtocolConfigCreated(authority_, feeWallet_);
+ }
+}
+```
+
+**Versioned implementations:**
+- `initialImplementation`: Set at factory deployment, immutable (for historical reference)
+- `currentImplementation`: Used for new splits, can be upgraded by protocol authority
+- Existing splits are unaffected by upgrades (their implementation is baked into clone bytecode)
+- Enables safe bug fixes: deploy new implementation, new splits use it, old splits unchanged
+
+**Note:** Implementation upgrades are currently instant (no timelock). This is intentional during active development for rapid iteration. Additional safeguards (timelock, multi-sig) may be added before production deployment.
+
+**No registry mapping:** Split addresses are deterministic via CREATE2. Discovery uses `SplitConfigCreated` events indexed by subgraphs. On-chain verification recomputes CREATE2 address from known parameters.
+
+**Implementation upgrade:**
+```solidity
+function upgradeImplementation(address newImplementation) external onlyAuthority {
+ if (newImplementation == address(0)) revert ZeroAddress(0);
+ if (newImplementation.code.length == 0) revert InvalidImplementation(newImplementation);
+ address oldImplementation = currentImplementation;
+ currentImplementation = newImplementation;
+ emit ImplementationUpgraded(oldImplementation, newImplementation);
+}
+```
+
+**Access control modifier:**
+```solidity
+modifier onlyAuthority() {
+ if (msg.sender != authority) revert Unauthorized(msg.sender, authority);
+ _;
+}
+```
+
+### Interfaces
+
+```solidity
+/// @notice Minimal factory interface for SplitConfig to read protocol configuration
+/// @dev Full interface includes createSplitConfig() and predictSplitAddress()
+interface ISplitFactory {
+ function feeWallet() external view returns (address);
+ function currentImplementation() external view returns (address);
+ function authority() external view returns (address);
+}
+
+/// @notice Minimal ERC20 interface for token operations
+/// @dev Use OpenZeppelin's IERC20 or Solady's equivalent in implementation
+interface IERC20 {
+ function transfer(address to, uint256 amount) external returns (bool);
+ function balanceOf(address account) external view returns (uint256);
+}
+```
+
+### SplitConfig
+
+Per-split configuration deployed as EIP-1167 clone with immutable args.
+
+```solidity
+contract SplitConfig {
+ // === IMMUTABLE (encoded in clone bytecode, read via EXTCODECOPY) ===
+ // address public factory; // Read via extcodecopy at offset 0x2d + 0
+ // address public authority; // Read via extcodecopy at offset 0x2d + 20
+ // address public token; // Read via extcodecopy at offset 0x2d + 40
+ // bytes32 public uniqueId; // Read via extcodecopy at offset 0x2d + 60
+ // Recipient[] recipients; // Read via extcodecopy at offset 0x2d + 92
+
+ // === STORAGE (only for unclaimed tracking) ===
+ uint256 private _unclaimedBitmap; // Bits 0-19: recipients, bit 20: protocol
+ mapping(uint256 => uint256) private _unclaimedByIndex; // index => amount
+}
+
+struct Recipient {
+ address addr;
+ uint16 percentageBps; // 1-9900 (0.01%-99%)
+}
+```
+
+**Immutable args byte layout:**
+
+| Offset | Size | Field | Clone Bytecode Offset |
+|--------|------|-------|----------------------|
+| 0 | 20 | factory | `0x2d + 0` |
+| 20 | 20 | authority | `0x2d + 20` |
+| 40 | 20 | token | `0x2d + 40` |
+| 60 | 32 | uniqueId | `0x2d + 60` |
+| 92 | 22×N | recipients[N] | `0x2d + 92 + i*22` |
+
+Each recipient is packed as `address (20 bytes) + uint16 percentageBps (2 bytes) = 22 bytes`. Total clone data size: `92 + 22×N` bytes where N is recipient count (1-20). The `0x2d` (45 bytes) prefix is the EIP-1167 proxy bytecode that precedes the immutable args.
+
+**Recipient count derivation:** N is not stored explicitly—it's derived from the clone's code size:
+
+```solidity
+function getRecipientCount() public view returns (uint256) {
+ // code.length = 0x2d (proxy) + 92 (fixed fields) + 22*N (recipients)
+ return (address(this).code.length - 0x2d - 92) / 22;
+}
+```
+
+**Immutable args pattern:** Recipients and configuration are encoded in the clone's bytecode, not storage. Reading from bytecode via EXTCODECOPY (~100 gas base + ~3 gas/word) is significantly cheaper than storage (~2,100 gas cold SLOAD per slot). Trade-off: splits are immutable—deploy new split to change recipients.
+
+**Lazy unclaimed tracking:**
+- `_unclaimedBitmap`: Single slot, bits indicate which indices have unclaimed
+- `_unclaimedByIndex`: Only written when transfer fails (lazy)
+- Maximum 21 bits used (20 recipients + 1 protocol)
+- Happy path: 1 SLOAD to check bitmap, skip if zero
+
+### Invariants
+
+The following properties must always hold:
+
+| Invariant | Description |
+|-----------|-------------|
+| `popcount(_unclaimedBitmap) <= 21` | Max 20 recipients + 1 protocol with unclaimed |
+| `balance >= totalUnclaimed()` | Contract holds at least enough for all unclaimed |
+| `sum(percentageBps) == 9900` | Recipients always total 99% (immutable in bytecode) |
+| `recipientCount >= 1 && <= 20` | Always 1-20 recipients (immutable in bytecode) |
+| `_unclaimedBitmap & (1 << i) != 0 ⟺ _unclaimedByIndex[i] > 0` | Bitmap and mapping stay synchronized |
+
+Where `totalUnclaimed() = sum(_unclaimedByIndex[i] for all set bits)`.
+
+**Gas bounds:** Maximum iteration in `executeSplit()` is 41 transfers (20 recipients + 1 protocol for new split, plus 21 unclaimed retries). Bitmap check short-circuits when no unclaimed exists.
+
+---
+
+## Instructions
+
+### Factory Instructions
+
+| Instruction | Description | Authorization |
+|-------------|-------------|---------------|
+| `createSplitConfig` | Deploy new split clone (uses `currentImplementation`) | Anyone |
+| `updateProtocolConfig` | Update fee wallet (validates non-zero) | Protocol authority |
+| `upgradeImplementation` | Set new implementation for future splits | Protocol authority |
+| `transferProtocolAuthority` | Propose authority transfer | Protocol authority |
+| `acceptProtocolAuthority` | Accept authority transfer | Pending authority |
+
+#### createSplitConfig
+
+```solidity
+function createSplitConfig(
+ address authority,
+ address token,
+ bytes32 uniqueId,
+ Recipient[] calldata recipients
+) external returns (address split);
+```
+
+**Parameters:**
+- `authority`: Creator/namespace address for the split (see Authority Field below)
+- `token`: ERC20 token address (e.g., USDC)
+- `uniqueId`: Unique identifier (enables multiple splits per authority/token pair)
+- `recipients`: Array of recipients with percentage allocations (must sum to 9900 bps)
+
+**Returns:** Deployed split clone address
+
+**Authority Field:**
+
+The `authority` address serves as a namespace and identifier, NOT a control mechanism:
+
+| Purpose | Description |
+|---------|-------------|
+| **CREATE2 namespace** | Ensures address uniqueness per creator (`salt = keccak256(authority, token, uniqueId)`) |
+| **Event indexing** | Allows filtering splits by creator in subgraphs via `SplitConfigCreated` event |
+| **Semantic ownership** | Identifies who configured the split for off-chain coordination |
+
+**Authority has NO on-chain privileges** for deployed splits. Splits are fully immutable and permissionlessly executable. The authority cannot:
+- Modify recipients or percentages
+- Withdraw or redirect funds
+- Pause, close, or disable the split
+
+`address(0)` is allowed as authority for "communal" splits with no attributed creator. This is useful for trustless configurations where no single party should be identified as the owner.
+
+**Validation:**
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses (for recipients)
+- No zero percentages
+- Split with same params must not already exist
+
+**Implementation note—CREATE2 collision handling:**
+
+Use Solady's `createDeterministicClone` which handles collision detection internally and returns deployment status:
+
+```solidity
+bytes32 salt = keccak256(abi.encode(authority, token, uniqueId));
+
+// Pack immutable args: factory (20) + authority (20) + token (20) + uniqueId (32) + recipients (22 each)
+bytes memory data = abi.encodePacked(address(this), authority, token, uniqueId);
+for (uint256 i; i < recipients.length; ) {
+ data = abi.encodePacked(data, recipients[i].addr, recipients[i].percentageBps);
+ unchecked { i++; }
+}
+
+(bool alreadyDeployed, address split) = LibClone.createDeterministicClone(
+ currentImplementation,
+ data,
+ salt
+);
+if (alreadyDeployed) revert SplitAlreadyExists(split);
+```
+
+This is cleaner than manual `predictDeterministicAddress` + `code.length` check—Solady handles the atomic check-and-deploy pattern internally.
+
+#### updateProtocolConfig
+
+```solidity
+function updateProtocolConfig(address newFeeWallet) external onlyAuthority;
+```
+
+Updates the protocol fee wallet. Validates non-zero address.
+
+```solidity
+function updateProtocolConfig(address newFeeWallet) external onlyAuthority {
+ if (newFeeWallet == address(0)) revert ZeroAddress(0);
+ address oldFeeWallet = feeWallet;
+ feeWallet = newFeeWallet;
+ emit ProtocolConfigUpdated(oldFeeWallet, newFeeWallet);
+}
+```
+
+#### transferProtocolAuthority
+
+```solidity
+function transferProtocolAuthority(address newAuthority) external onlyAuthority;
+```
+
+Initiates two-step authority transfer. Set to `address(0)` to cancel pending transfer.
+
+```solidity
+function transferProtocolAuthority(address newAuthority) external onlyAuthority {
+ pendingAuthority = newAuthority;
+ emit ProtocolAuthorityTransferProposed(authority, newAuthority);
+}
+```
+
+#### acceptProtocolAuthority
+
+```solidity
+function acceptProtocolAuthority() external;
+```
+
+Completes authority transfer. Must be called by pending authority.
+
+```solidity
+function acceptProtocolAuthority() external {
+ if (pendingAuthority == address(0)) revert NoPendingTransfer();
+ if (msg.sender != pendingAuthority) revert Unauthorized(msg.sender, pendingAuthority);
+ address oldAuthority = authority;
+ authority = pendingAuthority;
+ pendingAuthority = address(0);
+ emit ProtocolAuthorityTransferAccepted(oldAuthority, authority);
+}
+```
+
+### Split Instructions
+
+| Instruction | Description | Authorization |
+|-------------|-------------|---------------|
+| `executeSplit` | Distribute balance to recipients | Permissionless |
+
+#### executeSplit
+
+```solidity
+function executeSplit() external nonReentrant;
+```
+
+Distributes available balance to recipients and protocol. Automatically retries any pending unclaimed transfers. See [executeSplit Algorithm](#executesplit-algorithm) for detailed behavior.
+
+**No `updateSplitConfig`:** Splits are immutable by design. To change recipients, deploy a new split and update your `payTo` address. This provides trustless verification—payers can verify recipients on-chain before paying, and authority cannot change recipients after payment.
+
+### View Functions
+
+| Function | Returns | Description |
+|----------|---------|-------------|
+| `getRecipients()` | `Recipient[]` | All configured recipients |
+| `getRecipientCount()` | `uint256` | Number of recipients (derived from code size) |
+| `totalUnclaimed()` | `uint256` | Sum of all unclaimed amounts |
+| `hasPendingFunds()` | `bool` | True if balance > unclaimed |
+| `pendingAmount()` | `uint256` | Amount available for next execution |
+| `previewExecution()` | `(uint256[], uint256, uint256, uint256[], uint256)` | Preview complete execution (new distribution + pending unclaimed) |
+| `getBalance()` | `uint256` | Total token balance held |
+| `isCascadeSplitConfig()` | `bool` | Always returns true (for detection) |
+
+**View function implementations:**
+
+```solidity
+/// @notice Calculate total unclaimed across all recipients + protocol
+function totalUnclaimed() public view returns (uint256 total) {
+ uint256 bitmap = _unclaimedBitmap;
+ if (bitmap == 0) return 0;
+
+ for (uint256 i; i < 21; ) {
+ if (bitmap & (1 << i) != 0) {
+ total += _unclaimedByIndex[i];
+ }
+ unchecked { i++; }
+ }
+}
+```
+
+**Design note:** `totalUnclaimed()` iterates over the bitmap rather than caching the sum in storage. This trades higher read cost (up to 21 SLOADs in worst case) for lower write cost on the happy path where transfers succeed. Since failures are rare and `totalUnclaimed()` is called once per `executeSplit()`, caching would add 5,000 gas per failure/clear event to save reads that rarely happen.
+
+```solidity
+/// @notice Preview complete execution outcome including pending unclaimed
+/// @return recipientAmounts Amount each recipient would receive from new funds
+/// @return protocolFee Amount protocol would receive from new funds (1% + dust)
+/// @return available Total new funds being distributed
+/// @return pendingRecipientAmounts Unclaimed amounts per recipient that would be retried
+/// @return pendingProtocolAmount Unclaimed protocol fee that would be retried
+function previewExecution() public view returns (
+ uint256[] memory recipientAmounts,
+ uint256 protocolFee,
+ uint256 available,
+ uint256[] memory pendingRecipientAmounts,
+ uint256 pendingProtocolAmount
+) {
+ uint256 count = getRecipientCount();
+ recipientAmounts = new uint256[](count);
+ pendingRecipientAmounts = new uint256[](count);
+
+ // Calculate pending unclaimed amounts
+ uint256 bitmap = _unclaimedBitmap;
+ if (bitmap != 0) {
+ for (uint256 i; i < count; ) {
+ if (bitmap & (1 << i) != 0) {
+ pendingRecipientAmounts[i] = _unclaimedByIndex[i];
+ }
+ unchecked { i++; }
+ }
+ if (bitmap & (1 << PROTOCOL_INDEX) != 0) {
+ pendingProtocolAmount = _unclaimedByIndex[PROTOCOL_INDEX];
+ }
+ }
+
+ // Calculate new distribution
+ available = IERC20(token()).balanceOf(address(this)) - totalUnclaimed();
+ if (available == 0) return (recipientAmounts, 0, 0, pendingRecipientAmounts, pendingProtocolAmount);
+
+ uint256 distributed;
+ for (uint256 i; i < count; ) {
+ (, uint16 bps) = _getRecipient(i);
+ recipientAmounts[i] = (available * bps) / 10000;
+ distributed += recipientAmounts[i];
+ unchecked { i++; }
+ }
+
+ protocolFee = available - distributed; // 1% + dust
+}
+```
+
+### executeSplit Algorithm
+
+```
+1. Load _unclaimedBitmap (1 SLOAD)
+2. If bitmap != 0:
+ - For each set bit i in bitmap:
+ - Attempt transfer of _unclaimedByIndex[i] to recipient[i] (or feeWallet if i == 20)
+ - If success: clear bit and mapping
+ - If fail: keep as unclaimed
+3. Calculate available = token.balanceOf(this) - totalUnclaimed()
+4. If available > 0:
+ a. For each recipient i:
+ - amount[i] = available * percentageBps[i] / 10000
+ - Attempt transfer, record as unclaimed on failure
+ b. protocolFee = available - sum(amount[i]) // Includes 1% + dust
+ - Attempt transfer to feeWallet, record as unclaimed on failure
+5. Emit SplitExecuted(totalDistributed, protocolFee, unclaimedCleared, newUnclaimed)
+```
+
+**Key behaviors:**
+- Step 2 runs before step 4: unclaimed retries happen first
+- Step 4b uses subtraction, not multiplication: protocol gets exact remainder including dust
+- All transfers use self-healing wrapper: failures don't revert, they record as unclaimed
+
+### Transaction Atomicity
+
+All state changes in `executeSplit()` are atomic with the EVM transaction:
+
+1. **Partial execution rollback:** If the transaction reverts mid-execution (e.g., out of gas after some transfers), ALL state changes are rolled back, including bitmap modifications, unclaimed mapping updates, and ERC20 transfers (reverted in token contract).
+
+2. **External call isolation:** ERC20 `transfer()` calls only modify state in the token contract (balance mappings). They do not execute arbitrary code on recipient addresses for standard ERC20 tokens.
+
+3. **No cross-transaction state leakage:** Each `executeSplit()` call is independent. A failed call leaves state unchanged, and the next call starts fresh.
+
+**Note:** This guarantee relies on standard ERC20 behavior. Tokens with transfer hooks (ERC777) could introduce additional complexity—see [Gas Griefing](#gas-griefing) section.
+
+---
+
+## x402 Integration
+
+Cascade Splits integrates with the [x402 protocol](https://github.com/coinbase/x402) for internet-native payments. When a resource server sets `payTo` to a split address, funds land via EIP-3009 and can be distributed via `executeSplit`.
+
+See: [x402 Specification](https://github.com/coinbase/x402/blob/main/specs/x402-specification.md) | [EVM Scheme](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md)
+
+### Payment Flow
+
+```
+x402 Payment (EIP-3009):
+ Client signs transferWithAuthorization (to: split address)
+ → Facilitator submits to token contract
+ → Funds land in split
+
+Async Distribution:
+ Keeper/Anyone calls executeSplit()
+ → Recipients receive their shares
+ → Protocol receives 1% fee
+```
+
+### Detection
+
+**Quick check (weak):** Any contract can implement `isCascadeSplitConfig()`, so this is not authoritative:
+
+```solidity
+// Quick detection - may have false positives
+if (SplitConfig(payTo).isCascadeSplitConfig()) {
+ SplitConfig(payTo).executeSplit();
+}
+```
+
+**Verified check (strong):** Recompute CREATE2 address from immutable args:
+
+```solidity
+// Verified detection - authoritative
+address factory = SplitConfig(payTo).factory();
+if (factory == KNOWN_CASCADE_FACTORY) {
+ // Get immutable args from the split itself
+ address authority = SplitConfig(payTo).authority();
+ address token = SplitConfig(payTo).token();
+ bytes32 uniqueId = SplitConfig(payTo).uniqueId();
+ Recipient[] memory recipients = SplitConfig(payTo).getRecipients();
+
+ // Recompute CREATE2 address
+ bytes memory data = abi.encodePacked(factory, authority, token, uniqueId, recipients);
+ bytes32 salt = keccak256(abi.encode(authority, token, uniqueId));
+ address computed = LibClone.predictDeterministicAddress(
+ ISplitFactory(factory).implementation(),
+ data,
+ salt,
+ factory
+ );
+
+ if (computed == payTo) {
+ // Confirmed Cascade Split
+ SplitConfig(payTo).executeSplit();
+ }
+}
+```
+
+For most integrations, the quick check is sufficient since calling `executeSplit()` on a non-Cascade contract will simply revert.
+
+### Keeper Pattern
+
+```typescript
+// Minimal keeper for async settlement
+async function executeAllPending(splits: Address[]) {
+ for (const split of splits) {
+ if (await splitConfig.hasPendingFunds()) {
+ await splitConfig.executeSplit();
+ }
+ }
+}
+```
+
+### Token Compatibility
+
+| Token | EIP-3009 | x402 Compatible |
+|-------|----------|-----------------|
+| USDC (Base) | ✓ | ✓ |
+| USDT | ✗ | ✗ |
+| DAI | ✗ (EIP-2612) | ✗ |
+
+### Smart Wallet Recipients
+
+x402 is actively developing smart wallet support ([EIP-4337](https://github.com/coinbase/x402/issues/639), [EIP-6492](https://github.com/coinbase/x402/pull/675)). Split recipients may be:
+
+| Wallet Type | Challenge | Cascade Handling |
+|-------------|-----------|------------------|
+| **Coinbase Smart Wallet** | May not be deployed when split created | Self-healing retries until deployed |
+| **EIP-4337 Account** | UserOp execution timing varies | Self-healing bridges timing gaps |
+| **Counterfactual Wallets** | Address known before code exists | Self-healing stores until ready |
+
+**Integration pattern for facilitators:**
+
+```typescript
+// After settling x402 payment to split
+const split = SplitConfig.at(payTo);
+
+// Execute immediately - handles smart wallet failures gracefully
+await split.executeSplit();
+
+// If TransferFailed events emitted, schedule retry
+// (or rely on keeper to call executeSplit later)
+```
+
+### x402 v2 Compatibility
+
+Cascade Splits aligns with x402 v2's modular architecture:
+
+- **@x402/evm mechanism**: Splits work with existing EIP-3009 settlement
+- **Delegated billing** ([Issue #694](https://github.com/coinbase/x402/issues/694)): Complementary—billing → splits → recipients
+- **Future mechanisms**: Same `payTo` → split pattern works regardless of settlement mechanism
+
+---
+
+## Production Considerations
+
+### ERC20 Token Edge Cases
+
+**Fee-on-Transfer Tokens (PAXG, STA):**
+Supported. Recipients receive their proportional share minus transfer fees at each hop. The split percentages remain accurate relative to each other. No code changes required.
+
+**Unclaimed retry behavior with fee-on-transfer tokens:** When a transfer fails and is stored as unclaimed, the retry on subsequent `executeSplit()` calls transfers the **stored amount**, not the net amount after fees:
+
+```
+Initial execution:
+ - Split receives 990 tokens (1000 sent, 1% fee taken on deposit)
+ - Recipient A's share: 495 tokens
+ - Transfer to A fails → stored as unclaimed[0] = 495
+
+Retry execution:
+ - executeSplit() retries transfer of 495 to A
+ - Fee-on-transfer takes 1% → A receives 490 tokens
+ - unclaimed[0] cleared to 0
+```
+
+Recipients of fee-on-transfer tokens may receive slightly less than their stored unclaimed amount on retry due to the additional transfer fee. This is inherent to fee-on-transfer token mechanics and cannot be avoided without protocol-level subsidization.
+
+**Rebasing Tokens (stETH, OHM, AMPL):**
+Balance changes without transfers. Unclaimed accounting breaks. **Explicitly exclude.**
+
+**Blocklist/Pausable Tokens (USDC, USDT):**
+Circle/Tether can freeze addresses. Self-healing handles gracefully, but funds may be stuck permanently if recipient is blocklisted.
+
+### Gas Griefing
+
+**Standard ERC20:** Not vulnerable. Token `transfer()` only updates balances in the token contract - no code executes on the recipient address.
+
+**Tokens with hooks (ERC777, custom):** If supported in future, recipient contracts could consume gas via `tokensReceived` hooks. Mitigation: gas caps per transfer or explicitly exclude hook-enabled tokens.
+
+**Current scope (USDC):** USDC is standard ERC20 without hooks. No gas griefing vector.
+
+### Deterministic Address Derivation
+
+Split addresses are deterministic via CREATE2. The address depends on **both** the salt AND the immutable data (which includes recipients):
+
+```solidity
+// Salt ensures uniqueness per (authority, token, uniqueId) tuple
+bytes32 salt = keccak256(abi.encode(authority, token, uniqueId));
+
+// Immutable data is encoded in the clone bytecode
+bytes memory data = abi.encodePacked(factory, authority, token, uniqueId, recipients);
+
+// Address depends on implementation, data, salt, AND factory
+address = LibClone.predictDeterministicAddress(implementation, data, salt, factory);
+```
+
+**Note:** The salt does not include `factory` because the CREATE2 formula already incorporates the deployer address—adding it to the salt would be redundant.
+
+**Important:** Changing any parameter (including recipients) produces a different address. To compute the address off-chain, you need all parameters including the full recipient list.
+
+### L2 Compatibility
+
+**No time-based logic:** This contract has no vesting, time locks, or block-based conditions—execution is purely balance-driven. This avoids incompatibilities with L2s where `block.number` behaves differently (e.g., Polygon zkEVM uses transaction count).
+
+**zkSync Era:** Uses a different CREATE2 formula—split addresses will differ from other EVM chains. This is expected behavior (same pattern as Safe, 0xSplits).
+
+### Clone Initialization Front-Running
+
+Salt includes `authority`, so attacker cannot front-run with different recipients while using same predicted address.
+
+### Unclaimed Array Growth
+
+If many transfers fail, iteration cost grows. Current implementation handles via mapping + array pattern.
+
+### Multi-Chain Determinism
+
+CREATE2 address depends on factory address. Deploy factory via deterministic deployer (like 0age's) for same addresses across chains.
+
+---
+
+## Events
+
+| Event | Description |
+|-------|-------------|
+| `ProtocolConfigCreated` | Factory deployed |
+| `ProtocolConfigUpdated` | Fee wallet changed |
+| `ProtocolAuthorityTransferProposed` | Authority transfer initiated |
+| `ProtocolAuthorityTransferAccepted` | Authority transfer completed |
+| `ImplementationUpgraded` | New implementation set for future splits |
+| `SplitConfigCreated` | New split deployed (includes full recipient list for indexing) |
+| `SplitExecuted` | Funds distributed (includes per-recipient status) |
+| `TransferFailed` | Individual transfer failed (recipient stored as unclaimed) |
+| `UnclaimedCleared` | Previously unclaimed funds successfully delivered |
+
+### Factory Event Signatures
+
+```solidity
+/// @notice Emitted when factory is deployed
+event ProtocolConfigCreated(address indexed authority, address indexed feeWallet);
+
+/// @notice Emitted when fee wallet is updated
+event ProtocolConfigUpdated(address indexed oldFeeWallet, address indexed newFeeWallet);
+
+/// @notice Emitted when authority transfer is initiated
+event ProtocolAuthorityTransferProposed(address indexed currentAuthority, address indexed pendingAuthority);
+
+/// @notice Emitted when authority transfer is completed
+event ProtocolAuthorityTransferAccepted(address indexed oldAuthority, address indexed newAuthority);
+
+/// @notice Emitted when implementation is upgraded for future splits
+event ImplementationUpgraded(address indexed oldImplementation, address indexed newImplementation);
+```
+
+### SplitConfigCreated Details
+
+```solidity
+event SplitConfigCreated(
+ address indexed split,
+ address indexed authority,
+ address indexed token,
+ bytes32 uniqueId,
+ Recipient[] recipients
+);
+```
+
+Emitted by factory when a new split is deployed. Recipients array enables indexers to capture full configuration without additional queries.
+
+### SplitExecuted Details
+
+```solidity
+event SplitExecuted(
+ uint256 totalAmount, // Total distributed this execution
+ uint256 protocolFee, // Protocol's 1% share
+ uint256 unclaimedCleared, // Previously unclaimed now delivered
+ uint256 newUnclaimed // New transfers that failed
+);
+```
+
+### TransferFailed Details
+
+Emitted for each failed transfer during execution:
+
+```solidity
+event TransferFailed(
+ address indexed recipient,
+ uint256 amount,
+ bool isProtocol // True if protocol fee transfer failed
+);
+```
+
+**Monitoring use cases:**
+- Detect blocklisted recipients (repeated failures)
+- Identify undeployed smart wallets (prompt user action)
+- Track compliance issues with regulated tokens
+
+### UnclaimedCleared Details
+
+Emitted when a previously unclaimed transfer succeeds on retry:
+
+```solidity
+event UnclaimedCleared(
+ address indexed recipient,
+ uint256 amount,
+ bool isProtocol // True if protocol fee was cleared
+);
+```
+
+**Monitoring use cases:**
+- Track successful fund recovery after temporary failures
+- Audit trail for all fund movements (complements TransferFailed)
+- Debugging integrations (correlate with previous TransferFailed events)
+
+### Event Emission Order
+
+Events in `executeSplit()` are emitted in the following order:
+
+1. **Unclaimed retry phase** (if bitmap != 0):
+ - `UnclaimedCleared(recipient, amount, isProtocol)` for each successful retry
+ - `TransferFailed(recipient, amount, isProtocol)` for each retry that fails again
+
+2. **New distribution phase** (if available > 0):
+ - `TransferFailed(recipient, amount, false)` for each recipient transfer that fails
+ - `TransferFailed(feeWallet, amount, true)` if protocol fee transfer fails
+
+3. **Summary event** (always emitted):
+ - `SplitExecuted(totalAmount, protocolFee, unclaimedCleared, newUnclaimed)`
+
+**Rationale:** `UnclaimedCleared` and `TransferFailed` events are emitted immediately when retries/transfers occur, providing a complete audit trail. `SplitExecuted` is emitted last with aggregated data for efficient querying.
+
+---
+
+## Error Definitions
+
+Custom errors with diagnostic parameters for debugging and SDK integration:
+
+```solidity
+/// @dev Recipients array length not in [1, 20] range
+error InvalidRecipientCount(uint256 count, uint256 min, uint256 max);
+
+/// @dev Recipient percentages don't sum to 9900 bps (99%)
+error InvalidSplitTotal(uint256 actual, uint256 expected);
+
+/// @dev Same recipient address appears multiple times
+error DuplicateRecipient(address recipient, uint256 firstIndex, uint256 duplicateIndex);
+
+/// @dev Recipient or feeWallet address is zero
+error ZeroAddress(uint256 index);
+
+/// @dev Recipient has 0 bps allocation
+error ZeroPercentage(uint256 index);
+
+/// @dev Caller not authorized for this operation
+error Unauthorized(address caller, address expected);
+
+/// @dev No pending authority transfer to accept
+error NoPendingTransfer();
+
+/// @dev Split with identical params already deployed at this address
+error SplitAlreadyExists(address predicted);
+
+/// @dev Implementation address has no deployed code
+error InvalidImplementation(address implementation);
+
+/// @dev Reentrant call detected
+error Reentrancy();
+```
+
+**Rationale:** Parameterized errors cost minimal bytecode (defined once) but dramatically improve debugging. When `InvalidRecipientCount(25, 1, 20)` is thrown, the issue is immediately clear vs tracing through transaction logs.
+
+---
+
+## Security
+
+### Implemented Protections
+
+- ReentrancyGuard on `executeSplit` only (Solady's `ReentrancyGuardTransient` via EIP-1153)
+- Self-healing transfer wrapper (catches failures, records as unclaimed—see Audit Considerations)
+- Overflow protection (Solidity 0.8+)
+- Two-step protocol authority transfer (prevents accidental transfers)
+- Duplicate recipient validation at creation
+- Bounded recipient count (max 20, bounds gas consumption)
+- Self-healing unclaimed pattern (CEI-compliant)
+- Zero-address validation on feeWallet updates
+- Implementation code-length validation on upgrades
+
+### Not Implemented (by design)
+
+- Pausability (see rationale below)
+- Per-split upgrades (existing splits use fixed implementation, trust-minimized)
+- Time locks (unnecessary—no high-stakes parameter changes in splits)
+- Close/reclaim (no rent on EVM)
+- Native ETH support (ERC20 only—simplifies implementation, USDC is primary use case)
+
+**No Pause Mechanism Rationale:**
+
+The factory and split contracts have no pause functionality:
+
+- **Splits are immutable** — No parameter changes after creation
+- **Funds are isolated** — Each split holds its own funds, factory has none
+- **Bug mitigation** — Deploy new implementation; existing splits unaffected
+- **Trust minimization** — No authority can halt user operations
+- **Gas efficiency** — No pause check (+2,100 gas) on every `createSplitConfig`
+
+If a critical vulnerability is discovered:
+1. Upgrade factory implementation (new splits use fixed code)
+2. Existing splits continue operating (immutable, no migration path needed)
+3. Users can create new splits with fixed implementation
+
+This follows the pattern of other simple, audited protocols (Safe, Uniswap v3 core) that prioritize immutability over pausability.
+
+### Audit Considerations
+
+**Reentrancy:**
+- Solady's `ReentrancyGuardTransient` prevents same-tx reentrancy (uses slot `0x929eee149b4bd21268`)
+- CEI pattern followed: bitmap updated before external calls
+- Only `executeSplit` needs guard (only state-changing function with external calls)
+
+**Bitmap synchronization:**
+- Invariant: `bitmap bit set ⟺ unclaimedByIndex[i] > 0`
+- Both updated atomically within same transaction
+- Reentrancy guard prevents concurrent modifications
+
+**Unclaimed index mapping:**
+- Indices 0-19: Recipients (fixed, immutable in bytecode)
+- Index 20: Protocol fee
+- Indices never change after split creation
+
+**Protocol fee unclaimed handling:**
+
+The protocol fee wallet (index 20) uses the same self-healing pattern as recipients. If the fee wallet transfer fails (e.g., fee wallet is blocklisted):
+1. Fee amount is stored in `_unclaimedByIndex[20]`
+2. Bit 20 is set in `_unclaimedBitmap`
+3. Next `executeSplit()` retries the transfer to current `feeWallet` from factory
+4. If `feeWallet` was updated via `updateProtocolConfig`, retry succeeds to new address
+
+This ensures protocol fees are never lost—they remain in the split contract until successfully delivered. The fee wallet address is read from the factory on each execution, not stored in the split, so updating the factory's fee wallet allows recovery of stuck protocol fees.
+
+**Factory call pattern for feeWallet:**
+```solidity
+// In SplitConfigImpl.executeSplit()
+address feeWallet = ISplitFactory(factory()).feeWallet();
+// factory() uses EXTCODECOPY to read from clone bytecode (see Gas Optimization section)
+```
+
+**Token compatibility:**
+- Fee-on-transfer tokens: supported (recipients receive post-fee amounts)
+- Rebasing tokens: explicitly excluded (documented, not enforced)
+
+**Self-healing transfer wrapper (why NOT SafeERC20):**
+
+`SafeERC20.safeTransfer` reverts on failure—if one recipient is blocklisted, the entire `executeSplit()` transaction reverts and nobody gets paid. Self-healing **requires** catching failures gracefully:
+
+| Pattern | On Transfer Failure | Result |
+|---------|---------------------|--------|
+| `SafeERC20.safeTransfer()` | Reverts entire tx | All recipients blocked |
+| Manual `call()` | Returns false | Failed recipient stored as unclaimed, others paid |
+
+We use assembly-based transfer following Solady's `trySafeTransferFrom` pattern (adapted for `transfer`):
+
+```solidity
+/// @dev Attempts ERC20 transfer without reverting. Returns success status.
+/// Follows Solady's SafeTransferLib pattern for robust token handling.
+function _trySafeTransfer(address token, address to, uint256 amount)
+ private
+ returns (bool success)
+{
+ /// @solidity memory-safe-assembly
+ assembly {
+ mstore(0x14, to) // Store the `to` argument.
+ mstore(0x34, amount) // Store the `amount` argument.
+ mstore(0x00, 0xa9059cbb000000000000000000000000) // `transfer(address,uint256)`.
+ success := call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
+ if iszero(and(eq(mload(0x00), 1), success)) {
+ // Success if: call succeeded AND (no code at token OR returndata is empty)
+ success := lt(or(iszero(extcodesize(token)), returndatasize()), success)
+ }
+ mstore(0x34, 0) // Restore the part of the free memory pointer that was overwritten.
+ }
+}
+
+/// @dev Transfer with self-healing fallback. Records failures as unclaimed.
+function _safeTransferWithFallback(
+ address token,
+ address to,
+ uint256 amount,
+ uint256 index
+) private returns (bool success) {
+ success = _trySafeTransfer(token, to, amount);
+
+ if (!success) {
+ // Record as unclaimed for retry on next execution
+ _unclaimedByIndex[index] += amount;
+ _unclaimedBitmap |= (1 << index);
+ emit TransferFailed(to, amount, index == PROTOCOL_INDEX);
+ }
+}
+```
+
+**Why assembly-based pattern:**
+- Matches Solady's battle-tested `trySafeTransferFrom` implementation
+- Handles USDT (no return value) via `returndatasize()` check
+- Handles malformed return data (won't revert on garbage—checks for exact `1`)
+- Includes `extcodesize` check for safety edge cases
+- Catches reverts (blocklisted addresses, paused tokens) without reverting
+- Allows other recipients to receive funds even when one transfer fails
+
+**CREATE2 determinism:**
+- Salt = `keccak256(authority, token, uniqueId)`
+- Same params = same address (intentional, prevents duplicates)
+- `SplitAlreadyExists` error if clone already deployed
+
+**CREATE2 collision (known limitation):**
+
+The factory checks `predicted.code.length == 0` before deploying. If code already exists at the predicted address (deployed by someone else), `SplitAlreadyExists` is thrown. We do NOT verify whether existing code is a valid SplitConfig.
+
+*Why this is acceptable:*
+- Attack requires knowing factory address + exact salt before factory deployment
+- Even if successful, attacker only griefs one specific split creation—no funds at risk
+- Using `isCascadeSplitConfig()` check would be spoofable (any contract can implement it)
+- Realistic threat level: zero (requires predicting deterministic deployer output)
+
+---
+
+## Gas Optimization
+
+Optimized for high-throughput micropayments where `executeSplit` is called frequently.
+
+### Architectural Optimizations
+
+| Optimization | Creation Impact | Execution Impact | Trade-off |
+|--------------|-----------------|------------------|-----------|
+| **Immutable args** | -65% | -78% | No in-place recipient updates |
+| **No factory registry** | -7% | None | No on-chain enumeration |
+| **Lazy unclaimed bitmap** | None | -11% | View functions slightly complex |
+| **Combined** | -71% | -89% | See above |
+
+**Measured gas costs:**
+
+| Recipients | `createSplitConfig` | `executeSplit` |
+|------------|---------------------|----------------|
+| 2 | 93k | 91k |
+| 5 | 117k | 170k |
+| 10 | 163k | 303k |
+| 20 | 276k | 567k |
+
+Gas scales linearly with recipient count due to ERC20 transfers and bytecode encoding.
+
+### Clones with Immutable Args
+
+Recipients stored in clone bytecode instead of storage. **Use Solady's `LibClone`** (not OpenZeppelin—OZ doesn't support immutable args).
+
+> ⚠️ **CRITICAL: Two Incompatible CWIA Patterns Exist**
+>
+> Solady has **TWO libraries** for clones with immutable args—they are **INCOMPATIBLE**:
+>
+> | Library | Location | Args Storage | Reading Method |
+> |---------|----------|--------------|----------------|
+> | **LibClone** | `utils/LibClone.sol` | Bytecode only | EXTCODECOPY or `LibClone.argsOnClone()` |
+> | **LibCWIA** (legacy) | `utils/legacy/LibCWIA.sol` | Appended to calldata | `_getArg*()` via `CWIA.sol` |
+>
+> **We use LibClone (modern pattern).** From LibClone.sol:
+> > "The implementation of CWIA here does NOT append the immutable args into the calldata passed into delegatecall."
+>
+> **DO NOT:**
+> - Import or inherit from `CWIA.sol` or `LibCWIA.sol`
+> - Use `_getArgAddress()`, `_getArgUint256()`, or other `_getArg*()` helpers
+> - Read args via `calldataload`—args are NOT in calldata with LibClone
+>
+> **DO:**
+> - Use inline `extcodecopy` assembly (shown below)
+> - Or use `LibClone.argsOnClone(address(this))` helper
+> - Read from bytecode offset `0x2d` (45 bytes = proxy code size)
+
+```solidity
+import {LibClone} from "solady/utils/LibClone.sol";
+
+// Factory deploys clone with appended data
+bytes memory data = abi.encodePacked(
+ factory, // 20 bytes
+ authority, // 20 bytes
+ token, // 20 bytes
+ uniqueId, // 32 bytes
+ recipients // 22 bytes each (address + uint16)
+);
+address split = LibClone.cloneDeterministic(currentImplementation, data, salt);
+```
+
+**Reading immutable args from bytecode (in SplitConfig implementation):**
+
+Solady's `LibClone` stores immutable args in the clone's bytecode after the proxy code (offset 0x2d = 45 bytes). **Important:** Unlike wighawag's original CWIA pattern, Solady does NOT append args to calldata during delegatecall—they remain in bytecode only.
+
+```solidity
+import {LibClone} from "solady/utils/LibClone.sol";
+
+contract SplitConfigImpl {
+ // Byte offsets in clone bytecode (after 0x2d proxy bytes)
+ uint256 private constant _FACTORY_OFFSET = 0;
+ uint256 private constant _AUTHORITY_OFFSET = 20;
+ uint256 private constant _TOKEN_OFFSET = 40;
+ uint256 private constant _UNIQUE_ID_OFFSET = 60;
+ uint256 private constant _RECIPIENTS_OFFSET = 92;
+
+ // Gas-efficient: inline assembly reads directly from clone bytecode
+ // EXTCODECOPY: ~3 gas per word vs SLOAD: 2,100 gas (cold)
+ function factory() public view returns (address result) {
+ assembly {
+ extcodecopy(address(), 0x00, 0x2d, 0x20) // 0x2d = proxy code size
+ result := shr(96, mload(0x00)) // Right-align address
+ }
+ }
+
+ function authority() public view returns (address result) {
+ assembly {
+ extcodecopy(address(), 0x00, add(0x2d, 20), 0x20)
+ result := shr(96, mload(0x00))
+ }
+ }
+
+ function token() public view returns (address result) {
+ assembly {
+ extcodecopy(address(), 0x00, add(0x2d, 40), 0x20)
+ result := shr(96, mload(0x00))
+ }
+ }
+
+ function uniqueId() public view returns (bytes32 result) {
+ assembly {
+ extcodecopy(address(), 0x00, add(0x2d, 60), 0x20)
+ result := mload(0x00)
+ }
+ }
+
+ // Reading packed recipients (22 bytes each: address + uint16)
+ function _getRecipient(uint256 index) internal view returns (address addr, uint16 bps) {
+ uint256 offset = 0x2d + 92 + (index * 22); // After proxy + fixed fields
+ assembly {
+ extcodecopy(address(), 0x00, offset, 0x20)
+ addr := shr(96, mload(0x00))
+ extcodecopy(address(), 0x00, add(offset, 20), 0x20)
+ bps := shr(240, mload(0x00))
+ }
+ }
+
+ // Alternative: Use LibClone helper (allocates memory, less gas-efficient)
+ function _getAllArgs() internal view returns (bytes memory) {
+ return LibClone.argsOnClone(address(this));
+ }
+}
+```
+
+**Note:** `address(this)` inside the implementation refers to the clone proxy (where args are stored), not the implementation contract. This is because the clone delegates calls to the implementation while maintaining its own address context.
+
+**Why Solady over OpenZeppelin:**
+- Native `cloneDeterministicWithImmutableArgs` support
+- Highly gas-optimized (hand-tuned assembly)
+- Battle-tested, maintained by Vectorized
+- OpenZeppelin's `Clones.sol` lacks immutable args support
+
+### Compiler Requirements
+
+**Solidity 0.8.30+** required for native transient storage support.
+
+```toml
+# foundry.toml
+[profile.default]
+solc = "0.8.30"
+optimizer = true
+optimizer_runs = 1000000 # Optimize for runtime (frequently called)
+evm_version = "cancun" # Required for transient storage (Base L2)
+```
+
+**Note:** Solidity 0.8.30 defaults to "prague" EVM version, but we explicitly set "cancun" for Base L2 compatibility. Cancun includes EIP-1153 (transient storage) which is all we need.
+
+### Transient Storage ReentrancyGuard
+
+Use Solady's `ReentrancyGuardTransient` for reentrancy protection via EIP-1153. Saves ~9,800 gas per `executeSplit` call compared to traditional storage-based guards.
+
+```solidity
+import {ReentrancyGuardTransient} from "solady/utils/ReentrancyGuardTransient.sol";
+
+contract SplitConfigImpl is ReentrancyGuardTransient {
+ function executeSplit() external nonReentrant {
+ // ...
+ }
+}
+```
+
+**Why Solady over custom assembly:**
+- Battle-tested implementation with known slot allocation
+- Inheritance-safe (uses pseudo-random slot `0x929eee149b4bd21268`, not slot 0)
+- Consistent with project's existing Solady dependency (LibClone)
+- Less custom code to audit
+
+### Lazy Unclaimed Bitmap
+
+Only write to storage when transfers fail:
+
+```solidity
+uint256 private _unclaimedBitmap;
+mapping(uint256 => uint256) private _unclaimedByIndex;
+
+function executeSplit() external nonReentrant {
+ uint256 bitmap = _unclaimedBitmap; // 1 SLOAD
+
+ // Happy path: bitmap is 0, skip unclaimed processing entirely
+ if (bitmap != 0) {
+ for (uint256 i; i < 21; ) {
+ if (bitmap & (1 << i) != 0) {
+ // Try to clear unclaimed[i]
+ }
+ unchecked { i++; }
+ }
+ }
+
+ // Process distribution...
+ // Only write on failure:
+ if (!success) {
+ _unclaimedByIndex[i] = amount;
+ _unclaimedBitmap |= (1 << i);
+ }
+}
+```
+
+### Storage Patterns
+
+**Constants and Immutables:** Use `constant` for compile-time values, `immutable` for constructor-set values. Saves 2,100 gas per read vs storage variables.
+
+```solidity
+// Constants (inlined, 0 gas read)
+uint16 public constant PROTOCOL_FEE_BPS = 100;
+uint16 public constant REQUIRED_SPLIT_TOTAL = 9900;
+```
+
+### L2 Optimization Priority (Post-Dencun)
+
+With EIP-4844 blobs, L2 calldata costs are minimal. Optimization priority:
+
+1. **Execution gas** - Storage reads/writes, ERC20 transfers
+2. **Storage patterns** - Packing, caching, transient storage
+3. **Calldata size** - Less critical on L2s post-Dencun
+
+---
+
+## SDK Usage
+
+Per [glossary](./glossary.md), vault address is the primary identifier. On EVM, vault = split address.
+
+```typescript
+import { CascadeSplits } from "@cascade-fyi/splits-sdk/evm";
+
+const sdk = new CascadeSplits({ rpcUrl: "https://mainnet.base.org" });
+
+// Create split config - returns vault (deposit address)
+const { vault, tx } = await sdk.createSplitConfig({
+ authority,
+ token: USDC_BASE,
+ recipients: [
+ { addr: platform, percentageBps: 900 }, // 9%
+ { addr: merchant, percentageBps: 9000 }, // 90%
+ ],
+});
+
+// Get split data by vault address
+const split = await sdk.getSplit(vault);
+
+// Execute split (distribute vault balance)
+await sdk.executeSplit(vault);
+
+// Detect if address is a Cascade split
+const isSplit = await sdk.detectSplitConfig(vault);
+```
+
+**Note:** `vault` and `split` refer to the same address on EVM. The SDK uses `vault` as the parameter name for consistency with Solana SDK where they differ.
+
+---
+
+## Deployment
+
+### Deterministic Multi-Chain Deployment
+
+Factory deployed to **same address on all chains** using CREATE2 via deterministic deployer.
+
+```solidity
+// 0age's Deterministic Deployment Proxy (same address on all EVM chains)
+address constant DETERMINISTIC_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C;
+```
+
+**Deployment script:**
+```solidity
+// script/Deploy.s.sol
+import {Script} from "forge-std/Script.sol";
+import {SplitFactory} from "../src/SplitFactory.sol";
+import {SplitConfigImpl} from "../src/SplitConfigImpl.sol";
+
+contract Deploy is Script {
+ bytes32 constant SALT = keccak256("cascade-splits-v1");
+
+ function run() external {
+ vm.startBroadcast();
+
+ // Deploy implementation first
+ SplitConfigImpl impl = new SplitConfigImpl{salt: SALT}();
+
+ // Deploy factory with deterministic address
+ SplitFactory factory = new SplitFactory{salt: SALT}(
+ address(impl), // initialImplementation
+ feeWallet
+ );
+
+ vm.stopBroadcast();
+ }
+}
+```
+
+```bash
+# Deploy to Base (primary chain)
+forge script script/Deploy.s.sol --rpc-url base --broadcast --verify
+
+# Future: Deploy to additional chains (same address via deterministic deployment)
+forge script script/Deploy.s.sol --rpc-url polygon --broadcast --verify
+forge script script/Deploy.s.sol --rpc-url bnb --broadcast --verify
+```
+
+### Contract Addresses
+
+**Deterministic addresses (same on ALL EVM chains):**
+
+| Contract | Address | Base Mainnet | Base Sepolia |
+|----------|---------|--------------|--------------|
+| SplitConfigImpl | `0xF9ad695ecc76c4b8E13655365b318d54E4131EA6` | [View](https://basescan.org/address/0xF9ad695ecc76c4b8E13655365b318d54E4131EA6) | [View](https://sepolia.basescan.org/address/0xF9ad695ecc76c4b8E13655365b318d54E4131EA6) |
+| SplitFactory | `0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7` | [View](https://basescan.org/address/0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7) | [View](https://sepolia.basescan.org/address/0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7) |
+
+These addresses are derived via CREATE2 using Arachnid's deterministic deployer and are identical on all supported networks.
+
+#### Deployment Status
+
+| Network | Status | Explorer |
+|---------|--------|----------|
+| Base Mainnet | Deployed | [View on BaseScan](https://basescan.org/address/0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7) |
+| Base Sepolia | Deployed | [View on BaseScan](https://sepolia.basescan.org/address/0x946Cd053514b1Ab7829dD8fEc85E0ade5550dcf7) |
+
+#### Future Chains (Planned)
+
+| Network | Status | Notes |
+|---------|--------|-------|
+| Polygon | Planned | Same address via deterministic deployment |
+| BNB Chain | Planned | Same address via deterministic deployment |
+
+### Multi-Chain Deployment Strategy
+
+Factory and implementation are deployed to the **same address on all chains** using CREATE2 via the deterministic deployment proxy.
+
+**Deployment requirements:**
+1. Same deployer private key
+2. Same contract bytecode (including constructor args)
+3. Same salt
+
+**Cross-chain considerations:**
+
+| Consideration | Handling |
+|---------------|----------|
+| **Token addresses differ** | USDC has different addresses per chain. Splits are token-specific. |
+| **Pre-deployment deposits** | If user sends to predicted address before deployment, funds are accessible after deployment (CREATE2 address is deterministic). |
+| **Chain-specific features** | Base-specific features (if any) documented separately. |
+
+**Note:** Same salt + same bytecode = same address across all EVM chains (except zkSync Era which uses different CREATE2 formula).
+
+---
+
+## Design Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| **Hardcoded 1% fee** | Transparency. Anyone can verify on-chain. Avoids calculation complexity. |
+| **Immutable splits** | Trustless verification—payers can verify recipients on-chain. Authority cannot rug by changing recipients post-payment. Deploy new split to change. |
+| **Immutable args in bytecode** | 88% gas savings vs storage. Recipients encoded in clone bytecode via Solady's `LibClone`. Read via EXTCODECOPY (NOT `_getArg*()` calldata helpers—see [critical warning](#clones-with-immutable-args)). |
+| **Versioned implementations** | Safe iteration during development. New splits use latest impl, existing splits unchanged. |
+| **No factory registry** | Events + CREATE2 sufficient. Saves 22k gas per creation. Indexers use events anyway. |
+| **Lazy unclaimed bitmap** | Only write storage on failure. Happy path: 1 SLOAD. 11% execution savings. |
+| **Solady over OpenZeppelin** | Native immutable args support, superior gas optimization. OZ Clones lacks this feature. |
+| **`token` not `mint`** | "Mint" means creating tokens in EVM. Avoid confusion. |
+| **No close instruction** | EVM has no rent. Contracts persist forever. No reclaim needed. |
+| **Self-healing over claim** | Single idempotent interface. Recipients auto-receive on retry. |
+| **Clone pattern** | ~83k gas deploy with immutable args. Critical for high-throughput. |
+| **Two-step protocol authority** | Higher stakes. Prevent accidental irreversible transfers. |
+| **ERC20 only, no native ETH** | Simplifies implementation. USDC is primary use case for x402. |
+
+---
+
+## Constants
+
+```solidity
+uint16 public constant PROTOCOL_FEE_BPS = 100; // 1%
+uint16 public constant REQUIRED_SPLIT_TOTAL = 9900; // 99%
+uint8 public constant MIN_RECIPIENTS = 1;
+uint8 public constant MAX_RECIPIENTS = 20;
+uint256 public constant PROTOCOL_INDEX = MAX_RECIPIENTS; // Bitmap index for protocol fee (20)
+```
+
+**PROTOCOL_INDEX explained:** Recipients use indices 0-19 (up to 20 recipients). Index 20 is reserved for protocol fee unclaimed tracking. This allows a single 21-bit bitmap to track unclaimed status for all parties:
+- Bits 0-19: Recipients (one bit per possible recipient slot)
+- Bit 20: Protocol fee wallet
+
+**Design note:** Protocol index is placed after recipients (index 20) rather than before (index 0) to enable natural array indexing where `recipients[i]` maps directly to bitmap bit `i`. This avoids off-by-one errors and simplifies loop logic.
+
+### Recipient Limits
+
+**Maximum:** 20 recipients per split
+
+**Rationale:**
+- Bounds execution gas to predictable maximum (~150,000 gas for 20 transfers)
+- Clone bytecode stays compact (<600 bytes immutable data)
+- Covers 99%+ of x402 micropayment use cases (typically 2-5 recipients)
+- Bitmap fits cleanly in single storage slot (21 bits for recipients + protocol)
+
+**Duplicate validation:** O(n²) comparison is used for duplicate detection. For 20 recipients, this is ~190 comparisons (~38,000 gas worst case). This is acceptable because:
+- Split creation is a one-time cost
+- Most splits have 2-5 recipients
+- On L2, the absolute cost is negligible (~$0.0001)
+
+**Note:** The limit is defined as a constant. Changing it requires a new factory deployment.
+
+**Comparison with industry:**
+- 0xSplits v2: No hard cap (gas limits dictate practical maximum)
+- Cascade Splits: Explicit cap for predictable gas costs and simpler UX
+
+---
+
+## Resources
+
+### Core Dependencies
+- [Solady LibClone](https://github.com/Vectorized/solady/blob/main/src/utils/LibClone.sol) - Clones with immutable args (factory + reading)
+- [Solady ReentrancyGuardTransient](https://github.com/Vectorized/solady/blob/main/src/utils/ReentrancyGuardTransient.sol) - Gas-efficient reentrancy protection via EIP-1153
+
+**Note:** We do NOT use SafeERC20 for self-healing transfers—it reverts on failure. See [Audit Considerations](#self-healing-transfer-wrapper-why-not-safeerc20) for the manual `call()` pattern we use instead.
+
+**Note on CWIA patterns:** See the [critical warning in Gas Optimization](#clones-with-immutable-args) for details on Solady's two incompatible CWIA patterns. We use `LibClone` (modern, bytecode storage) NOT `LibCWIA` (legacy, calldata appending). Reading must use EXTCODECOPY, not `_getArg*()` helpers.
+
+### Standards
+- [EIP-1167: Minimal Proxy Contract](https://eips.ethereum.org/EIPS/eip-1167)
+- [EIP-1153: Transient Storage](https://eips.ethereum.org/EIPS/eip-1153)
+
+### Related Documentation
+- [Glossary](./glossary.md) - Canonical terminology (Solana is source of truth)
+- [Solana Specification](./specification.md)
+
+### Related Projects
+- [x402 Protocol](https://github.com/coinbase/x402)
+- [0xSplits V2 Architecture](https://docs.splits.org/core/split-v2)
+- [Base Documentation](https://docs.base.org/)
+
+### Deployment
+- [Deterministic Deployment Proxy](https://github.com/Arachnid/deterministic-deployment-proxy)
+
+---
+
+**Last Updated:** 2025-12-02
diff --git a/apps/docs/specification.md b/apps/docs/specification.md
new file mode 100644
index 0000000..144b06c
--- /dev/null
+++ b/apps/docs/specification.md
@@ -0,0 +1,628 @@
+# Cascade Splits Specification
+
+**Version:** 1.1
+**GitHub:** [cascade-protocol/splits](https://github.com/cascade-protocol/splits)
+**Target:** Solana payment infrastructure
+**Terminology:** [Glossary](./glossary.md)
+
+---
+
+## Overview
+
+Cascade Splits is a non-custodial payment splitting protocol for Solana that automatically distributes incoming payments to multiple recipients based on pre-configured percentages.
+
+**Design Goals:**
+- High-throughput micropayments (API calls, streaming payments)
+- Minimal compute cost per execution
+- Simple, idempotent interface for facilitators
+- Permissionless operation
+
+**Key Features:**
+- Accept payments to a single vault address
+- Automatically split funds to 1-20 recipients
+- Mandatory 1% protocol fee (transparent, on-chain enforced)
+- Supports SPL Token and Token-2022
+- Idempotent execution with self-healing unclaimed recovery
+- Multiple configs per authority/mint via unique identifiers
+- Integration with x402 payment facilitators
+
+---
+
+## How It Works
+
+### 1. Setup
+
+Authority creates a **split config** defining:
+- Token mint (USDC, USDT, etc.)
+- Recipients and their percentages (must total 99%)
+- Unique identifier (enables multiple configs per authority/mint)
+
+The protocol automatically creates a vault (PDA-owned ATA) to receive payments.
+
+### 2. Payment Flow
+
+```
+Payment → Vault (PDA-owned) → execute_split() → Recipients
+```
+
+**Without Facilitator:**
+1. Payment sent to vault
+2. Anyone calls `execute_split()`
+3. Funds distributed
+
+**With x402 Facilitator (e.g., PayAI):**
+1. Facilitator sends payment to vault address
+2. Anyone can call `execute_split` to distribute funds
+3. Recipients receive their shares
+
+### 3. Idempotent Execution
+
+`execute_split` is designed to be idempotent and self-healing:
+- Multiple calls on the same vault state produce the same result
+- Only new funds (vault balance minus unclaimed) are split
+- Previously unclaimed amounts are automatically delivered when recipient ATAs become valid
+- Facilitators can safely retry without risk of double-distribution
+
+---
+
+## Core Concepts
+
+### PDA Vault Pattern
+
+- Vault is an Associated Token Account owned by a Program Derived Address (PDA)
+- No private keys = truly non-custodial
+- Funds can only be moved by program instructions
+
+### Self-Healing Unclaimed Recovery
+
+If a recipient's ATA is missing, invalid, or frozen during execution:
+1. Their share is recorded as "unclaimed" and stays in vault
+2. Unclaimed funds are protected from re-splitting
+3. On subsequent `execute_split` calls, the system automatically attempts to clear unclaimed
+4. Once recipient creates their ATA (or account is thawed if frozen), funds are delivered on the next execution
+5. No separate claim instruction needed - single interface for all operations
+
+**Frozen Accounts**: Token-2022 tokens using sRFC-37 DefaultAccountState::Frozen are supported. Frozen recipient accounts trigger the same unclaimed flow as missing accounts.
+
+Recipients can trigger `execute_split` themselves to retrieve unclaimed funds, even when no new payments exist. This gives recipients agency over their funds without depending on facilitators.
+
+### Protocol Fee
+
+- **Fixed 1%** enforced by program (transparent, cannot be bypassed)
+- Recipients control the remaining 99%
+- Example: `[90%, 9%]` = 99% total ✅
+- Invalid: `[90%, 10%]` = 100% total ❌
+
+**Design Decision:** Fee percentage is hardcoded for transparency. Integrators can verify the exact fee on-chain. If fee changes are needed, protocol will redeploy.
+
+### Multiple Configs per Authority
+
+Each split config includes a `unique_id` allowing an authority to create multiple configurations for the same token:
+- Facilitator managing multiple merchants
+- Different split ratios for different products
+- Parallel config creation without contention
+
+### ATA Lifecycle Strategy
+
+**At config creation:** All recipient ATAs must exist. This:
+- Ensures recipients are ready to receive funds
+- Protects facilitators from ATA creation costs (0.002 SOL × recipients)
+- Prevents malicious configs designed to drain facilitators
+
+**During execution:** Missing ATAs are handled gracefully. If a recipient accidentally closes their ATA:
+- Their share goes to unclaimed (protected from re-splitting)
+- Other recipients still receive funds
+- Funds auto-deliver when ATA is recreated
+
+This design optimizes for both security (creation) and reliability (execution).
+
+---
+
+## Account Structure
+
+### ProtocolConfig (PDA)
+
+Global protocol configuration (single instance).
+
+```rust
+#[account(zero_copy)]
+pub struct ProtocolConfig {
+ pub authority: Pubkey, // Can update config
+ pub pending_authority: Pubkey, // Pending authority for two-step transfer
+ pub fee_wallet: Pubkey, // Receives protocol fees
+ pub bump: u8, // Stored for CU optimization
+}
+```
+
+**Seeds:** `[b"protocol_config"]`
+
+**Usage:** Constraints use `bump = protocol_config.bump` to avoid on-chain PDA derivation.
+
+**Two-Step Authority Transfer:** To prevent accidental irreversible transfers, authority changes require:
+1. Current authority calls `transfer_protocol_authority` → sets `pending_authority`
+2. New authority calls `accept_protocol_authority` → completes transfer
+
+The pending transfer can be cancelled by calling `transfer_protocol_authority` with `Pubkey::default()`.
+
+### SplitConfig (PDA)
+
+Per-split configuration. Uses zero-copy for optimal compute efficiency.
+
+```rust
+#[account(zero_copy)]
+#[repr(C)]
+pub struct SplitConfig {
+ pub version: u8, // Schema version
+ pub authority: Pubkey, // Can update/close config
+ pub mint: Pubkey, // Token mint
+ pub vault: Pubkey, // Payment destination
+ pub unique_id: Pubkey, // Enables multiple configs
+ pub bump: u8, // Stored for CU optimization
+ pub recipient_count: u8, // Active recipients (1-20)
+ pub recipients: [Recipient; 20], // Fixed array, use recipient_count
+ pub unclaimed_amounts: [UnclaimedAmount; 20], // Fixed array
+ pub protocol_unclaimed: u64, // Protocol fees awaiting claim
+ pub last_activity: i64, // Timestamp of last execution
+ pub rent_payer: Pubkey, // Who paid rent (for refund on close)
+}
+
+#[repr(C)]
+pub struct Recipient {
+ pub address: Pubkey,
+ pub percentage_bps: u16, // 1-9900 (0.01%-99%)
+}
+
+#[repr(C)]
+pub struct UnclaimedAmount {
+ pub recipient: Pubkey,
+ pub amount: u64,
+ pub timestamp: i64,
+}
+```
+
+**Seeds:** `[b"split_config", authority, mint, unique_id]`
+
+**Space Allocation:** Fixed size for all configs (1,832 bytes). Zero-copy provides ~50% serialization CU savings, critical for high-throughput micropayments. The fixed rent (~0.015 SOL) is negligible compared to cumulative compute savings.
+
+**Payer Separation:** The `rent_payer` field tracks who paid rent for the account, enabling:
+- **Sponsored rent:** Protocol or third party pays rent on behalf of user
+- **Proper refunds:** On close, rent returns to original payer, not authority
+
+The `authority` controls the config (update, close), while `rent_payer` receives the rent refund. These can be the same address (user pays own rent) or different (sponsored).
+
+**Activity Tracking:** The `last_activity` timestamp is updated on every `execute_split`. This enables future capability for:
+- Stale account cleanup (recover rent from abandoned accounts after inactivity period)
+
+Currently, only the authority can close accounts. The activity tracking reserves the option to add permissionless cleanup of inactive accounts in a future version without breaking changes.
+
+---
+
+## Instructions
+
+### initialize_protocol
+
+One-time protocol initialization.
+
+**Authorization:** Deployer (first call only)
+
+**Parameters:**
+- `fee_wallet`: Address to receive protocol fees
+
+### update_protocol_config
+
+Updates protocol fee wallet.
+
+**Authorization:** Protocol authority
+
+**Parameters:**
+- `new_fee_wallet`: New address for protocol fees
+
+### transfer_protocol_authority
+
+Proposes transfer of protocol authority to a new address.
+
+**Authorization:** Protocol authority
+
+**Parameters:**
+- `new_authority`: Address to receive authority (or `Pubkey::default()` to cancel)
+
+**Note:** This only sets `pending_authority`. The new authority must call `accept_protocol_authority` to complete the transfer.
+
+### accept_protocol_authority
+
+Accepts a pending protocol authority transfer.
+
+**Authorization:** Pending authority (must match `pending_authority` in config)
+
+**Parameters:** None
+
+**Note:** Completes the two-step transfer and clears `pending_authority`.
+
+### create_split_config
+
+Creates a new payment split configuration.
+
+**Authorization:** Anyone (becomes authority)
+
+**Accounts:**
+- `payer` - Pays rent for account creation (recorded as `rent_payer`)
+- `authority` - Controls the config (update, close)
+
+The payer and authority can be the same address (user pays own rent) or different (sponsored rent).
+
+**Validation:**
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses
+- No zero percentages
+- All recipient ATAs must exist
+
+*Note: Requiring pre-existing ATAs protects payment facilitators from ATA creation costs (0.002 SOL × recipients). Config creators ensure their recipients are ready before setup.*
+
+*Note: Recipients can be PDAs (multisig vaults, DAO treasuries, other protocols). The controlling program must have logic to withdraw from the ATA - the protocol only transfers to the ATA, not beyond.*
+
+**Example:**
+```typescript
+import { createSplitConfig } from "@cascade-fyi/splits-sdk/solana";
+
+const { instruction, vault } = await createSplitConfig({
+ authority: wallet,
+ recipients: [
+ { address: "Agent111111111111111111111111111111111111111", share: 90 },
+ { address: "Marketplace1111111111111111111111111111111", share: 10 },
+ ],
+});
+```
+
+### execute_split
+
+Distributes vault balance to recipients. Self-healing: also clears any pending unclaimed amounts.
+
+**Authorization:** Permissionless (anyone can trigger)
+
+**Required Accounts:**
+```typescript
+remaining_accounts: [
+ recipient_1_ata, // Canonical ATA for first recipient
+ recipient_2_ata, // Canonical ATA for second recipient
+ // ... one per recipient in config order
+ protocol_ata // Protocol fee wallet canonical ATA (last)
+]
+```
+
+**Important**: All ATAs must be canonical Associated Token Accounts derived via `get_associated_token_address_with_program_id()`. Non-canonical token accounts are rejected to prevent UX issues where recipients don't monitor non-standard accounts.
+
+The instruction validates that `remaining_accounts.len() >= recipient_count + 1`.
+
+**Logic:**
+1. Calculate available funds: `vault_balance - total_unclaimed - protocol_unclaimed`
+2. If available > 0:
+ - Calculate each recipient's share (floor division)
+ - Attempt transfer to each recipient
+ - If transfer fails → record as unclaimed (protected)
+ - Calculate protocol fee (1% + rounding dust)
+ - Attempt transfer to protocol
+ - If protocol transfer fails → add to `protocol_unclaimed`
+3. Attempt to clear all unclaimed amounts:
+ - For each recipient entry, check if ATA now exists
+ - If valid → transfer exact recorded amount, remove entry
+ - If still invalid → keep in unclaimed
+4. Attempt to clear protocol unclaimed:
+ - If protocol ATA exists → transfer `protocol_unclaimed`, reset to 0
+ - No additional fee charged on clearing (fee was calculated on original split)
+
+**Idempotency:** Safe to call multiple times. Only new funds are split. Unclaimed funds cannot be redistributed to other recipients.
+
+### update_split_config
+
+Authority updates recipient list while preserving the vault address.
+
+**Authorization:** Config authority
+
+**Requirements:**
+- Vault must be empty (execute pending splits first)
+- All `unclaimed_amounts` must be zero
+- `protocol_unclaimed` must be zero
+- 1-20 recipients
+- Total exactly 9900 bps (99%)
+- No duplicate recipients
+- No zero addresses
+- No zero percentages
+- All recipient ATAs must exist
+
+**Use Case:** The splitConfig address (PDA) is the stable public interface for x402 payments—facilitators derive the vault ATA automatically. When business arrangements change (new partners, revised percentages), the authority can update the split without requiring payers to change their payment destination.
+
+**Design Decision:** Vault must be empty to ensure funds are always split according to the rules active when they were received.
+
+### close_split_config
+
+Closes config and vault, reclaiming all rent.
+
+**Authorization:** Config authority
+
+**Accounts:**
+- `authority` - Must match config authority (authorizes close)
+- `rent_destination` - Must match config `rent_payer` (receives rent refund)
+- `vault` - Vault ATA (closed via CPI to token program)
+- `token_program` - Token program owning the vault
+
+**Requirements:**
+- Vault must be empty (balance = 0)
+- All unclaimed amounts must be zero
+- Protocol unclaimed must be zero
+
+**Rent Recovery:**
+- Config account rent: ~0.015 SOL (1,832 bytes)
+- Vault ATA rent: ~0.002 SOL (165 bytes)
+- **Total recovered**: ~0.017 SOL
+
+The rent is refunded to the original `rent_payer`, not necessarily the authority. This enables sponsored rent where a third party pays rent but the user controls the config.
+
+---
+
+## x402 Integration
+
+### Merchant Configuration
+
+Set `payTo` to the **splitConfig address** (PDA), not the vault. Per [x402 SVM spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_svm.md), facilitators derive the destination: `ATA(owner=payTo, mint=asset)`.
+
+This makes `payTo` token-agnostic—same address works for USDC, USDT, or any supported token.
+
+### Automatic Detection
+
+After payment, facilitators can detect split vaults by checking if the derived destination is a token account owned by a SplitConfig PDA:
+
+```typescript
+async function detectSplitVault(destination: PublicKey): Promise {
+ const accountInfo = await connection.getAccountInfo(destination);
+ if (!accountInfo) return null;
+
+ const tokenAccount = decodeTokenAccount(accountInfo.data);
+
+ try {
+ const splitConfig = await program.account.splitConfig.fetch(
+ tokenAccount.owner // PDA that owns the vault
+ );
+
+ if (splitConfig.vault.equals(destination)) {
+ return splitConfig;
+ }
+ } catch {
+ // Not a split vault
+ }
+
+ return null;
+}
+```
+
+### Facilitator Benefits
+
+- **Single interface:** Only `execute_split` needed (self-healing handles unclaimed)
+- **Idempotent:** Safe to retry on network failures
+- **No ATA creation costs:** Protocol holds funds for missing ATAs, doesn't require facilitator to create them
+- **Multiple merchants:** Use `unique_id` to manage many configs with same token
+
+---
+
+## Token Support
+
+| Token Type | Support | Notes |
+|------------|---------|-------|
+| SPL Token | ✅ Full | Standard tokens |
+| Token-2022 | ✅ Full | See extensions below |
+| Native SOL | ❌ No | Use wrapped SOL |
+
+**Token-2022 Extensions:**
+- ✅ **Transfer Fees**: Recipients receive net amounts after token's fees. Transfer fee is separate from 1% protocol fee.
+- ✅ **sRFC-37 (Frozen Accounts)**: Frozen accounts automatically trigger unclaimed flow. Funds held until account is thawed. See [sRFC-37](https://forum.solana.com/t/srfc-37-efficient-block-allow-list-token-standard/4036).
+- ✅ **Transfer Hooks**: Program invokes transfer hooks per Token-2022 spec. Hook failures revert the transaction.
+- ✅ **Interest-Bearing**: Supported. Interest accrues to vault before distribution.
+- ⚠️ **Confidential Transfer**: Supported but requires proper account setup by recipients.
+
+**Note on Frozen Accounts**: Tokens using sRFC-37 DefaultAccountState::Frozen (e.g., tokens with allowlists/blocklists) are supported. If a recipient's account is frozen during execution, their share is held as unclaimed until the account is thawed by the Gate Program.
+
+**⚠️ Vault Freeze Warning**: Token issuers with freeze authority can freeze the vault account itself, not just recipient accounts. If the vault is frozen, all funds are locked and no distributions can occur. There is no protocol-level recovery mechanism. When using tokens with freeze authority (e.g., regulated stablecoins), users accept that the token issuer has ultimate control over fund movement.
+
+---
+
+## Events
+
+All operations emit events for indexing:
+
+| Event | Description |
+|-------|-------------|
+| `ProtocolConfigCreated` | Protocol initialized |
+| `ProtocolConfigUpdated` | Fee wallet changed |
+| `ProtocolAuthorityTransferProposed` | Authority transfer proposed |
+| `ProtocolAuthorityTransferAccepted` | Authority transfer completed |
+| `SplitConfigCreated` | New split config created |
+| `SplitExecuted` | Payment distributed (includes `held_as_unclaimed` field) |
+| `SplitConfigUpdated` | Config recipients modified |
+| `SplitConfigClosed` | Config deleted, rent reclaimed |
+
+**SplitExecuted Event Details:**
+```rust
+pub struct SplitExecuted {
+ pub config: Pubkey,
+ pub vault: Pubkey,
+ pub total_amount: u64, // Total vault balance processed
+ pub recipients_distributed: u64, // Amount sent to recipients
+ pub protocol_fee: u64, // Amount sent to protocol
+ pub held_as_unclaimed: u64, // Amount added to unclaimed
+ pub unclaimed_cleared: u64, // Amount cleared from previous unclaimed
+ pub protocol_unclaimed_cleared: u64, // Protocol fees cleared
+ pub executor: Pubkey,
+ pub timestamp: i64,
+}
+```
+
+**Use Case:** Build indexer to track all configs, executions, and analytics.
+
+---
+
+## Error Codes
+
+| Code | Description |
+|------|-------------|
+| `InvalidRecipientCount` | Recipients count not in 1-20 range |
+| `InvalidSplitTotal` | Percentages don't sum to 9900 bps |
+| `DuplicateRecipient` | Same address appears twice |
+| `ZeroAddress` | Recipient address is zero |
+| `ZeroPercentage` | Recipient percentage is zero |
+| `RecipientATADoesNotExist` | Required ATA not found |
+| `RecipientATAInvalid` | ATA is not the canonical derived address |
+| `RecipientATAWrongOwner` | ATA owner doesn't match recipient |
+| `RecipientATAWrongMint` | ATA mint doesn't match config |
+| `VaultNotEmpty` | Vault must be empty for this operation |
+| `InvalidVault` | Vault doesn't match config |
+| `InsufficientRemainingAccounts` | Not enough accounts provided |
+| `MathOverflow` | Arithmetic overflow |
+| `MathUnderflow` | Arithmetic underflow |
+| `InvalidProtocolFeeRecipient` | Protocol ATA validation failed |
+| `Unauthorized` | Signer not authorized |
+| `AlreadyInitialized` | Protocol already initialized |
+| `UnclaimedNotEmpty` | Unclaimed amounts must be cleared first |
+| `InvalidTokenProgram` | Token account owned by wrong program |
+| `NoPendingTransfer` | No pending authority transfer to accept |
+| `InvalidRentDestination` | Rent destination doesn't match original payer |
+
+---
+
+## Security
+
+### Implemented Protections
+
+- ✅ Non-custodial (PDA-owned vaults)
+- ✅ Idempotent execution (unclaimed funds protected from re-splitting)
+- ✅ Overflow/underflow checks (all math uses `checked_*`)
+- ✅ Duplicate recipient validation
+- ✅ Bounded account size (max 20 recipients)
+- ✅ Protocol fee enforcement (cannot be bypassed)
+- ✅ Configurable protocol wallet
+- ✅ Fixed space allocation (zero-copy)
+
+### Known Limitations
+
+- No pause mechanism (redeploy if critical issue found)
+- Single authority per config (use Squads multisig as authority for multi-sig control)
+- Unclaimed funds never expire
+- Vault freeze risk: Token-2022 issuers with freeze authority can freeze the vault directly, locking all funds with no protocol-level recovery (see Token Support section)
+
+---
+
+## Design Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| **Hardcoded 1% fee** | Transparency for integrators. Anyone can verify on-chain. Avoids calculation complexity and potential bugs. Protocol redeploys if fee change needed. |
+| **Empty vault for updates** | Ensures funds are split according to rules active when received. Prevents race conditions. |
+| **Update preserves vault address** | Vault address is the stable public interface. Payers shouldn't need to update their systems when business arrangements change. |
+| **unique_id over counter** | Client generates (no on-chain state management). Enables parallel creation without contention. Simple implementation. |
+| **Self-healing over separate claim** | Single idempotent interface for facilitators. Simplifies integration. Recipients auto-receive on next execution. No additional flow to maintain. |
+| **Protocol unclaimed tracking** | Enables permissionless support for any token. Protocol doesn't need to pre-create ATAs for every possible token. Fees are preserved until protocol ATA exists. |
+| **Zero-copy with fixed arrays** | ~50% serialization CU savings. Fixed rent (~0.015 SOL) is negligible vs cumulative compute savings across thousands of transactions. Critical for high-throughput micropayments. |
+| **Stored bumps** | All PDAs store their bump. Constraints use stored bump instead of deriving, saving ~1,300 CU per account validation. |
+| **remaining_accounts pattern** | Recipient count is variable (1-20). Anchor requires dynamic account lists via remaining_accounts. Accounts in config order with protocol ATA last. |
+| **Minimal logging** | Production builds avoid `msg!` statements. Each costs ~100-200 CU. Debug logging via feature flag. |
+| **No streaming/partial splits** | Different product category (see Streamflow, Zebec). Cascade Splits is for instant one-time splits. |
+| **No native SOL** | Adds complexity. Use wrapped SOL instead. |
+| **No built-in multi-sig** | Use Squads/Realms as authority. Works with current design without added complexity. |
+| **Pre-existing ATAs required** | Protects facilitators from being drained by forced ATA creation (0.002 SOL each). Config creators responsible for recipient readiness. |
+| **Two-step authority transfer** | Prevents accidental irreversible authority transfers. Current authority proposes, new authority accepts. Can be cancelled before acceptance. |
+| **Payer separation** | Separates rent payer from authority. Enables sponsored rent (protocol/third party pays) while user retains control. Rent refunds go to original payer, not authority. |
+| **Activity timestamp tracking** | Enables future stale account cleanup without breaking changes. Updated on every execution. |
+| **Frozen account detection** | sRFC-37 tokens with DefaultAccountState::Frozen are detected before transfer attempts (~300 CU per recipient). Frozen accounts trigger unclaimed flow rather than transaction failure. Minimal overhead for compatibility with allowlist/blocklist tokens. |
+| **Vault rent recovery on close** | Close instruction closes both config and vault via CPI, recovering all rent (~0.017 SOL total). Adds ~5,000 CU to close operation but ensures no rent is left behind. |
+| **Canonical ATA enforcement** | All recipient and protocol ATAs must be canonical derived addresses. Prevents funds from being sent to non-standard accounts that recipients may not monitor. Consistent with security best practices. |
+
+---
+
+## Technical Details
+
+**Dependencies:**
+```toml
+anchor-lang = "0.32.1"
+anchor-spl = "0.32.1"
+```
+
+**Constants:**
+```rust
+PROTOCOL_FEE_BPS: u16 = 100; // 1%
+REQUIRED_SPLIT_TOTAL: u16 = 9900; // Recipients must total 99%
+MIN_RECIPIENTS: usize = 1;
+MAX_RECIPIENTS: usize = 20;
+```
+
+**Fixed Space (Zero-Copy):**
+```rust
+// SplitConfig size (fixed for all configs)
+pub const SPLIT_CONFIG_SIZE: usize =
+ 8 + // discriminator
+ 1 + // version
+ 32 + // authority
+ 32 + // mint
+ 32 + // vault
+ 32 + // unique_id
+ 1 + // bump
+ 1 + // recipient_count
+ (34 * 20) + // recipients [Recipient; 20]
+ (48 * 20) + // unclaimed_amounts [UnclaimedAmount; 20]
+ 8 + // protocol_unclaimed
+ 8 + // last_activity
+ 32; // rent_payer
+ // Total: 1,832 bytes
+
+// ProtocolConfig size
+pub const PROTOCOL_CONFIG_SIZE: usize =
+ 8 + // discriminator
+ 32 + // authority
+ 32 + // pending_authority
+ 32 + // fee_wallet
+ 1; // bump
+ // Total: 105 bytes
+```
+
+**Compute Budget:**
+Current compute unit consumption (as of 2025-11-26):
+
+| Instruction | 1 recipient | 5 recipients | 20 recipients |
+|-------------|-------------|--------------|---------------|
+| execute_split | 28,505 CU | 68,573 CU | 211,703 CU |
+| create_split_config | 36,590 CU | 40,024 CU | N/A |
+| close_split_config | 10,168 CU | N/A | N/A |
+| update_split_config | N/A | 7,424 CU (to 2) | 14,032 CU (to 10) |
+
+For high-throughput micropayments, set explicit CU limits based on recipient count:
+```typescript
+// Conservative estimate: 30,000 base + (3,500 * recipient_count)
+const computeUnits = 30_000 + (recipientCount * 3_500);
+
+transaction.add(
+ ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits })
+);
+```
+
+Latest benchmarks: [docs/benchmarks/compute_units.md](../benchmarks/compute_units.md)
+
+**Logging:**
+Production builds use minimal logging to save compute. Debug logging available via feature flag:
+```rust
+#[cfg(feature = "verbose")]
+msg!("Debug: {}", value);
+```
+
+**Program ID:** `SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB`
+
+---
+
+## Resources
+
+- **GitHub:** https://github.com/cascade-protocol/splits
+- **SDK:** `@cascade-fyi/splits-sdk`
+- **Usage Guide:** [docs/usage.md](./usage.md)
+- **Contact:** hello@cascade.fyi
+
+---
+
+**Last Updated:** 2025-11-29
diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json
index 0a4a5b2..bcbf8b5 100644
--- a/apps/docs/tsconfig.json
+++ b/apps/docs/tsconfig.json
@@ -1,28 +1,3 @@
{
- "include": [
- "**/*",
- "**/.server/**/*",
- "**/.client/**/*",
- ".react-router/types/**/*"
- ],
- "compilerOptions": {
- "lib": ["DOM", "DOM.Iterable", "ES2022"],
- "types": ["node", "vite/client"],
- "target": "esnext",
- "module": "esnext",
- "moduleResolution": "bundler",
- "jsx": "react-jsx",
- "rootDirs": [".", "./.react-router/types"],
- "baseUrl": ".",
- "paths": {
- "@/*": ["./app/*"],
- "fumadocs-mdx:collections/*": [".source/*"]
- },
- "esModuleInterop": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "strict": true
- }
+ "extends": "astro/tsconfigs/strict"
}
diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts
deleted file mode 100644
index a07e04f..0000000
--- a/apps/docs/vite.config.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { reactRouter } from "@react-router/dev/vite";
-import tailwindcss from "@tailwindcss/vite";
-import { defineConfig } from "vite";
-import tsconfigPaths from "vite-tsconfig-paths";
-import mdx from "fumadocs-mdx/vite";
-import * as MdxConfig from "./source.config";
-
-export default defineConfig({
- plugins: [
- mdx(MdxConfig),
- tailwindcss(),
- reactRouter(),
- tsconfigPaths({
- root: __dirname,
- }),
- ],
-});
diff --git a/apps/facilitator/package.json b/apps/facilitator/package.json
new file mode 100644
index 0000000..0a0aaed
--- /dev/null
+++ b/apps/facilitator/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "facilitator",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "wrangler dev",
+ "build": "wrangler deploy --dry-run --outdir=dist",
+ "deploy": "wrangler deploy",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "type-check": "tsc -b",
+ "lint": "biome check",
+ "check": "pnpm type-check && biome check --write"
+ },
+ "dependencies": {
+ "@cascade-fyi/splits-sdk": "workspace:*",
+ "@coinbase/x402": "^2.0.0",
+ "@solana-program/compute-budget": "^0.7.0",
+ "@solana-program/system": "^0.10.0",
+ "@solana-program/token": "^0.8.0",
+ "@solana-program/token-2022": "^0.4.0",
+ "@solana/kit": "^5.0.0",
+ "@x402/core": "^2.0.0",
+ "hono": "^4.10.8"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20250610.0",
+ "typescript": "^5.7.2",
+ "vitest": "^4.0.12",
+ "wrangler": "^4.54.0"
+ }
+}
diff --git a/apps/facilitator/src/index.ts b/apps/facilitator/src/index.ts
new file mode 100644
index 0000000..946f7dd
--- /dev/null
+++ b/apps/facilitator/src/index.ts
@@ -0,0 +1,32 @@
+/**
+ * Cascade Facilitator
+ *
+ * x402 facilitator implementing RFC #646 enhancements:
+ * - CPI verification via simulation (smart wallet support)
+ * - Deadline validator support (maxTimeoutSeconds enforcement)
+ * - Durable nonce support (extended timeouts)
+ *
+ * @see https://github.com/coinbase/x402/issues/646
+ */
+
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import type { Env } from "./types.js";
+import { supportedHandler } from "./routes/supported.js";
+import { verifyHandler } from "./routes/verify.js";
+import { settleHandler } from "./routes/settle.js";
+
+const app = new Hono<{ Bindings: Env }>();
+
+// CORS for all routes
+app.use("/*", cors());
+
+// Health check
+app.get("/health", (c) => c.json({ ok: true, timestamp: Date.now() }));
+
+// x402 Facilitator endpoints
+app.get("/supported", supportedHandler);
+app.post("/verify", verifyHandler);
+app.post("/settle", settleHandler);
+
+export default app;
diff --git a/apps/facilitator/src/lib/signer.ts b/apps/facilitator/src/lib/signer.ts
new file mode 100644
index 0000000..e9ac7b2
--- /dev/null
+++ b/apps/facilitator/src/lib/signer.ts
@@ -0,0 +1,242 @@
+/**
+ * Facilitator SVM Signer
+ *
+ * Handles signing, simulation, and broadcasting of Solana transactions.
+ *
+ * NOTE: This is a custom implementation, NOT using x402's `toFacilitatorSvmSigner()`.
+ * Reason: We need `innerInstructions` from simulation for RFC #646 CPI verification.
+ *
+ * When smart wallets (e.g., Squads multisig) pay, the actual transfer happens via
+ * Cross-Program Invocation (CPI) inside the multisig instruction. x402's signer
+ * returns `void` from simulateTransaction() and doesn't request innerInstructions,
+ * so it cannot verify CPI transfers. Our signer returns SimulationResult with
+ * innerInstructions which validateCpiTransfer() uses to find and verify the
+ * actual TransferChecked instruction inside the CPI.
+ *
+ * @see https://github.com/coinbase/x402/issues/646
+ * @see validation.ts - validateCpiTransfer()
+ */
+
+import {
+ type Address,
+ type Signature,
+ type Transaction,
+ type Base64EncodedWireTransaction,
+ type KeyPairSigner,
+ createSolanaRpc,
+ createKeyPairSignerFromBytes,
+ getBase58Encoder,
+ getBase64Encoder,
+ getBase64EncodedWireTransaction,
+ getTransactionDecoder,
+} from "@solana/kit";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface FacilitatorSigner {
+ /** The underlying KeyPairSigner for SDK usage */
+ keyPairSigner: KeyPairSigner;
+
+ /** Get all fee payer addresses */
+ getAddresses(): readonly Address[];
+
+ /** Sign a partial transaction with the fee payer */
+ signTransaction(
+ transaction: string,
+ feePayer: Address,
+ network: string,
+ ): Promise;
+
+ /** Simulate a transaction to verify it would succeed */
+ simulateTransaction(
+ transaction: string,
+ network: string,
+ ): Promise;
+
+ /** Send a transaction to the network */
+ sendTransaction(transaction: string, network: string): Promise;
+
+ /** Wait for transaction confirmation */
+ confirmTransaction(signature: string, network: string): Promise;
+}
+
+export interface SimulationResult {
+ success: boolean;
+ error?: string;
+ logs?: string[];
+ unitsConsumed?: bigint;
+ innerInstructions?: InnerInstruction[];
+}
+
+export interface InnerInstruction {
+ index: number;
+ instructions: {
+ programIdIndex: number;
+ accounts: number[];
+ data: string;
+ }[];
+}
+
+// =============================================================================
+// Transaction Utilities
+// =============================================================================
+
+export function decodeTransaction(base64Tx: string): Transaction {
+ const base64Encoder = getBase64Encoder();
+ const transactionBytes = base64Encoder.encode(base64Tx);
+ const transactionDecoder = getTransactionDecoder();
+ return transactionDecoder.decode(transactionBytes);
+}
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
+
+// =============================================================================
+// RPC Client Creation
+// =============================================================================
+
+function createRpc(network: string, rpcUrl: string) {
+ // Validate network - currently only mainnet is supported
+ if (network !== SOLANA_MAINNET_CAIP2) {
+ throw new Error(
+ `Unsupported network: ${network}. Only ${SOLANA_MAINNET_CAIP2} is supported.`,
+ );
+ }
+ return createSolanaRpc(rpcUrl as `https://${string}`);
+}
+
+// =============================================================================
+// Signer Factory
+// =============================================================================
+
+export async function createFacilitatorSigner(
+ feePayerKeyBase58: string,
+ rpcUrl: string,
+): Promise {
+ // Decode the fee payer key using kit's base58 encoder (encode string → bytes)
+ const base58Encoder = getBase58Encoder();
+ const keyBytes = base58Encoder.encode(feePayerKeyBase58);
+ const signer = await createKeyPairSignerFromBytes(keyBytes);
+
+ return {
+ keyPairSigner: signer,
+ getAddresses: () => [signer.address],
+
+ signTransaction: async (
+ transaction: string,
+ feePayer: Address,
+ _network: string,
+ ) => {
+ if (feePayer !== signer.address) {
+ throw new Error(
+ `No signer for feePayer ${feePayer}. Available: ${signer.address}`,
+ );
+ }
+
+ // Decode transaction
+ const tx = decodeTransaction(transaction);
+
+ // Sign the message
+ const signableMessage = {
+ content: tx.messageBytes,
+ signatures: tx.signatures,
+ };
+
+ const [facilitatorSignature] = await signer.signMessages([
+ signableMessage as never,
+ ]);
+
+ // Merge signatures
+ const fullySignedTx = {
+ ...tx,
+ signatures: {
+ ...tx.signatures,
+ ...facilitatorSignature,
+ },
+ };
+
+ return getBase64EncodedWireTransaction(fullySignedTx);
+ },
+
+ simulateTransaction: async (transaction: string, network: string) => {
+ const rpc = createRpc(network, rpcUrl);
+
+ const result = await rpc
+ .simulateTransaction(transaction as Base64EncodedWireTransaction, {
+ sigVerify: true,
+ replaceRecentBlockhash: false,
+ commitment: "confirmed",
+ encoding: "base64",
+ innerInstructions: true, // Request inner instructions for CPI verification
+ })
+ .send();
+
+ if (result.value.err) {
+ return {
+ success: false,
+ error: JSON.stringify(result.value.err),
+ logs: result.value.logs ?? undefined,
+ };
+ }
+
+ return {
+ success: true,
+ logs: result.value.logs ?? undefined,
+ unitsConsumed: result.value.unitsConsumed ?? undefined,
+ innerInstructions: result.value.innerInstructions as unknown as
+ | InnerInstruction[]
+ | undefined,
+ };
+ },
+
+ sendTransaction: async (transaction: string, network: string) => {
+ const rpc = createRpc(network, rpcUrl);
+
+ return await rpc
+ .sendTransaction(transaction as Base64EncodedWireTransaction, {
+ encoding: "base64",
+ skipPreflight: false,
+ preflightCommitment: "confirmed",
+ })
+ .send();
+ },
+
+ confirmTransaction: async (signature: string, network: string) => {
+ const rpc = createRpc(network, rpcUrl);
+
+ let confirmed = false;
+ let attempts = 0;
+ const maxAttempts = 30;
+
+ while (!confirmed && attempts < maxAttempts) {
+ const status = await rpc
+ .getSignatureStatuses([signature as Signature])
+ .send();
+
+ const txStatus = status.value[0];
+ if (
+ txStatus?.confirmationStatus === "confirmed" ||
+ txStatus?.confirmationStatus === "finalized"
+ ) {
+ if (txStatus.err) {
+ throw new Error(
+ `Transaction failed: ${JSON.stringify(txStatus.err)}`,
+ );
+ }
+ confirmed = true;
+ return;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ attempts++;
+ }
+
+ throw new Error("Transaction confirmation timeout");
+ },
+ };
+}
diff --git a/apps/facilitator/src/lib/validation.test.ts b/apps/facilitator/src/lib/validation.test.ts
new file mode 100644
index 0000000..a10d4e3
--- /dev/null
+++ b/apps/facilitator/src/lib/validation.test.ts
@@ -0,0 +1,707 @@
+/**
+ * Unit tests for transaction validation helpers
+ */
+
+import { describe, it, expect } from "vitest";
+import type { Address } from "@solana/kit";
+import type { PaymentRequirements } from "@x402/core/types";
+import {
+ detectInstructionLayout,
+ verifyComputeLimit,
+ verifyComputePrice,
+ verifyDeadlineValidator,
+ verifyNonceAuthority,
+ verifyFeePayerSafety,
+ verifyCpiTransfer,
+} from "./validation.js";
+import type { SimulationResult } from "./signer.js";
+
+// =============================================================================
+// Test Constants
+// =============================================================================
+
+const COMPUTE_BUDGET_PROGRAM =
+ "ComputeBudget111111111111111111111111111111" as Address;
+const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" as Address;
+const TOKEN_2022_PROGRAM =
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" as Address;
+const SYSTEM_PROGRAM = "11111111111111111111111111111111" as Address;
+const DEADLINE_VALIDATOR =
+ "DEADaT1auZ8JjUMWUhhPWjQqFk9HSgHBkt5KaGMVnp1H" as Address;
+const ASSOCIATED_TOKEN_PROGRAM =
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" as Address;
+const SQUADS_PROGRAM = "SMPLecH534NA9acpos4G6x7uf3LWbCAwZQE9e8ZekMu" as Address;
+
+const FEE_PAYER = "F2vVvFwrbGHtsBEqFkSkLvsM6SJmDMm7KqhiW2P64WxY" as Address;
+const USER_WALLET = "8ACGYVcVNHToCa6anLweeFnBTV1Q2QQsvh21zWkW6N8i" as Address;
+
+// =============================================================================
+// Instruction Layout Detection Tests
+// =============================================================================
+
+describe("detectInstructionLayout", () => {
+ it("detects minimal 3-instruction direct transfer", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout).not.toBeNull();
+ expect(layout?.hasNonceAdvance).toBe(false);
+ expect(layout?.computeLimitIndex).toBe(0);
+ expect(layout?.computePriceIndex).toBe(1);
+ expect(layout?.hasDeadlineValidator).toBe(false);
+ expect(layout?.hasAtaCreate).toBe(false);
+ expect(layout?.transferIndex).toBe(2);
+ expect(layout?.isDirectTransfer).toBe(true);
+ });
+
+ it("detects Token-2022 direct transfer", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: TOKEN_2022_PROGRAM },
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout?.isDirectTransfer).toBe(true);
+ });
+
+ it("detects 4-instruction with nonce advance", () => {
+ const instructions = [
+ { programAddress: SYSTEM_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout).not.toBeNull();
+ expect(layout?.hasNonceAdvance).toBe(true);
+ expect(layout?.computeLimitIndex).toBe(1);
+ expect(layout?.computePriceIndex).toBe(2);
+ expect(layout?.transferIndex).toBe(3);
+ });
+
+ it("detects 4-instruction with deadline validator", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: DEADLINE_VALIDATOR },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout).not.toBeNull();
+ expect(layout?.hasDeadlineValidator).toBe(true);
+ expect(layout?.deadlineValidatorIndex).toBe(2);
+ expect(layout?.transferIndex).toBe(3);
+ });
+
+ it("detects 4-instruction with ATA create", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: ASSOCIATED_TOKEN_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout).not.toBeNull();
+ expect(layout?.hasAtaCreate).toBe(true);
+ expect(layout?.ataCreateIndex).toBe(2);
+ expect(layout?.transferIndex).toBe(3);
+ });
+
+ it("detects 6-instruction full layout", () => {
+ const instructions = [
+ { programAddress: SYSTEM_PROGRAM }, // nonce
+ { programAddress: COMPUTE_BUDGET_PROGRAM }, // limit
+ { programAddress: COMPUTE_BUDGET_PROGRAM }, // price
+ { programAddress: DEADLINE_VALIDATOR }, // deadline
+ { programAddress: ASSOCIATED_TOKEN_PROGRAM }, // ata create
+ { programAddress: TOKEN_PROGRAM }, // transfer
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout).not.toBeNull();
+ expect(layout?.hasNonceAdvance).toBe(true);
+ expect(layout?.computeLimitIndex).toBe(1);
+ expect(layout?.computePriceIndex).toBe(2);
+ expect(layout?.hasDeadlineValidator).toBe(true);
+ expect(layout?.deadlineValidatorIndex).toBe(3);
+ expect(layout?.hasAtaCreate).toBe(true);
+ expect(layout?.ataCreateIndex).toBe(4);
+ expect(layout?.transferIndex).toBe(5);
+ expect(layout?.isDirectTransfer).toBe(true);
+ });
+
+ it("detects CPI transfer (non-token program)", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: SQUADS_PROGRAM }, // Squads useSpendingLimit
+ ];
+
+ const layout = detectInstructionLayout(instructions);
+
+ expect(layout?.isDirectTransfer).toBe(false);
+ });
+
+ it("rejects too few instructions", () => {
+ const instructions = [
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ expect(detectInstructionLayout(instructions)).toBeNull();
+ });
+
+ it("rejects too many instructions", () => {
+ const instructions = [
+ { programAddress: SYSTEM_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: COMPUTE_BUDGET_PROGRAM },
+ { programAddress: DEADLINE_VALIDATOR },
+ { programAddress: ASSOCIATED_TOKEN_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ { programAddress: TOKEN_PROGRAM }, // 7th instruction
+ ];
+
+ expect(detectInstructionLayout(instructions)).toBeNull();
+ });
+
+ it("rejects missing compute budget", () => {
+ const instructions = [
+ { programAddress: TOKEN_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ { programAddress: TOKEN_PROGRAM },
+ ];
+
+ expect(detectInstructionLayout(instructions)).toBeNull();
+ });
+});
+
+// =============================================================================
+// Compute Budget Tests
+// =============================================================================
+
+describe("verifyComputeLimit", () => {
+ it("accepts valid SetComputeUnitLimit instruction", () => {
+ // Discriminator 2 = SetComputeUnitLimit, followed by u32 limit
+ const data = new Uint8Array([2, 0x40, 0x42, 0x0f, 0x00]); // 1_000_000 units
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputeLimit(instruction);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects wrong discriminator", () => {
+ const data = new Uint8Array([3, 0x40, 0x42, 0x0f, 0x00]); // Wrong discriminator
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputeLimit(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("invalid_compute_limit_instruction");
+ });
+
+ it("rejects wrong program", () => {
+ const data = new Uint8Array([2, 0x40, 0x42, 0x0f, 0x00]);
+ const instruction = {
+ programAddress: TOKEN_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputeLimit(instruction);
+ expect(result.isValid).toBe(false);
+ });
+});
+
+describe("verifyComputePrice", () => {
+ it("accepts valid low price", () => {
+ // Discriminator 3 = SetComputeUnitPrice, followed by u64 microLamports
+ // 1_000_000 microLamports = 1 lamport/CU (well under 5 limit)
+ const data = new Uint8Array([
+ 3, 0x40, 0x42, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00,
+ ]);
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputePrice(instruction);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("accepts price at max limit (5 lamports/CU)", () => {
+ // 5_000_000 microLamports = 5 lamports/CU (max allowed)
+ const data = new Uint8Array([
+ 3, 0x40, 0x4b, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00,
+ ]);
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputePrice(instruction);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects price above max limit", () => {
+ // 10_000_000 microLamports = 10 lamports/CU (over limit)
+ // Use DataView to ensure correct little-endian encoding
+ const data = new Uint8Array(9);
+ data[0] = 3; // SetComputeUnitPrice discriminator
+ const view = new DataView(data.buffer);
+ view.setBigUint64(1, 10_000_000n, true); // 10M microLamports
+
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputePrice(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("compute_price_too_high");
+ });
+
+ it("rejects wrong discriminator", () => {
+ const data = new Uint8Array([
+ 2, 0x40, 0x42, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00,
+ ]);
+ const instruction = {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data,
+ };
+
+ const result = verifyComputePrice(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("invalid_compute_price_instruction");
+ });
+});
+
+// =============================================================================
+// Deadline Validator Tests
+// =============================================================================
+
+describe("verifyDeadlineValidator", () => {
+ it("accepts valid future deadline", () => {
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 60; // 60 seconds from now
+ const data = new Uint8Array(9);
+ data[0] = 0; // CheckClock discriminator
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(futureTimestamp), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("accepts deadline within tolerance (recent past)", () => {
+ const recentPast = Math.floor(Date.now() / 1000) - 10; // 10 seconds ago
+ const data = new Uint8Array(9);
+ data[0] = 0;
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(recentPast), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects deadline too far in past", () => {
+ const oldTimestamp = Math.floor(Date.now() / 1000) - 120; // 2 minutes ago
+ const data = new Uint8Array(9);
+ data[0] = 0;
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(oldTimestamp), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("deadline_already_passed");
+ });
+
+ it("rejects deadline exceeding maxTimeoutSeconds", () => {
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 300; // 5 minutes from now
+ const data = new Uint8Array(9);
+ data[0] = 0;
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(futureTimestamp), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction, 60); // Max 60 seconds
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("deadline_exceeds_max_timeout");
+ });
+
+ it("accepts deadline within maxTimeoutSeconds", () => {
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 30; // 30 seconds from now
+ const data = new Uint8Array(9);
+ data[0] = 0;
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(futureTimestamp), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction, 60); // Max 60 seconds
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects wrong program", () => {
+ const data = new Uint8Array(9);
+ data[0] = 0;
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(Math.floor(Date.now() / 1000) + 60), true);
+
+ const instruction = {
+ programAddress: TOKEN_PROGRAM,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("invalid_deadline_validator_program");
+ });
+
+ it("rejects invalid instruction discriminator", () => {
+ const data = new Uint8Array(9);
+ data[0] = 1; // Wrong discriminator (not CheckClock)
+ const view = new DataView(data.buffer);
+ view.setBigInt64(1, BigInt(Math.floor(Date.now() / 1000) + 60), true);
+
+ const instruction = {
+ programAddress: DEADLINE_VALIDATOR,
+ data,
+ };
+
+ const result = verifyDeadlineValidator(instruction);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("invalid_deadline_instruction_type");
+ });
+});
+
+// =============================================================================
+// Nonce Authority Tests
+// =============================================================================
+
+describe("verifyNonceAuthority", () => {
+ it("accepts valid nonce authority (not fee payer)", () => {
+ // AdvanceNonceAccount discriminator is 4 (u32 LE)
+ const data = new Uint8Array([4, 0, 0, 0]);
+ const instruction = {
+ programAddress: SYSTEM_PROGRAM,
+ accounts: [
+ { address: "NonceAccount11111111111111111111111111111111" as Address },
+ { address: "SysvarRecentB1ockHashes11111111111111111111" as Address },
+ { address: USER_WALLET }, // Authority is user, not fee payer
+ ],
+ data,
+ };
+
+ const result = verifyNonceAuthority(instruction, [FEE_PAYER.toString()]);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects fee payer as nonce authority", () => {
+ const data = new Uint8Array([4, 0, 0, 0]);
+ const instruction = {
+ programAddress: SYSTEM_PROGRAM,
+ accounts: [
+ { address: "NonceAccount11111111111111111111111111111111" as Address },
+ { address: "SysvarRecentB1ockHashes11111111111111111111" as Address },
+ { address: FEE_PAYER }, // Fee payer as authority - BAD
+ ],
+ data,
+ };
+
+ const result = verifyNonceAuthority(instruction, [FEE_PAYER.toString()]);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("fee_payer_is_nonce_authority");
+ });
+
+ it("rejects wrong instruction discriminator", () => {
+ const data = new Uint8Array([3, 0, 0, 0]); // Not AdvanceNonceAccount
+ const instruction = {
+ programAddress: SYSTEM_PROGRAM,
+ accounts: [
+ { address: "NonceAccount11111111111111111111111111111111" as Address },
+ { address: "SysvarRecentB1ockHashes11111111111111111111" as Address },
+ { address: USER_WALLET },
+ ],
+ data,
+ };
+
+ const result = verifyNonceAuthority(instruction, [FEE_PAYER.toString()]);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("invalid_nonce_instruction_type");
+ });
+});
+
+// =============================================================================
+// Fee Payer Safety Tests
+// =============================================================================
+
+describe("verifyFeePayerSafety", () => {
+ const layout = {
+ hasNonceAdvance: false,
+ computeLimitIndex: 0,
+ computePriceIndex: 1,
+ hasDeadlineValidator: false,
+ hasAtaCreate: false,
+ transferIndex: 2,
+ isDirectTransfer: true,
+ };
+
+ it("accepts when fee payer not in instruction accounts", () => {
+ const compiled = {
+ staticAccounts: [FEE_PAYER, USER_WALLET, TOKEN_PROGRAM],
+ instructions: [
+ { accountIndices: [] }, // compute limit
+ { accountIndices: [] }, // compute price
+ { accountIndices: [1] }, // transfer - only user wallet
+ ],
+ };
+
+ const result = verifyFeePayerSafety(
+ compiled as never,
+ [FEE_PAYER.toString()],
+ layout,
+ );
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects fee payer in transfer accounts", () => {
+ const compiled = {
+ staticAccounts: [FEE_PAYER, USER_WALLET, TOKEN_PROGRAM],
+ instructions: [
+ { accountIndices: [] }, // compute limit
+ { accountIndices: [] }, // compute price
+ { accountIndices: [0, 1] }, // transfer includes fee payer - BAD
+ ],
+ };
+
+ const result = verifyFeePayerSafety(
+ compiled as never,
+ [FEE_PAYER.toString()],
+ layout,
+ );
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("fee_payer_in_instruction_accounts");
+ });
+
+ it("allows fee payer in compute budget instruction accounts", () => {
+ const compiled = {
+ staticAccounts: [FEE_PAYER, USER_WALLET, TOKEN_PROGRAM],
+ instructions: [
+ { accountIndices: [0] }, // compute limit - fee payer OK here
+ { accountIndices: [0] }, // compute price - fee payer OK here
+ { accountIndices: [1] }, // transfer - no fee payer
+ ],
+ };
+
+ const result = verifyFeePayerSafety(
+ compiled as never,
+ [FEE_PAYER.toString()],
+ layout,
+ );
+ expect(result.isValid).toBe(true);
+ });
+});
+
+// =============================================================================
+// CPI Transfer Verification Tests
+// =============================================================================
+
+describe("verifyCpiTransfer", () => {
+ const requirements: PaymentRequirements = {
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ amount: "1000000", // 1 USDC
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: USER_WALLET.toString(),
+ maxTimeoutSeconds: 90,
+ extra: {},
+ };
+
+ // Helper to create TransferChecked instruction data
+ function createTransferCheckedData(amount: bigint): string {
+ const data = new Uint8Array(10);
+ data[0] = 12; // TransferChecked discriminator
+ const view = new DataView(data.buffer);
+ view.setBigUint64(1, amount, true);
+ data[9] = 6; // decimals
+ // Convert to base64
+ return btoa(String.fromCharCode(...data));
+ }
+
+ it("accepts valid CPI transfer with correct amount", async () => {
+ const simulationResult: SimulationResult = {
+ success: true,
+ logs: ["Program log: Transfer"],
+ innerInstructions: [
+ {
+ index: 2,
+ instructions: [
+ {
+ programIdIndex: 3,
+ accounts: [1, 2, 3, 4],
+ data: createTransferCheckedData(1000000n),
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("accepts CPI transfer with higher amount", async () => {
+ const simulationResult: SimulationResult = {
+ success: true,
+ innerInstructions: [
+ {
+ index: 2,
+ instructions: [
+ {
+ programIdIndex: 3,
+ accounts: [1, 2, 3, 4],
+ data: createTransferCheckedData(2000000n), // 2x required
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("rejects CPI transfer with insufficient amount", async () => {
+ const simulationResult: SimulationResult = {
+ success: true,
+ innerInstructions: [
+ {
+ index: 2,
+ instructions: [
+ {
+ programIdIndex: 3,
+ accounts: [1, 2, 3, 4],
+ data: createTransferCheckedData(500000n), // Only 0.5 USDC
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("insufficient_amount");
+ });
+
+ it("rejects failed simulation", async () => {
+ const simulationResult: SimulationResult = {
+ success: false,
+ error: "InstructionError: [2, InsufficientFunds]",
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toContain("simulation_failed");
+ });
+
+ it("rejects no inner instructions", async () => {
+ const simulationResult: SimulationResult = {
+ success: true,
+ innerInstructions: [],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("no_inner_instructions");
+ });
+
+ it("rejects no transfer in inner instructions", async () => {
+ const simulationResult: SimulationResult = {
+ success: true,
+ innerInstructions: [
+ {
+ index: 2,
+ instructions: [
+ {
+ programIdIndex: 3,
+ accounts: [1, 2],
+ data: btoa(String.fromCharCode(5, 0, 0, 0)), // Not TransferChecked
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("no_transfer_in_cpi");
+ });
+
+ it("rejects multiple transfers in CPI", async () => {
+ const transferData = createTransferCheckedData(1000000n);
+ const simulationResult: SimulationResult = {
+ success: true,
+ innerInstructions: [
+ {
+ index: 2,
+ instructions: [
+ {
+ programIdIndex: 3,
+ accounts: [1, 2, 3, 4],
+ data: transferData,
+ },
+ {
+ programIdIndex: 3,
+ accounts: [5, 6, 7, 8],
+ data: transferData, // Second transfer - BAD
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = await verifyCpiTransfer(simulationResult, requirements);
+ expect(result.isValid).toBe(false);
+ expect(result.invalidReason).toBe("multiple_transfers_in_cpi");
+ });
+});
diff --git a/apps/facilitator/src/lib/validation.ts b/apps/facilitator/src/lib/validation.ts
new file mode 100644
index 0000000..1af975d
--- /dev/null
+++ b/apps/facilitator/src/lib/validation.ts
@@ -0,0 +1,738 @@
+/**
+ * Transaction Validation Helpers
+ *
+ * Implements RFC #646 instruction validation for:
+ * - 3-6 instruction transactions
+ * - Compute budget verification
+ * - Deadline validator verification
+ * - Transfer verification (static and CPI)
+ */
+
+import {
+ type Address,
+ type CompiledTransactionMessage,
+ type ReadonlyUint8Array,
+ type Transaction,
+ decompileTransactionMessage,
+ getBase64Encoder,
+ getCompiledTransactionMessageDecoder,
+} from "@solana/kit";
+import {
+ COMPUTE_BUDGET_PROGRAM_ADDRESS,
+ parseSetComputeUnitLimitInstruction,
+ parseSetComputeUnitPriceInstruction,
+} from "@solana-program/compute-budget";
+import {
+ TOKEN_PROGRAM_ADDRESS,
+ parseTransferCheckedInstruction as parseTransferCheckedToken,
+ findAssociatedTokenPda,
+} from "@solana-program/token";
+import {
+ TOKEN_2022_PROGRAM_ADDRESS,
+ parseTransferCheckedInstruction as parseTransferChecked2022,
+} from "@solana-program/token-2022";
+import { SYSTEM_PROGRAM_ADDRESS } from "@solana-program/system";
+import type { PaymentRequirements } from "@x402/core/types";
+import { decodeTransaction, type SimulationResult } from "./signer.js";
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+/** Maximum compute unit price (5 lamports per CU) */
+const MAX_COMPUTE_UNIT_PRICE = 5_000_000n;
+
+/** Deadline validator program address */
+const DEADLINE_VALIDATOR_PROGRAM =
+ "DEADaT1auZ8JjUMWUhhPWjQqFk9HSgHBkt5KaGMVnp1H";
+
+/** Associated Token Program address */
+const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface ValidationResult {
+ isValid: boolean;
+ invalidReason?: string;
+ payer?: string;
+}
+
+export interface InstructionLayout {
+ hasNonceAdvance: boolean;
+ computeLimitIndex: number;
+ computePriceIndex: number;
+ hasDeadlineValidator: boolean;
+ deadlineValidatorIndex?: number;
+ hasAtaCreate: boolean;
+ ataCreateIndex?: number;
+ transferIndex: number;
+ isDirectTransfer: boolean;
+}
+
+/** Parsed TransferChecked instruction structure (compatible with Token and Token-2022) */
+interface ParsedTransferChecked {
+ accounts: {
+ authority: { address: Address };
+ mint: { address: Address };
+ destination: { address: Address };
+ };
+ data: {
+ amount: bigint;
+ };
+}
+
+// =============================================================================
+// Instruction Layout Detection
+// =============================================================================
+
+/**
+ * Detect the instruction layout of a transaction.
+ * Supports RFC #646 layouts with 3-6 instructions.
+ */
+export function detectInstructionLayout(
+ instructions: ReadonlyArray<{ programAddress: Address }>,
+): InstructionLayout | null {
+ const count = instructions.length;
+
+ // Must have 3-6 instructions
+ if (count < 3 || count > 6) {
+ return null;
+ }
+
+ let offset = 0;
+
+ // Check for nonce advance at position 0
+ const hasNonceAdvance =
+ instructions[0].programAddress.toString() ===
+ SYSTEM_PROGRAM_ADDRESS.toString();
+ if (hasNonceAdvance) {
+ offset = 1;
+ }
+
+ // Next two must be compute budget
+ const computeLimitIndex = offset;
+ const computePriceIndex = offset + 1;
+
+ if (
+ instructions[computeLimitIndex]?.programAddress.toString() !==
+ COMPUTE_BUDGET_PROGRAM_ADDRESS.toString() ||
+ instructions[computePriceIndex]?.programAddress.toString() !==
+ COMPUTE_BUDGET_PROGRAM_ADDRESS.toString()
+ ) {
+ return null;
+ }
+
+ offset += 2;
+
+ // Check for optional deadline validator
+ let hasDeadlineValidator = false;
+ let deadlineValidatorIndex: number | undefined;
+ if (
+ offset < count - 1 &&
+ instructions[offset].programAddress.toString() ===
+ DEADLINE_VALIDATOR_PROGRAM
+ ) {
+ hasDeadlineValidator = true;
+ deadlineValidatorIndex = offset;
+ offset++;
+ }
+
+ // Check for optional ATA create
+ let hasAtaCreate = false;
+ let ataCreateIndex: number | undefined;
+ if (
+ offset < count - 1 &&
+ instructions[offset].programAddress.toString() === ASSOCIATED_TOKEN_PROGRAM
+ ) {
+ hasAtaCreate = true;
+ ataCreateIndex = offset;
+ offset++;
+ }
+
+ // Last instruction must be the transfer
+ const transferIndex = count - 1;
+ if (offset !== transferIndex) {
+ return null;
+ }
+
+ // Check if it's a direct token transfer
+ const transferProgram = instructions[transferIndex].programAddress.toString();
+ const isDirectTransfer =
+ transferProgram === TOKEN_PROGRAM_ADDRESS.toString() ||
+ transferProgram === TOKEN_2022_PROGRAM_ADDRESS.toString();
+
+ return {
+ hasNonceAdvance,
+ computeLimitIndex,
+ computePriceIndex,
+ hasDeadlineValidator,
+ deadlineValidatorIndex,
+ hasAtaCreate,
+ ataCreateIndex,
+ transferIndex,
+ isDirectTransfer,
+ };
+}
+
+// =============================================================================
+// Compute Budget Verification
+// =============================================================================
+
+export function verifyComputeLimit(instruction: {
+ programAddress: Address;
+ data?: Readonly;
+}): ValidationResult {
+ if (
+ instruction.programAddress.toString() !==
+ COMPUTE_BUDGET_PROGRAM_ADDRESS.toString() ||
+ !instruction.data ||
+ instruction.data[0] !== 2 // SetComputeUnitLimit discriminator
+ ) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_compute_limit_instruction",
+ };
+ }
+
+ try {
+ parseSetComputeUnitLimitInstruction(instruction as never);
+ return { isValid: true };
+ } catch {
+ return {
+ isValid: false,
+ invalidReason: "invalid_compute_limit_instruction",
+ };
+ }
+}
+
+export function verifyComputePrice(instruction: {
+ programAddress: Address;
+ data?: Readonly;
+}): ValidationResult {
+ if (
+ instruction.programAddress.toString() !==
+ COMPUTE_BUDGET_PROGRAM_ADDRESS.toString() ||
+ !instruction.data ||
+ instruction.data[0] !== 3 // SetComputeUnitPrice discriminator
+ ) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_compute_price_instruction",
+ };
+ }
+
+ try {
+ const parsed = parseSetComputeUnitPriceInstruction(instruction as never);
+ const price = (parsed as unknown as { data: { microLamports: bigint } })
+ .data.microLamports;
+
+ if (price > MAX_COMPUTE_UNIT_PRICE) {
+ return {
+ isValid: false,
+ invalidReason: "compute_price_too_high",
+ };
+ }
+
+ return { isValid: true };
+ } catch {
+ return {
+ isValid: false,
+ invalidReason: "invalid_compute_price_instruction",
+ };
+ }
+}
+
+// =============================================================================
+// Deadline Validator Verification
+// =============================================================================
+
+/**
+ * Verify deadline validator instruction.
+ * Ensures the deadline is within maxTimeoutSeconds from now.
+ *
+ * Instruction data format:
+ * - byte 0: instruction discriminator (0 = CheckClock)
+ * - bytes 1-8: deadline timestamp (Unix timestamp, little-endian i64)
+ */
+export function verifyDeadlineValidator(
+ instruction: {
+ programAddress: Address;
+ data?: Readonly;
+ },
+ maxTimeoutSeconds?: number,
+): ValidationResult {
+ if (instruction.programAddress.toString() !== DEADLINE_VALIDATOR_PROGRAM) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_deadline_validator_program",
+ };
+ }
+
+ if (!instruction.data || instruction.data.length < 9) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_deadline_validator_data",
+ };
+ }
+
+ // Check instruction discriminator (0 = CheckClock)
+ if (instruction.data[0] !== 0) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_deadline_instruction_type",
+ };
+ }
+
+ // Extract deadline (little-endian i64 at offset 1)
+ const dataView = new DataView(
+ instruction.data.buffer,
+ instruction.data.byteOffset + 1, // Start after discriminator
+ 8,
+ );
+ const deadline = Number(dataView.getBigInt64(0, true));
+
+ // If maxTimeoutSeconds is specified, verify deadline is within bounds
+ if (maxTimeoutSeconds !== undefined) {
+ const now = Math.floor(Date.now() / 1000);
+ const maxDeadline = now + maxTimeoutSeconds;
+
+ if (deadline > maxDeadline) {
+ return {
+ isValid: false,
+ invalidReason: "deadline_exceeds_max_timeout",
+ };
+ }
+ }
+
+ // Verify deadline is in the future (or very recent past for tolerance)
+ const now = Math.floor(Date.now() / 1000);
+ const TOLERANCE_SECONDS = 30; // Allow 30 seconds of clock drift
+
+ if (deadline < now - TOLERANCE_SECONDS) {
+ return {
+ isValid: false,
+ invalidReason: "deadline_already_passed",
+ };
+ }
+
+ return { isValid: true };
+}
+
+// =============================================================================
+// Nonce Authority Verification
+// =============================================================================
+
+/**
+ * Verify nonce advance instruction doesn't use fee payer as authority.
+ *
+ * System program AdvanceNonceAccount instruction format:
+ * - byte 0-3: instruction discriminator (4 = AdvanceNonceAccount)
+ * - accounts[0]: nonce account (writable)
+ * - accounts[1]: recent blockhashes sysvar
+ * - accounts[2]: nonce authority (signer)
+ */
+export function verifyNonceAuthority(
+ instruction: {
+ programAddress: Address;
+ accounts: ReadonlyArray<{ address: Address; role?: unknown }>;
+ data?: Readonly;
+ },
+ feePayerAddresses: string[],
+): ValidationResult {
+ if (
+ instruction.programAddress.toString() !== SYSTEM_PROGRAM_ADDRESS.toString()
+ ) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_nonce_advance_program",
+ };
+ }
+
+ if (!instruction.data || instruction.data.length < 4) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_nonce_advance_data",
+ };
+ }
+
+ // Check instruction discriminator (4 = AdvanceNonceAccount as u32 LE)
+ const dataView = new DataView(
+ instruction.data.buffer,
+ instruction.data.byteOffset,
+ 4,
+ );
+ const discriminator = dataView.getUint32(0, true);
+
+ if (discriminator !== 4) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_nonce_instruction_type",
+ };
+ }
+
+ // accounts[2] is the nonce authority
+ if (instruction.accounts.length < 3) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_nonce_accounts",
+ };
+ }
+
+ const nonceAuthority = instruction.accounts[2].address.toString();
+
+ // SECURITY: Nonce authority must not be the fee payer
+ if (feePayerAddresses.includes(nonceAuthority)) {
+ return {
+ isValid: false,
+ invalidReason: "fee_payer_is_nonce_authority",
+ };
+ }
+
+ return { isValid: true };
+}
+
+// =============================================================================
+// Direct Transfer Verification
+// =============================================================================
+
+export async function verifyDirectTransfer(
+ instruction: {
+ programAddress: Address;
+ accounts: Array<{ address: Address }>;
+ data?: Readonly;
+ },
+ requirements: PaymentRequirements,
+ feePayerAddresses: string[],
+): Promise {
+ const programAddress = instruction.programAddress.toString();
+
+ // Must be Token or Token-2022 program
+ if (
+ programAddress !== TOKEN_PROGRAM_ADDRESS.toString() &&
+ programAddress !== TOKEN_2022_PROGRAM_ADDRESS.toString()
+ ) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_transfer_program",
+ };
+ }
+
+ // Parse transfer instruction
+ let parsed: ParsedTransferChecked;
+ try {
+ if (programAddress === TOKEN_PROGRAM_ADDRESS.toString()) {
+ parsed = parseTransferCheckedToken(
+ instruction as never,
+ ) as ParsedTransferChecked;
+ } else {
+ parsed = parseTransferChecked2022(
+ instruction as never,
+ ) as ParsedTransferChecked;
+ }
+ } catch {
+ return {
+ isValid: false,
+ invalidReason: "invalid_transfer_instruction",
+ };
+ }
+
+ // Extract payer (authority)
+ const authorityAddress = parsed.accounts.authority.address.toString();
+
+ // SECURITY: Fee payer must not be the transfer authority
+ if (feePayerAddresses.includes(authorityAddress)) {
+ return {
+ isValid: false,
+ invalidReason: "fee_payer_is_transfer_authority",
+ payer: authorityAddress,
+ };
+ }
+
+ // Verify mint
+ const mintAddress = parsed.accounts.mint.address.toString();
+ if (mintAddress !== requirements.asset) {
+ return {
+ isValid: false,
+ invalidReason: "mint_mismatch",
+ payer: authorityAddress,
+ };
+ }
+
+ // Verify amount
+ const amount = parsed.data.amount;
+ if (amount < BigInt(requirements.amount)) {
+ return {
+ isValid: false,
+ invalidReason: "insufficient_amount",
+ payer: authorityAddress,
+ };
+ }
+
+ // Verify destination ATA
+ const destAta = parsed.accounts.destination.address.toString();
+ const [expectedAta] = await findAssociatedTokenPda({
+ mint: requirements.asset as Address,
+ owner: requirements.payTo as Address,
+ tokenProgram:
+ programAddress === TOKEN_PROGRAM_ADDRESS.toString()
+ ? TOKEN_PROGRAM_ADDRESS
+ : TOKEN_2022_PROGRAM_ADDRESS,
+ });
+
+ if (destAta !== expectedAta.toString()) {
+ return {
+ isValid: false,
+ invalidReason: "destination_mismatch",
+ payer: authorityAddress,
+ };
+ }
+
+ return {
+ isValid: true,
+ payer: authorityAddress,
+ };
+}
+
+// =============================================================================
+// Binary Utilities
+// =============================================================================
+
+function readU64LE(
+ bytes: Uint8Array | ReadonlyUint8Array,
+ offset: number,
+): bigint {
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8);
+ return view.getBigUint64(0, true);
+}
+
+// =============================================================================
+// CPI Transfer Verification (via Simulation)
+// =============================================================================
+
+export async function verifyCpiTransfer(
+ simulationResult: SimulationResult,
+ requirements: PaymentRequirements,
+): Promise {
+ if (!simulationResult.success) {
+ return {
+ isValid: false,
+ invalidReason: `simulation_failed: ${simulationResult.error}`,
+ };
+ }
+
+ // Extract inner instructions
+ const innerInstructions = simulationResult.innerInstructions;
+ if (!innerInstructions || innerInstructions.length === 0) {
+ return {
+ isValid: false,
+ invalidReason: "no_inner_instructions",
+ };
+ }
+
+ // Find TransferChecked in inner instructions
+ // TransferChecked instruction discriminator is 12
+ const TRANSFER_CHECKED_DISCRIMINATOR = 12;
+
+ let transferFound = false;
+ let transferAmount: bigint | undefined;
+
+ const base64Encoder = getBase64Encoder();
+
+ for (const inner of innerInstructions) {
+ for (const ix of inner.instructions) {
+ // Check if this is a TransferChecked instruction
+ // Data format: [discriminator (1 byte), amount (8 bytes LE), decimals (1 byte)]
+ const dataBytes = base64Encoder.encode(ix.data);
+ if (
+ dataBytes[0] === TRANSFER_CHECKED_DISCRIMINATOR &&
+ dataBytes.length >= 10
+ ) {
+ // Already found one transfer - error if we find another
+ if (transferFound) {
+ return {
+ isValid: false,
+ invalidReason: "multiple_transfers_in_cpi",
+ };
+ }
+
+ transferFound = true;
+ // Read amount as little-endian u64
+ transferAmount = readU64LE(dataBytes, 1);
+ }
+ }
+ }
+
+ if (!transferFound || transferAmount === undefined) {
+ return {
+ isValid: false,
+ invalidReason: "no_transfer_in_cpi",
+ };
+ }
+
+ // Verify amount
+ if (transferAmount < BigInt(requirements.amount)) {
+ return {
+ isValid: false,
+ invalidReason: "insufficient_amount",
+ };
+ }
+
+ // Note: For CPI verification, we trust the simulation result for destination
+ // because the transaction was simulated successfully and we verified the transfer exists
+
+ return {
+ isValid: true,
+ // Payer extraction for CPI is complex - would need to trace the outer instruction
+ // For now, we don't return payer for CPI transfers
+ };
+}
+
+// =============================================================================
+// Fee Payer Safety Check
+// =============================================================================
+
+export function verifyFeePayerSafety(
+ compiled: CompiledTransactionMessage,
+ feePayerAddresses: string[],
+ layout: InstructionLayout,
+): ValidationResult {
+ const staticAccounts = compiled.staticAccounts ?? [];
+ const instructions = compiled.instructions ?? [];
+
+ // Check each instruction (except compute budget) to ensure fee payer isn't in accounts
+ for (let i = 0; i < instructions.length; i++) {
+ // Skip compute budget instructions
+ if (i === layout.computeLimitIndex || i === layout.computePriceIndex) {
+ continue;
+ }
+
+ const ix = instructions[i];
+ const accountIndices = ix.accountIndices ?? [];
+
+ for (const accountIndex of accountIndices) {
+ const accountAddress = staticAccounts[accountIndex]?.toString();
+ if (feePayerAddresses.includes(accountAddress)) {
+ return {
+ isValid: false,
+ invalidReason: "fee_payer_in_instruction_accounts",
+ };
+ }
+ }
+ }
+
+ return { isValid: true };
+}
+
+// =============================================================================
+// Full Transaction Verification
+// =============================================================================
+
+export async function verifyTransaction(
+ transactionBase64: string,
+ requirements: PaymentRequirements,
+ feePayerAddresses: string[],
+ simulationResult?: SimulationResult,
+): Promise {
+ // 1. Decode transaction
+ let tx: Transaction;
+ try {
+ tx = decodeTransaction(transactionBase64);
+ } catch {
+ return {
+ isValid: false,
+ invalidReason: "invalid_transaction_encoding",
+ };
+ }
+
+ // 2. Decompile message
+ const compiled = getCompiledTransactionMessageDecoder().decode(
+ tx.messageBytes,
+ ) as CompiledTransactionMessage;
+ // Add dummy lifetimeToken for decompilation (not used in validation)
+ const compiledWithLifetime = {
+ ...compiled,
+ lifetimeToken: "11111111111111111111111111111111" as const,
+ };
+ const decompiled = decompileTransactionMessage(compiledWithLifetime);
+ const instructions = decompiled.instructions ?? [];
+
+ // 3. Detect instruction layout
+ const layout = detectInstructionLayout(instructions);
+ if (!layout) {
+ return {
+ isValid: false,
+ invalidReason: "invalid_instruction_layout",
+ };
+ }
+
+ // 4. Verify compute budget
+ const computeLimitResult = verifyComputeLimit(
+ instructions[layout.computeLimitIndex] as never,
+ );
+ if (!computeLimitResult.isValid) {
+ return computeLimitResult;
+ }
+
+ const computePriceResult = verifyComputePrice(
+ instructions[layout.computePriceIndex] as never,
+ );
+ if (!computePriceResult.isValid) {
+ return computePriceResult;
+ }
+
+ // 5. Verify nonce authority (if nonce advance is present)
+ if (layout.hasNonceAdvance) {
+ const nonceResult = verifyNonceAuthority(
+ instructions[0] as never,
+ feePayerAddresses,
+ );
+ if (!nonceResult.isValid) {
+ return nonceResult;
+ }
+ }
+
+ // 6. Verify deadline validator (if present)
+ if (
+ layout.hasDeadlineValidator &&
+ layout.deadlineValidatorIndex !== undefined
+ ) {
+ const deadlineResult = verifyDeadlineValidator(
+ instructions[layout.deadlineValidatorIndex] as never,
+ requirements.extra?.maxTimeoutSeconds as number | undefined,
+ );
+ if (!deadlineResult.isValid) {
+ return deadlineResult;
+ }
+ }
+
+ // 7. Verify fee payer safety
+ const feePayerResult = verifyFeePayerSafety(
+ compiled,
+ feePayerAddresses,
+ layout,
+ );
+ if (!feePayerResult.isValid) {
+ return feePayerResult;
+ }
+
+ // 8. Verify transfer
+ if (layout.isDirectTransfer) {
+ // Direct transfer - static verification
+ return await verifyDirectTransfer(
+ instructions[layout.transferIndex] as never,
+ requirements,
+ feePayerAddresses,
+ );
+ } else {
+ // CPI transfer - simulation verification
+ if (!simulationResult) {
+ return {
+ isValid: false,
+ invalidReason: "simulation_required_for_cpi",
+ };
+ }
+ return await verifyCpiTransfer(simulationResult, requirements);
+ }
+}
diff --git a/apps/facilitator/src/routes/routes.test.ts b/apps/facilitator/src/routes/routes.test.ts
new file mode 100644
index 0000000..e17a20a
--- /dev/null
+++ b/apps/facilitator/src/routes/routes.test.ts
@@ -0,0 +1,315 @@
+/**
+ * Integration tests for HTTP routes
+ *
+ * Tests the Hono app handlers with mocked dependencies.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+import type { Env } from "../types.js";
+import type {
+ SupportedResponse,
+ VerifyResponse,
+ SettleResponse,
+} from "@x402/core/types";
+
+// Mock the signer module
+vi.mock("../lib/signer.js", () => ({
+ createFacilitatorSigner: vi.fn().mockResolvedValue({
+ getAddresses: () => ["F2vVvFwrbGHtsBEqFkSkLvsM6SJmDMm7KqhiW2P64WxY"],
+ signTransaction: vi.fn().mockResolvedValue("signed-tx-base64"),
+ simulateTransaction: vi.fn().mockResolvedValue({
+ success: true,
+ logs: [],
+ innerInstructions: [],
+ }),
+ sendTransaction: vi.fn().mockResolvedValue("tx-signature-123"),
+ confirmTransaction: vi.fn().mockResolvedValue(undefined),
+ }),
+ decodeTransaction: vi.fn().mockReturnValue({
+ messageBytes: new Uint8Array(100),
+ signatures: {},
+ }),
+}));
+
+// Import handlers after mocking
+import { supportedHandler } from "./supported.js";
+import { verifyHandler } from "./verify.js";
+import { settleHandler } from "./settle.js";
+
+// Create test app
+function createTestApp() {
+ const app = new Hono<{ Bindings: Env }>();
+
+ app.get("/supported", supportedHandler);
+ app.post("/verify", verifyHandler);
+ app.post("/settle", settleHandler);
+
+ return app;
+}
+
+const TEST_ENV: Env = {
+ FEE_PAYER_KEY: "test-key-base58",
+ HELIUS_RPC_URL: "https://test-rpc.example.com",
+};
+
+describe("GET /supported", () => {
+ it("returns supported schemes and extensions", async () => {
+ const app = createTestApp();
+ const res = await app.request("/supported", {}, TEST_ENV);
+
+ expect(res.status).toBe(200);
+
+ const body = (await res.json()) as SupportedResponse;
+ expect(body.kinds).toHaveLength(1);
+ expect(body.kinds[0]).toMatchObject({
+ x402Version: 2,
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ });
+ expect(body.kinds[0].extra?.feePayer).toBe(
+ "F2vVvFwrbGHtsBEqFkSkLvsM6SJmDMm7KqhiW2P64WxY",
+ );
+ expect(body.extensions).toContain("cpi-verification");
+ expect(body.extensions).toContain("deadline-validator");
+ expect(body.extensions).toContain("durable-nonce");
+ expect(body.signers["solana:*"]).toContain(
+ "F2vVvFwrbGHtsBEqFkSkLvsM6SJmDMm7KqhiW2P64WxY",
+ );
+ });
+
+ it("returns 500 when misconfigured", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/supported",
+ {},
+ { FEE_PAYER_KEY: "", HELIUS_RPC_URL: "" },
+ );
+
+ expect(res.status).toBe(500);
+ });
+});
+
+describe("POST /verify", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("rejects invalid request body", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not-json",
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as VerifyResponse;
+ expect(body.isValid).toBe(false);
+ expect(body.invalidReason).toBe("invalid_request_body");
+ });
+
+ it("rejects unsupported scheme", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ x402Version: 2,
+ paymentPayload: {
+ x402Version: 2,
+ accepted: { scheme: "other", network: "solana:mainnet" },
+ payload: {},
+ },
+ paymentRequirements: {
+ scheme: "exact",
+ network: "solana:mainnet",
+ amount: "1000000",
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: "recipient",
+ maxTimeoutSeconds: 90,
+ extra: {},
+ },
+ }),
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as VerifyResponse;
+ expect(body.isValid).toBe(false);
+ expect(body.invalidReason).toBe("unsupported_scheme");
+ });
+
+ it("rejects network mismatch", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ x402Version: 2,
+ paymentPayload: {
+ x402Version: 2,
+ accepted: { scheme: "exact", network: "solana:devnet" },
+ payload: {},
+ },
+ paymentRequirements: {
+ scheme: "exact",
+ network: "solana:mainnet",
+ amount: "1000000",
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: "recipient",
+ maxTimeoutSeconds: 90,
+ extra: {},
+ },
+ }),
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as VerifyResponse;
+ expect(body.isValid).toBe(false);
+ expect(body.invalidReason).toBe("network_mismatch");
+ });
+
+ it("rejects missing transaction", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ x402Version: 2,
+ paymentPayload: {
+ x402Version: 2,
+ accepted: {
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ },
+ payload: {},
+ },
+ paymentRequirements: {
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ amount: "1000000",
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: "recipient",
+ maxTimeoutSeconds: 90,
+ extra: { feePayer: "F2vVvFwrbGHtsBEqFkSkLvsM6SJmDMm7KqhiW2P64WxY" },
+ },
+ }),
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as VerifyResponse;
+ expect(body.isValid).toBe(false);
+ expect(body.invalidReason).toBe("missing_transaction");
+ });
+
+ it("rejects unmanaged fee payer", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/verify",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ x402Version: 2,
+ paymentPayload: {
+ x402Version: 2,
+ accepted: {
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ },
+ payload: { transaction: "base64tx" },
+ },
+ paymentRequirements: {
+ scheme: "exact",
+ network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ amount: "1000000",
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: "recipient",
+ maxTimeoutSeconds: 90,
+ extra: { feePayer: "SomeOtherAddress" },
+ },
+ }),
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as VerifyResponse;
+ expect(body.isValid).toBe(false);
+ expect(body.invalidReason).toBe("fee_payer_not_managed_by_facilitator");
+ });
+});
+
+describe("POST /settle", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("rejects invalid request body", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/settle",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not-json",
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as SettleResponse;
+ expect(body.success).toBe(false);
+ expect(body.errorReason).toBe("invalid_request_body");
+ });
+
+ it("rejects unsupported scheme", async () => {
+ const app = createTestApp();
+ const res = await app.request(
+ "/settle",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ x402Version: 2,
+ paymentPayload: {
+ x402Version: 2,
+ accepted: { scheme: "other", network: "solana:mainnet" },
+ payload: {},
+ },
+ paymentRequirements: {
+ scheme: "exact",
+ network: "solana:mainnet",
+ amount: "1000000",
+ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
+ payTo: "recipient",
+ maxTimeoutSeconds: 90,
+ extra: {},
+ },
+ }),
+ },
+ TEST_ENV,
+ );
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as SettleResponse;
+ expect(body.success).toBe(false);
+ expect(body.errorReason).toBe("unsupported_scheme");
+ });
+});
diff --git a/apps/facilitator/src/routes/settle.ts b/apps/facilitator/src/routes/settle.ts
new file mode 100644
index 0000000..f039c0f
--- /dev/null
+++ b/apps/facilitator/src/routes/settle.ts
@@ -0,0 +1,262 @@
+/**
+ * POST /settle
+ *
+ * Settles a payment by verifying and broadcasting the transaction.
+ */
+
+import type { Context } from "hono";
+import type { SettleRequest, SettleResponse } from "@x402/core/types";
+import type { Env, ExactSvmPayload } from "../types.js";
+import {
+ createFacilitatorSigner,
+ type SimulationResult,
+} from "../lib/signer.js";
+import {
+ verifyTransaction,
+ detectInstructionLayout,
+} from "../lib/validation.js";
+import { decodeTransaction } from "../lib/signer.js";
+import {
+ decompileTransactionMessage,
+ getCompiledTransactionMessageDecoder,
+ createSolanaRpc,
+ type CompiledTransactionMessage,
+ type Address,
+} from "@solana/kit";
+import { executeAndSendSplit } from "@cascade-fyi/splits-sdk";
+
+export async function settleHandler(c: Context<{ Bindings: Env }>) {
+ const { FEE_PAYER_KEY, HELIUS_RPC_URL } = c.env;
+
+ if (!FEE_PAYER_KEY || !HELIUS_RPC_URL) {
+ return c.json({ error: "Server misconfigured" }, 500);
+ }
+
+ // Parse request body
+ let body: SettleRequest;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json(
+ {
+ success: false,
+ errorReason: "invalid_request_body",
+ transaction: "",
+ network: "unknown:unknown",
+ } satisfies SettleResponse,
+ 400,
+ );
+ }
+
+ const { paymentPayload, paymentRequirements } = body;
+
+ // Validate scheme
+ if (
+ paymentPayload.accepted.scheme !== "exact" ||
+ paymentRequirements.scheme !== "exact"
+ ) {
+ return c.json(
+ {
+ success: false,
+ errorReason: "unsupported_scheme",
+ transaction: "",
+ network: paymentPayload.accepted.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Validate network match
+ if (paymentPayload.accepted.network !== paymentRequirements.network) {
+ return c.json(
+ {
+ success: false,
+ errorReason: "network_mismatch",
+ transaction: "",
+ network: paymentPayload.accepted.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Extract transaction from payload
+ const svmPayload = paymentPayload.payload as unknown as ExactSvmPayload;
+ if (!svmPayload?.transaction) {
+ return c.json(
+ {
+ success: false,
+ errorReason: "missing_transaction",
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Create signer
+ const signer = await createFacilitatorSigner(FEE_PAYER_KEY, HELIUS_RPC_URL);
+ const feePayerAddresses = [...signer.getAddresses()].map((a) => a.toString());
+
+ // Validate fee payer in requirements
+ const requestedFeePayer = paymentRequirements.extra?.feePayer;
+ if (
+ typeof requestedFeePayer !== "string" ||
+ !feePayerAddresses.includes(requestedFeePayer)
+ ) {
+ return c.json(
+ {
+ success: false,
+ errorReason: "fee_payer_not_managed_by_facilitator",
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Detect if we need simulation (for CPI transactions)
+ let needsSimulation = false;
+ try {
+ const tx = decodeTransaction(svmPayload.transaction);
+ const compiled = getCompiledTransactionMessageDecoder().decode(
+ tx.messageBytes,
+ ) as CompiledTransactionMessage;
+ // Add dummy lifetimeToken for decompilation (not used in validation)
+ const compiledWithLifetime = {
+ ...compiled,
+ lifetimeToken: "11111111111111111111111111111111" as const,
+ };
+ const decompiled = decompileTransactionMessage(compiledWithLifetime);
+ const instructions = decompiled.instructions ?? [];
+ const layout = detectInstructionLayout(instructions);
+
+ if (layout && !layout.isDirectTransfer) {
+ needsSimulation = true;
+ }
+ } catch {
+ return c.json(
+ {
+ success: false,
+ errorReason: "invalid_transaction_encoding",
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Sign transaction (facilitator adds fee payer signature)
+ let signedTransaction: string;
+ try {
+ signedTransaction = await signer.signTransaction(
+ svmPayload.transaction,
+ requestedFeePayer as Address,
+ paymentRequirements.network,
+ );
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ errorReason: `signing_failed: ${error instanceof Error ? error.message : "unknown"}`,
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Simulate transaction
+ let simulationResult: SimulationResult;
+ try {
+ simulationResult = await signer.simulateTransaction(
+ signedTransaction,
+ paymentRequirements.network,
+ );
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ errorReason: `simulation_failed: ${error instanceof Error ? error.message : "unknown"}`,
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Verify transaction
+ const verifyResult = await verifyTransaction(
+ svmPayload.transaction,
+ paymentRequirements,
+ feePayerAddresses,
+ needsSimulation ? simulationResult : undefined,
+ );
+
+ if (!verifyResult.isValid) {
+ return c.json(
+ {
+ success: false,
+ errorReason: verifyResult.invalidReason ?? "verification_failed",
+ payer: verifyResult.payer,
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Send transaction
+ let signature: string;
+ try {
+ signature = await signer.sendTransaction(
+ signedTransaction,
+ paymentRequirements.network,
+ );
+ } catch (error) {
+ return c.json(
+ {
+ success: false,
+ errorReason: `send_failed: ${error instanceof Error ? error.message : "unknown"}`,
+ payer: verifyResult.payer,
+ transaction: "",
+ network: paymentRequirements.network,
+ } as SettleResponse,
+ 400,
+ );
+ }
+
+ // Wait for confirmation
+ try {
+ await signer.confirmTransaction(signature, paymentRequirements.network);
+ } catch {
+ // Transaction was sent but confirmation failed/timed out
+ // Return success with the signature - the transaction may still land
+ return c.json({
+ success: true,
+ payer: verifyResult.payer,
+ transaction: signature,
+ network: paymentRequirements.network,
+ } as SettleResponse);
+ }
+
+ // Execute split if payTo is a Cascade split (fire and forget)
+ const rpc = createSolanaRpc(HELIUS_RPC_URL as `https://${string}`);
+ executeAndSendSplit({
+ rpc,
+ splitConfig: paymentRequirements.payTo as Address,
+ signer: signer.keyPairSigner,
+ }).then((r) => {
+ if (r.sent) {
+ console.log(`[splits] Executed: ${r.signature}`);
+ } else if (r.reason !== "not_a_split") {
+ console.warn(`[splits] Skipped: ${r.reason}`, r.error);
+ }
+ });
+
+ return c.json({
+ success: true,
+ payer: verifyResult.payer,
+ transaction: signature,
+ network: paymentRequirements.network,
+ } as SettleResponse);
+}
diff --git a/apps/facilitator/src/routes/supported.ts b/apps/facilitator/src/routes/supported.ts
new file mode 100644
index 0000000..6d4b6ba
--- /dev/null
+++ b/apps/facilitator/src/routes/supported.ts
@@ -0,0 +1,50 @@
+/**
+ * GET /supported
+ *
+ * Returns facilitator capabilities - supported schemes, networks, and extensions.
+ */
+
+import type { Context } from "hono";
+import type { SupportedResponse } from "@x402/core/types";
+import type { Env } from "../types.js";
+import { createFacilitatorSigner } from "../lib/signer.js";
+
+// Solana mainnet CAIP-2 identifier
+const SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
+
+export async function supportedHandler(c: Context<{ Bindings: Env }>) {
+ const { FEE_PAYER_KEY, HELIUS_RPC_URL } = c.env;
+
+ if (!FEE_PAYER_KEY || !HELIUS_RPC_URL) {
+ return c.json({ error: "Server misconfigured" }, 500);
+ }
+
+ // Get fee payer address
+ const signer = await createFacilitatorSigner(FEE_PAYER_KEY, HELIUS_RPC_URL);
+ const addresses = signer.getAddresses();
+ const feePayer = addresses[0];
+
+ const response: SupportedResponse = {
+ kinds: [
+ {
+ x402Version: 2,
+ scheme: "exact",
+ network: SOLANA_MAINNET,
+ extra: {
+ feePayer,
+ },
+ },
+ ],
+ // RFC #646 extensions
+ extensions: [
+ "cpi-verification", // Smart wallet CPI support via simulation
+ "deadline-validator", // maxTimeoutSeconds enforcement
+ "durable-nonce", // Extended timeouts (>90s)
+ ],
+ signers: {
+ "solana:*": [...addresses],
+ },
+ };
+
+ return c.json(response);
+}
diff --git a/apps/facilitator/src/routes/verify.ts b/apps/facilitator/src/routes/verify.ts
new file mode 100644
index 0000000..ade590d
--- /dev/null
+++ b/apps/facilitator/src/routes/verify.ts
@@ -0,0 +1,176 @@
+/**
+ * POST /verify
+ *
+ * Verifies a payment transaction without settling.
+ * Implements RFC #646 verification:
+ * - 3-6 instruction support
+ * - CPI verification via simulation
+ * - Deadline validator support
+ */
+
+import type { Context } from "hono";
+import type { VerifyRequest, VerifyResponse } from "@x402/core/types";
+import type { Env, ExactSvmPayload } from "../types.js";
+import {
+ createFacilitatorSigner,
+ type SimulationResult,
+} from "../lib/signer.js";
+import {
+ verifyTransaction,
+ detectInstructionLayout,
+} from "../lib/validation.js";
+import { decodeTransaction } from "../lib/signer.js";
+import {
+ decompileTransactionMessage,
+ getCompiledTransactionMessageDecoder,
+ type CompiledTransactionMessage,
+ type Address,
+} from "@solana/kit";
+
+export async function verifyHandler(c: Context<{ Bindings: Env }>) {
+ const { FEE_PAYER_KEY, HELIUS_RPC_URL } = c.env;
+
+ if (!FEE_PAYER_KEY || !HELIUS_RPC_URL) {
+ return c.json({ error: "Server misconfigured" }, 500);
+ }
+
+ // Parse request body
+ let body: VerifyRequest;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: "invalid_request_body",
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ const { paymentPayload, paymentRequirements } = body;
+
+ // Validate scheme
+ if (
+ paymentPayload.accepted.scheme !== "exact" ||
+ paymentRequirements.scheme !== "exact"
+ ) {
+ return c.json(
+ { isValid: false, invalidReason: "unsupported_scheme" } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Validate network match
+ if (paymentPayload.accepted.network !== paymentRequirements.network) {
+ return c.json(
+ { isValid: false, invalidReason: "network_mismatch" } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Extract transaction from payload
+ const svmPayload = paymentPayload.payload as unknown as ExactSvmPayload;
+ if (!svmPayload?.transaction) {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: "missing_transaction",
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Create signer
+ const signer = await createFacilitatorSigner(FEE_PAYER_KEY, HELIUS_RPC_URL);
+ const feePayerAddresses = [...signer.getAddresses()].map((a) => a.toString());
+
+ // Validate fee payer in requirements
+ const requestedFeePayer = paymentRequirements.extra?.feePayer;
+ if (
+ typeof requestedFeePayer !== "string" ||
+ !feePayerAddresses.includes(requestedFeePayer)
+ ) {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: "fee_payer_not_managed_by_facilitator",
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Detect if we need simulation (for CPI transactions)
+ let needsSimulation = false;
+ try {
+ const tx = decodeTransaction(svmPayload.transaction);
+ const compiled = getCompiledTransactionMessageDecoder().decode(
+ tx.messageBytes,
+ ) as CompiledTransactionMessage;
+ // Add dummy lifetimeToken for decompilation (not used in validation)
+ const compiledWithLifetime = {
+ ...compiled,
+ lifetimeToken: "11111111111111111111111111111111" as const,
+ };
+ const decompiled = decompileTransactionMessage(compiledWithLifetime);
+ const instructions = decompiled.instructions ?? [];
+ const layout = detectInstructionLayout(instructions);
+
+ if (layout && !layout.isDirectTransfer) {
+ needsSimulation = true;
+ }
+ } catch {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: "invalid_transaction_encoding",
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Sign transaction first (facilitator adds fee payer signature)
+ let signedTransaction: string;
+ try {
+ signedTransaction = await signer.signTransaction(
+ svmPayload.transaction,
+ requestedFeePayer as Address,
+ paymentRequirements.network,
+ );
+ } catch (error) {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: `signing_failed: ${error instanceof Error ? error.message : "unknown"}`,
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Simulate if needed (for CPI verification or general validation)
+ let simulationResult: SimulationResult;
+ try {
+ simulationResult = await signer.simulateTransaction(
+ signedTransaction,
+ paymentRequirements.network,
+ );
+ } catch (error) {
+ return c.json(
+ {
+ isValid: false,
+ invalidReason: `simulation_failed: ${error instanceof Error ? error.message : "unknown"}`,
+ } as VerifyResponse,
+ 400,
+ );
+ }
+
+ // Verify transaction
+ const result = await verifyTransaction(
+ svmPayload.transaction,
+ paymentRequirements,
+ feePayerAddresses,
+ needsSimulation ? simulationResult : undefined,
+ );
+
+ return c.json(result as VerifyResponse);
+}
diff --git a/apps/facilitator/src/types.ts b/apps/facilitator/src/types.ts
new file mode 100644
index 0000000..bb38fe7
--- /dev/null
+++ b/apps/facilitator/src/types.ts
@@ -0,0 +1,23 @@
+/**
+ * Facilitator-specific Types
+ *
+ * Core x402 types should be imported directly from @x402/core/types
+ */
+
+// =============================================================================
+// Exact SVM Payload Types
+// =============================================================================
+
+/** Payload for exact scheme on Solana (base64-encoded transaction) */
+export interface ExactSvmPayload {
+ transaction: string;
+}
+
+// =============================================================================
+// Cloudflare Bindings
+// =============================================================================
+
+export interface Env {
+ FEE_PAYER_KEY: string;
+ HELIUS_RPC_URL: string;
+}
diff --git a/apps/facilitator/tsconfig.json b/apps/facilitator/tsconfig.json
new file mode 100644
index 0000000..b76dd49
--- /dev/null
+++ b/apps/facilitator/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/facilitator/tsconfig.tsbuildinfo b/apps/facilitator/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..07088ea
--- /dev/null
+++ b/apps/facilitator/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/index.ts","./src/types.ts","./src/lib/signer.ts","./src/lib/validation.test.ts","./src/lib/validation.ts","./src/routes/routes.test.ts","./src/routes/settle.ts","./src/routes/supported.ts","./src/routes/verify.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/apps/facilitator/vitest.config.ts b/apps/facilitator/vitest.config.ts
new file mode 100644
index 0000000..3085f32
--- /dev/null
+++ b/apps/facilitator/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ testTimeout: 10000,
+ },
+});
diff --git a/apps/facilitator/wrangler.jsonc b/apps/facilitator/wrangler.jsonc
new file mode 100644
index 0000000..5feabbd
--- /dev/null
+++ b/apps/facilitator/wrangler.jsonc
@@ -0,0 +1,17 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "cascade-facilitator",
+ "account_id": "3db7dd8ff22ae68593205855d8bc0b25",
+ "main": "./src/index.ts",
+ "compatibility_date": "2025-12-01",
+ "compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "enabled": true
+ },
+ "upload_source_maps": true,
+
+ // Custom domain for facilitator
+ "routes": [
+ { "pattern": "facilitator.cascade.fyi", "custom_domain": true }
+ ]
+}
diff --git a/apps/market/.cta.json b/apps/market/.cta.json
new file mode 100644
index 0000000..3ac7000
--- /dev/null
+++ b/apps/market/.cta.json
@@ -0,0 +1,12 @@
+{
+ "projectName": "market",
+ "mode": "file-router",
+ "typescript": true,
+ "tailwind": true,
+ "packageManager": "pnpm",
+ "git": false,
+ "addOnOptions": {},
+ "version": 1,
+ "framework": "react-cra",
+ "chosenAddOns": ["start", "cloudflare"]
+}
diff --git a/apps/market/.dev.vars.example b/apps/market/.dev.vars.example
new file mode 100644
index 0000000..7193ab2
--- /dev/null
+++ b/apps/market/.dev.vars.example
@@ -0,0 +1,5 @@
+# Copy this file to .dev.vars and fill in values
+# Do not commit .dev.vars - it contains secrets
+
+# JWT secret for signing auth tokens (minimum 32 characters)
+JWT_SECRET=your-secret-here-minimum-32-characters
diff --git a/apps/market/.gitignore b/apps/market/.gitignore
new file mode 100644
index 0000000..055af72
--- /dev/null
+++ b/apps/market/.gitignore
@@ -0,0 +1,17 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+count.txt
+.env
+.nitro
+.tanstack
+.wrangler
+.output
+.vinxi
+todos.json
+
+.dev.vars*
+!.dev.vars.example
+!.env.example
diff --git a/apps/market/.vscode/settings.json b/apps/market/.vscode/settings.json
new file mode 100644
index 0000000..00b5278
--- /dev/null
+++ b/apps/market/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+ "files.watcherExclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "search.exclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "files.readonlyInclude": {
+ "**/routeTree.gen.ts": true
+ }
+}
diff --git a/apps/market/README.md b/apps/market/README.md
new file mode 100644
index 0000000..a4739fd
--- /dev/null
+++ b/apps/market/README.md
@@ -0,0 +1,290 @@
+Welcome to your new TanStack app!
+
+# Getting Started
+
+To run this application:
+
+```bash
+pnpm install
+pnpm start
+```
+
+# Building For Production
+
+To build this application for production:
+
+```bash
+pnpm build
+```
+
+## Testing
+
+This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
+
+```bash
+pnpm test
+```
+
+## Styling
+
+This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
+
+
+
+
+## Routing
+This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
+
+### Adding A Route
+
+To add a new route to your application just add another a new file in the `./src/routes` directory.
+
+TanStack will automatically generate the content of the route file for you.
+
+Now that you have two routes you can use a `Link` component to navigate between them.
+
+### Adding Links
+
+To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
+
+```tsx
+import { Link } from "@tanstack/react-router";
+```
+
+Then anywhere in your JSX you can use it like so:
+
+```tsx
+About
+```
+
+This will create a link that will navigate to the `/about` route.
+
+More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
+
+### Using A Layout
+
+In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component.
+
+Here is an example layout that includes a header:
+
+```tsx
+import { Outlet, createRootRoute } from '@tanstack/react-router'
+import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
+
+import { Link } from "@tanstack/react-router";
+
+export const Route = createRootRoute({
+ component: () => (
+ <>
+
+
+
+
+
+ >
+ ),
+})
+```
+
+The `` component is not required so you can remove it if you don't want it in your layout.
+
+More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
+
+
+## Data Fetching
+
+There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
+
+For example:
+
+```tsx
+const peopleRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/people",
+ loader: async () => {
+ const response = await fetch("https://swapi.dev/api/people");
+ return response.json() as Promise<{
+ results: {
+ name: string;
+ }[];
+ }>;
+ },
+ component: () => {
+ const data = peopleRoute.useLoaderData();
+ return (
+
+ {data.results.map((person) => (
+
{person.name}
+ ))}
+
+ );
+ },
+});
+```
+
+Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
+
+### React-Query
+
+React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
+
+First add your dependencies:
+
+```bash
+pnpm add @tanstack/react-query @tanstack/react-query-devtools
+```
+
+Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
+
+```tsx
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+// ...
+
+const queryClient = new QueryClient();
+
+// ...
+
+if (!rootElement.innerHTML) {
+ const root = ReactDOM.createRoot(rootElement);
+
+ root.render(
+
+
+
+ );
+}
+```
+
+You can also add TanStack Query Devtools to the root route (optional).
+
+```tsx
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+
+const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+
+ >
+ ),
+});
+```
+
+Now you can use `useQuery` to fetch your data.
+
+```tsx
+import { useQuery } from "@tanstack/react-query";
+
+import "./App.css";
+
+function App() {
+ const { data } = useQuery({
+ queryKey: ["people"],
+ queryFn: () =>
+ fetch("https://swapi.dev/api/people")
+ .then((res) => res.json())
+ .then((data) => data.results as { name: string }[]),
+ initialData: [],
+ });
+
+ return (
+
+
+ {data.map((person) => (
+
{person.name}
+ ))}
+
+
+ );
+}
+
+export default App;
+```
+
+You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
+
+## State Management
+
+Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
+
+First you need to add TanStack Store as a dependency:
+
+```bash
+pnpm add @tanstack/store
+```
+
+Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
+
+```tsx
+import { useStore } from "@tanstack/react-store";
+import { Store } from "@tanstack/store";
+import "./App.css";
+
+const countStore = new Store(0);
+
+function App() {
+ const count = useStore(countStore);
+ return (
+
+
+
+ );
+}
+
+export default App;
+```
+
+One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
+
+Let's check this out by doubling the count using derived state.
+
+```tsx
+import { useStore } from "@tanstack/react-store";
+import { Store, Derived } from "@tanstack/store";
+import "./App.css";
+
+const countStore = new Store(0);
+
+const doubledStore = new Derived({
+ fn: () => countStore.state * 2,
+ deps: [countStore],
+});
+doubledStore.mount();
+
+function App() {
+ const count = useStore(countStore);
+ const doubledCount = useStore(doubledStore);
+
+ return (
+
+
+
Doubled - {doubledCount}
+
+ );
+}
+
+export default App;
+```
+
+We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
+
+Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
+
+You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
+
+# Demo files
+
+Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
+
+# Learn More
+
+You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
diff --git a/apps/market/components.json b/apps/market/components.json
new file mode 100644
index 0000000..67b287a
--- /dev/null
+++ b/apps/market/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/styles.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/apps/market/package.json b/apps/market/package.json
new file mode 100644
index 0000000..0b7f46d
--- /dev/null
+++ b/apps/market/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "market",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "serve": "vite preview",
+ "deploy": "pnpm run build && wrangler deploy",
+ "preview": "pnpm run build && vite preview",
+ "cf-typegen": "wrangler types",
+ "type-check": "tsc -b",
+ "lint": "biome check",
+ "check": "pnpm type-check && biome check --write"
+ },
+ "dependencies": {
+ "@cascade-fyi/splits-sdk": "workspace:*",
+ "@cascade-fyi/tabs-sdk": "workspace:*",
+ "@cloudflare/vite-plugin": "^1.17.1",
+ "@cloudflare/workers-oauth-provider": "^0.1.0",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-avatar": "^1.1.11",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@solana/client": "^1.1.4",
+ "@solana/kit": "^5.0.0",
+ "@solana/react": "^5.0.0",
+ "@solana/react-hooks": "^1.1.4",
+ "@solana/wallet-standard-features": "^1.3.0",
+ "@solana/wallet-standard-util": "^1.1.2",
+ "@tailwindcss/vite": "^4.1.17",
+ "@tanstack/react-devtools": "^0.7.0",
+ "@tanstack/react-query": "^5.90.12",
+ "@tanstack/react-router": "^1.141.0",
+ "@tanstack/react-router-devtools": "^1.141.0",
+ "@tanstack/react-router-ssr-query": "^1.141.0",
+ "@tanstack/react-start": "^1.141.0",
+ "@tanstack/react-table": "^8.21.3",
+ "@tanstack/router-plugin": "^1.141.0",
+ "@wallet-standard/react": "^1.0.1",
+ "@x402/core": "^2.0.0",
+ "@x402/extensions": "2.0.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "hono": "^4.10.8",
+ "jose": "^6.1.3",
+ "lucide-react": "^0.560.0",
+ "nanoid": "^5.1.6",
+ "pkce-challenge": "^5.0.1",
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
+ "react-hook-form": "^7.66.1",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.17",
+ "vite-tsconfig-paths": "^5.1.4",
+ "zod": "^4.1.13"
+ },
+ "devDependencies": {
+ "@tanstack/devtools-vite": "^0.3.11",
+ "@types/node": "^22.19.2",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.4",
+ "shadcn": "^3.5.2",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "web-vitals": "^5.1.0",
+ "wrangler": "^4.54.0"
+ }
+}
diff --git a/apps/market/public/manifest.json b/apps/market/public/manifest.json
new file mode 100644
index 0000000..078ef50
--- /dev/null
+++ b/apps/market/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "TanStack App",
+ "name": "Create TanStack App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/apps/market/public/robots.txt b/apps/market/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/apps/market/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/apps/docs/public/water-wave-cascade.svg b/apps/market/public/water-wave-cascade.svg
similarity index 100%
rename from apps/docs/public/water-wave-cascade.svg
rename to apps/market/public/water-wave-cascade.svg
diff --git a/apps/market/src/components/About.tsx b/apps/market/src/components/About.tsx
new file mode 100644
index 0000000..999ba07
--- /dev/null
+++ b/apps/market/src/components/About.tsx
@@ -0,0 +1,152 @@
+import { Link } from "@tanstack/react-router";
+import { useWalletConnection } from "@solana/react-hooks";
+import { Terminal, Server, DollarSign, Wallet, ArrowRight } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function About() {
+ const { connect, connectors, connecting, connected } = useWalletConnection();
+
+ return (
+
+
+ {/* Hero */}
+
+
+ Monetize your MCP in one command
+
+
+ Public endpoint. Automatic revenue. No infrastructure.
+
+ Create Your Smart Account
+
+ A non-custodial Squads account that lets you control API spending
+
+
+
+
+ {/* How it works */}
+
+
+ How it works
+
+
+
+
+ 1
+
+
+
Create Account
+
+ Creates a Squads smart account with you as the sole owner
+
+
+
+
+
+ 2
+
+
+
Deposit USDC
+
+ Fund your vault with USDC from your wallet
+
+
+
+
+
+ 3
+
+
+
Set Spending Limit
+
+ Configure daily limits to get your API key
+
+
+
+
+
+ 4
+
+
+
Use API Key
+
+ Third-party services can charge within your limits
+
+
+
+
+
+
+ {/* CTA */}
+
+
+
+
+ You'll always retain full control. Withdraw anytime.
+