diff --git a/app/api/rpc/solana/route.ts b/app/api/rpc/solana/route.ts deleted file mode 100644 index f048c4a..0000000 --- a/app/api/rpc/solana/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(req: NextRequest) { - const rpcUrl = process.env.NEXT_PUBLIC_SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com"; - - try { - const body = await req.json(); - - const response = await fetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - return NextResponse.json({ error: "Upstream error", details: response.statusText }, { status: response.status }); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - console.error("RPC Proxy Error:", error); - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); - } -} diff --git a/app/api/v1/agents/balance/route.ts b/app/api/v1/agents/balance/route.ts index 339a053..cde8381 100644 --- a/app/api/v1/agents/balance/route.ts +++ b/app/api/v1/agents/balance/route.ts @@ -50,19 +50,15 @@ export async function GET(request: NextRequest) { // Get balances const balances = await getAgentUSDCBalance(agent.id); - const solanaBalance = parseFloat(balances.solana); const baseBalance = parseFloat(balances.base); - const totalBalance = solanaBalance + baseBalance; return NextResponse.json({ success: true, balances: { - solana_usdc: balances.solana, base_usdc: balances.base, - total_usdc: totalBalance.toFixed(6), + total_usdc: baseBalance.toFixed(6), }, addresses: { - solana: addresses.solana, base: addresses.base, }, note: 'Balances shown in USDC (6 decimals). 4.1% rewards earned on Base USDC balances.', diff --git a/app/api/v1/author-split/[authorId]/route.ts b/app/api/v1/author-split/[authorId]/route.ts new file mode 100644 index 0000000..61d7ad7 --- /dev/null +++ b/app/api/v1/author-split/[authorId]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin } from '@/lib/db/supabase-server'; +import { getOrCreateAuthorSplit } from '@/lib/splits'; + +/** + * GET /api/v1/author-split/:authorId + * + * Returns the 0xSplits PushSplit contract address for an author. + * Creates a new split on-chain if one doesn't exist yet. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ authorId: string }> } +) { + try { + const { authorId } = await params; + + // Fetch author's Base wallet + const { data: author } = await supabaseAdmin + .from('agents') + .select('id, wallet_base, agentkit_wallet_address_base') + .eq('id', authorId) + .single(); + + if (!author) { + return NextResponse.json({ error: 'Author not found' }, { status: 404 }); + } + + const authorWallet = author.agentkit_wallet_address_base || author.wallet_base; + if (!authorWallet) { + return NextResponse.json({ error: 'Author has no Base wallet' }, { status: 400 }); + } + + const splitAddress = await getOrCreateAuthorSplit({ + authorId: author.id, + authorAddress: authorWallet, + }); + + return NextResponse.json({ split_address: splitAddress }); + } catch (error) { + console.error('Error getting author split:', error); + return NextResponse.json( + { error: 'Failed to get or create split' }, + { status: 500 } + ); + } +} diff --git a/app/api/v1/check-access/route.ts b/app/api/v1/check-access/route.ts new file mode 100644 index 0000000..a8da48d --- /dev/null +++ b/app/api/v1/check-access/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin } from '@/lib/db/supabase-server'; + +/** + * GET /api/v1/check-access?post_id=X&payer_address=Y + * + * Check if a payer has permanent access to a paid article. + * Used by the frontend to determine whether to show paywall. + */ +export async function GET(request: NextRequest) { + const postId = request.nextUrl.searchParams.get('post_id'); + const payerAddress = request.nextUrl.searchParams.get('payer_address'); + + if (!postId || !payerAddress) { + return NextResponse.json({ hasAccess: false }, { status: 200 }); + } + + try { + const { data } = await supabaseAdmin + .from('article_access') + .select('id') + .eq('post_id', postId) + .eq('payer_address', payerAddress.toLowerCase()) + .single(); + + return NextResponse.json({ hasAccess: !!data }, { status: 200 }); + } catch { + return NextResponse.json({ hasAccess: false }, { status: 200 }); + } +} diff --git a/app/api/v1/post/[id]/route.ts b/app/api/v1/post/[id]/route.ts index 51ce6ee..335812c 100644 --- a/app/api/v1/post/[id]/route.ts +++ b/app/api/v1/post/[id]/route.ts @@ -9,37 +9,11 @@ * - Implements x402 protocol for paid content * * Flow for paid posts: - * 1. First request without payment → 402 with payment_options - * 2. Client makes payment on-chain (Solana/Base) + * 1. First request without payment → check persistent access → 402 with payment_options + * 2. Client makes payment on-chain (Base USDC) * 3. Second request with X-Payment-Proof header → 200 with content * * @see claude/knowledge/prd.md Section 2.2 (x402 Payment Flow) - * @see claude/operations/tasks.md Tasks 1.6.1-1.6.6, 2.3.5, 2.3.10 - * - * Response (200 OK - Free Post or Verified Payment): - * { - * "post": { - * "id": "uuid", - * "title": "My Article", - * "content": "...", - * ... - * } - * } - * - * Response (402 Payment Required - Paid Post): - * { - * "error": "payment_required", - * "resource_id": "uuid", - * "price_usdc": "0.25", - * "valid_until": "2026-02-03T12:30:00Z", - * "payment_options": [{ chain: "solana", ... }], - * "preview": { ... } - * } - * - * Errors: - * - 402: Payment required (with verification failure details if proof invalid) - * - 404: Post not found - * - 500: Internal server error */ import { NextRequest, NextResponse } from 'next/server'; @@ -91,10 +65,46 @@ interface AuthorWithWallets { id: string; display_name: string; avatar_url: string | null; - wallet_solana: string | null; wallet_base: string | null; } +/** + * Check if a payer has persistent access to a post + */ +async function checkPersistentAccess( + postId: string, + payerAddress: string +): Promise { + try { + const { data } = await supabaseAdmin + .from('article_access') + .select('id') + .eq('post_id', postId) + .eq('payer_address', payerAddress.toLowerCase()) + .single(); + return !!data; + } catch { + return false; + } +} + +/** + * Get the split address for an author (if one exists) + */ +async function getAuthorSplitAddress(authorId: string): Promise { + try { + const { data } = await supabaseAdmin + .from('author_splits') + .select('split_address') + .eq('author_id', authorId) + .eq('chain', 'base') + .single(); + return data?.split_address || null; + } catch { + return null; + } +} + /** * GET handler for retrieving posts by ID or slug */ @@ -106,7 +116,7 @@ export async function GET( const { id: postIdentifier } = await params; // ================================================================ - // Query based on ID or Slug (Tasks 1.6.2, 1.6.3) + // Query based on ID or Slug // ================================================================ const isUuid = UUID_PATTERN.test(postIdentifier); @@ -117,7 +127,7 @@ export async function GET( ` id, title, content, summary, tags, is_paid, price_usdc, view_count, paid_view_count, status, published_at, author_id, - author:agents!posts_author_id_fkey(id, display_name, avatar_url, wallet_solana, wallet_base) + author:agents!posts_author_id_fkey(id, display_name, avatar_url, wallet_base) ` ) .eq('status', 'published'); @@ -133,7 +143,7 @@ export async function GET( const { data: post, error } = await query.single(); // ================================================================ - // Handle Not Found (Task 1.6.2) + // Handle Not Found // ================================================================ if (error || !post) { return NextResponse.json( @@ -154,9 +164,8 @@ export async function GET( } // ================================================================ - // Increment View Count (Task 1.6.6) + // Increment View Count // ================================================================ - // Fire-and-forget for non-critical view count increment void (async () => { try { await supabaseAdmin @@ -169,7 +178,7 @@ export async function GET( })(); // ================================================================ - // Return Free Posts Immediately (Task 1.6.4) + // Return Free Posts Immediately // ================================================================ if (!post.is_paid) { const response: GetPostResponse = { @@ -195,14 +204,49 @@ export async function GET( } // ================================================================ - // Handle Paid Posts - Check for Payment Proof (Tasks 2.3.5, 2.3.10) + // Check Persistent Access (before requiring payment) + // ================================================================ + const payerAddress = request.headers.get('X-Payer-Address'); + if (payerAddress) { + const hasAccess = await checkPersistentAccess(post.id, payerAddress); + if (hasAccess) { + const response: GetPostResponse = { + post: { + id: post.id, + title: post.title, + content: post.content, + summary: post.summary, + tags: post.tags || [], + is_paid: true, + price_usdc: post.price_usdc?.toFixed(2) || null, + view_count: post.view_count || 0, + published_at: post.published_at, + author: { + id: author.id, + display_name: author.display_name, + avatar_url: author.avatar_url, + }, + }, + }; + + return NextResponse.json(response, { + status: 200, + headers: { + [X402_CONFIG.HEADERS.VERSION]: X402_CONFIG.PROTOCOL_VERSION, + 'X-Access-Type': 'persistent', + }, + }); + } + } + + // ================================================================ + // Handle Paid Posts - Check for Payment Proof // ================================================================ const proofHeader = request.headers.get(X402_CONFIG.HEADERS.PROOF); const proof = parsePaymentProof(proofHeader); // If payment proof provided, verify it if (proof) { - // Build post object for verification const postForPayment: PostForPayment = { id: post.id, title: post.title, @@ -216,7 +260,6 @@ export async function GET( id: author.id, display_name: author.display_name, avatar_url: author.avatar_url, - wallet_solana: author.wallet_solana, wallet_base: author.wallet_base, }, }; @@ -224,9 +267,7 @@ export async function GET( const verificationResult = await verifyPayment(proof, postForPayment); if (verificationResult.success) { - // ================================================================ - // Task 2.3.9: Record Payment Event - // ================================================================ + // Record Payment Event (also grants persistent access and triggers split distribution) const paymentEventId = await recordPaymentEvent( proof, postForPayment, @@ -234,7 +275,6 @@ export async function GET( ); if (paymentEventId) { - // Increment paid view count void (async () => { try { await supabaseAdmin @@ -247,9 +287,6 @@ export async function GET( })(); } - // ================================================================ - // Task 2.3.10: Return Content After Successful Payment - // ================================================================ const response: GetPostResponse = { post: { id: post.id, @@ -280,7 +317,8 @@ export async function GET( } // Payment verification failed - return error with payment options - const paymentOptions = buildPaymentOptions(post.id, ['solana', 'base']); + const splitAddress = await getAuthorSplitAddress(post.author_id); + const paymentOptions = buildPaymentOptions(post.id, ['base'], splitAddress || undefined); const paymentResponse: PaymentRequiredResponse = { error: 'payment_required', @@ -317,10 +355,10 @@ export async function GET( } // ================================================================ - // Task 2.3.5: Return 402 with Payment Options + // Return 402 with Payment Options (Base only, with split address) // ================================================================ - // No payment proof provided - return 402 with payment options - const paymentOptions = buildPaymentOptions(post.id, ['solana', 'base']); + const splitAddress = await getAuthorSplitAddress(post.author_id); + const paymentOptions = buildPaymentOptions(post.id, ['base'], splitAddress || undefined); const paymentResponse: PaymentRequiredResponse = { error: 'payment_required', diff --git a/app/api/v1/verify-payment/route.ts b/app/api/v1/verify-payment/route.ts index 50b4e6c..022b6c2 100644 --- a/app/api/v1/verify-payment/route.ts +++ b/app/api/v1/verify-payment/route.ts @@ -26,7 +26,7 @@ import { createErrorResponse, formatZodErrors } from '@/types/api'; // ============================================ const verifyPaymentSchema = z.object({ - chain: z.enum(['solana', 'base']), + chain: z.literal('base'), transaction_signature: z.string().min(1), payer_address: z.string().min(1), resource_type: z.enum(['post', 'spam_fee']), @@ -141,7 +141,6 @@ export async function POST(request: NextRequest): Promise { id, display_name, avatar_url, - wallet_solana, wallet_base ) ` diff --git a/app/author/[id]/page.tsx b/app/author/[id]/page.tsx index 7569461..e1776cd 100644 --- a/app/author/[id]/page.tsx +++ b/app/author/[id]/page.tsx @@ -226,10 +226,7 @@ async function AuthorContent({ id }: { id: string }) { {/* Wallet Addresses */} - {(author.agentkit_wallet_address_solana || - author.wallet_solana || - author.agentkit_wallet_address_base || - author.wallet_base) && ( + {(author.agentkit_wallet_address_base || author.wallet_base) && (

- Payment Wallets + Payment Wallet

- {(author.agentkit_wallet_address_solana || - author.wallet_solana) && ( - - )} - {(author.agentkit_wallet_address_base || author.wallet_base) && ( - - )} +
)} diff --git a/app/post/[id]/page.tsx b/app/post/[id]/page.tsx index 97c23c0..b969c30 100644 --- a/app/post/[id]/page.tsx +++ b/app/post/[id]/page.tsx @@ -33,9 +33,7 @@ async function fetchPost(idOrSlug: string) { *, author:agents!posts_author_id_fkey( *, - wallet_solana, wallet_base, - agentkit_wallet_address_solana, agentkit_wallet_address_base, wallet_provider ) @@ -84,16 +82,12 @@ async function PostContent({ id }: { id: string }) { display_name: 'Unknown Author', bio: null, avatar_url: null, - wallet_solana: null, wallet_base: null, reputation_tier: 'new', is_human: false, }; - // Compute wallet addresses with AgentKit fallback - // Prioritize AgentKit wallets if available, otherwise use self-custodied - const authorWalletSolana = - author.agentkit_wallet_address_solana || author.wallet_solana; + // Compute wallet address with AgentKit fallback const authorWalletBase = author.agentkit_wallet_address_base || author.wallet_base; @@ -190,7 +184,7 @@ async function PostContent({ id }: { id: string }) { title: post.title, priceUsdc: post.price_usdc, previewContent: post.summary || '', - authorWalletSolana, + authorId: author.id, authorWalletBase, }} isPurchased={hasAccess} @@ -208,8 +202,8 @@ async function PostContent({ id }: { id: string }) { title={post.title} priceUsdc={post.price_usdc || 0} previewContent={post.summary || ''} - authorWalletSolana={authorWalletSolana} - authorWalletBase={authorWalletBase} + authorId={author.id} + recipientAddress={authorWalletBase || process.env.BASE_TREASURY_ADDRESS || '0xF1F9448354F99fAe1D29A4c82DC839c16e72AfD5'} /> )} diff --git a/components/features/PaymentModal.tsx b/components/features/PaymentModal.tsx index 83b0dcd..aff4c6c 100644 --- a/components/features/PaymentModal.tsx +++ b/components/features/PaymentModal.tsx @@ -7,7 +7,6 @@ import { createContext, useContext, } from 'react'; -import { SolanaPaymentFlow } from './SolanaPaymentFlow'; import { EVMPaymentFlow } from './EVMPaymentFlow'; import { PrivyPaymentFlow } from './PrivyPaymentFlow'; @@ -17,7 +16,7 @@ export interface PaymentPostData { title: string; priceUsdc: number; previewContent: string; - authorWalletSolana: string | null; + authorId: string; authorWalletBase: string | null; } @@ -74,60 +73,15 @@ export function PaymentModalProvider({ children }: PaymentModalProviderProps) { ); } -// Chain configuration -type PaymentChain = 'solana' | 'base' | null; type PaymentMethod = 'privy' | 'external' | null; -const CHAIN_PREFERENCE_KEY = 'clawstack_preferred_chain'; - -const CHAIN_CONFIG = { - solana: { - id: 'solana' as const, - name: 'Solana', - icon: '◎', - color: '#9945FF', - bgColor: 'bg-[#9945FF]/10', - hoverBg: 'hover:bg-[#9945FF]/5', - hoverBorder: 'hover:border-[#9945FF]/50', - networkLabel: 'on Solana', - }, - base: { - id: 'base' as const, - name: 'Base', - icon: 'Ⓑ', - color: '#0052FF', - bgColor: 'bg-[#0052FF]/10', - hoverBg: 'hover:bg-[#0052FF]/5', - hoverBorder: 'hover:border-[#0052FF]/50', - networkLabel: 'on Base', - }, +const BASE_CHAIN_CONFIG = { + name: 'Base', + icon: 'Ⓑ', + color: '#0052FF', + networkLabel: 'on Base', }; -// Treasury addresses - payments go to treasury, not directly to authors -// These are public blockchain addresses, safe to hardcode as defaults -const TREASURY_ADDRESSES = { - solana: - process.env.NEXT_PUBLIC_SOLANA_TREASURY_PUBKEY || - 'HTtKB78L63MBkdMiv6Vcmo4E2eUFHiwugYoU669TPKbn', - base: - process.env.NEXT_PUBLIC_BASE_TREASURY_ADDRESS || - '0xF1F9448354F99fAe1D29A4c82DC839c16e72AfD5', -} as const; - -function getPreferredChain(): PaymentChain { - if (typeof window === 'undefined') return null; - const stored = localStorage.getItem(CHAIN_PREFERENCE_KEY); - if (stored === 'solana' || stored === 'base') { - return stored; - } - return null; -} - -function setPreferredChain(chain: PaymentChain): void { - if (typeof window === 'undefined' || !chain) return; - localStorage.setItem(CHAIN_PREFERENCE_KEY, chain); -} - interface PaymentModalDialogProps { isOpen: boolean; postData: PaymentPostData | null; @@ -139,12 +93,12 @@ function PaymentModalDialog({ postData, onClose, }: PaymentModalDialogProps) { - const [selectedChain, setSelectedChain] = useState(null); const [paymentMethod, setPaymentMethod] = useState(null); const [paymentSuccess, setPaymentSuccess] = useState(false); const [paymentError, setPaymentError] = useState(null); - const [rememberPreference, setRememberPreference] = useState(true); const [isAnimating, setIsAnimating] = useState(false); + const [splitAddress, setSplitAddress] = useState(null); + const [splitLoading, setSplitLoading] = useState(false); // Prevent body scroll when modal is open useEffect(() => { @@ -172,47 +126,41 @@ function PaymentModalDialog({ return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose, paymentSuccess]); - // Load saved chain preference on mount - // Initialize preferred chain when modal opens - valid initialization pattern + // Fetch split address when modal opens useEffect(() => { - if (isOpen && postData) { - const preferred = getPreferredChain(); - const availableChains = [ - postData.authorWalletSolana ? 'solana' : null, - postData.authorWalletBase ? 'base' : null, - ].filter(Boolean); - - if (preferred && availableChains.includes(preferred)) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedChain(preferred); - } + if (isOpen && postData?.authorId) { + setSplitLoading(true); + fetch(`/api/v1/author-split/${postData.authorId}`) + .then((res) => res.json()) + .then((data) => { + if (data.split_address) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSplitAddress(data.split_address); + } + }) + .catch((err) => { + console.error('Failed to fetch split address:', err); + }) + .finally(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSplitLoading(false); + }); } - }, [isOpen, postData]); + }, [isOpen, postData?.authorId]); // Reset state when modal closes - valid cleanup pattern useEffect(() => { if (!isOpen) { /* eslint-disable react-hooks/set-state-in-effect */ - setSelectedChain(null); setPaymentMethod(null); setPaymentSuccess(false); setPaymentError(null); setIsAnimating(false); + setSplitAddress(null); /* eslint-enable react-hooks/set-state-in-effect */ } }, [isOpen]); - const handleChainSelect = useCallback( - (chainId: 'solana' | 'base') => { - setSelectedChain(chainId); - setPaymentError(null); - if (rememberPreference) { - setPreferredChain(chainId); - } - }, - [rememberPreference] - ); - const handlePaymentSuccess = useCallback(() => { setIsAnimating(true); setPaymentSuccess(true); @@ -227,15 +175,9 @@ function PaymentModalDialog({ }, []); const handleBack = useCallback(() => { - if (paymentMethod !== null) { - // Go back to payment method selection - setPaymentMethod(null); - } else { - // Go back to chain selection - setSelectedChain(null); - } + setPaymentMethod(null); setPaymentError(null); - }, [paymentMethod]); + }, []); const handlePaymentMethodSelect = useCallback( (method: 'privy' | 'external') => { @@ -251,18 +193,13 @@ function PaymentModalDialog({ if (!isOpen || !postData) return null; - const { postId, title, priceUsdc, authorWalletSolana, authorWalletBase } = - postData; + const { postId, title, priceUsdc } = postData; - const allChains = [ - { ...CHAIN_CONFIG.solana, wallet: authorWalletSolana }, - { ...CHAIN_CONFIG.base, wallet: authorWalletBase }, - ]; - - const availableChains = allChains.filter((chain) => chain.wallet !== null); - const selectedChainConfig = selectedChain - ? CHAIN_CONFIG[selectedChain] - : null; + // Use split address if available, otherwise fall back to treasury + const recipientAddress = + splitAddress || + process.env.NEXT_PUBLIC_BASE_TREASURY_ADDRESS || + '0xF1F9448354F99fAe1D29A4c82DC839c16e72AfD5'; return (
@@ -388,18 +325,16 @@ function PaymentModalDialog({ ${priceUsdc.toFixed(2)}{' '} USDC

- {selectedChainConfig && ( - - {selectedChainConfig.icon} - {selectedChainConfig.networkLabel} - - )} + + {BASE_CHAIN_CONFIG.icon} + {BASE_CHAIN_CONFIG.networkLabel} +
{/* Access Duration Badge */} @@ -473,266 +408,166 @@ function PaymentModalDialog({ )} - {/* Chain Selection → Payment Method → Payment Flow */} - {availableChains.length > 0 ? ( - selectedChain === null ? ( - // Step 1: Chain Selection -
-

- Choose your payment network: -

-
- {availableChains.map((chain) => { - const config = CHAIN_CONFIG[chain.id]; - return ( - - ); - })} -
- -
- ) : paymentMethod === null ? ( - // Step 2: Payment Method Selection -
+ {/* Split address loading */} + {splitLoading ? ( +
+

+ Setting up payment... +

+
+ ) : paymentMethod === null ? ( + // Payment Method Selection (skip chain selection - Base only) +
+

+ How would you like to pay? +

+
+ {/* Use Account Funds (Privy) */} -

- How would you like to pay? -

-
- {/* Use Account Funds (Privy) */} - - - {/* Connect External Wallet */} - -
-
- ) : paymentMethod === 'privy' && selectedChain ? ( - // Step 3a: Privy Payment Flow -
-
+
+

Use Account Funds

+

+ Pay from your ClawStack wallet +

+
- + - Back - -
- ) : paymentMethod === 'external' && - selectedChain === 'solana' && - authorWalletSolana ? ( - // Step 3b: External Solana Wallet -
+ + {/* Connect External Wallet */} - -
- ) : paymentMethod === 'external' && - selectedChain === 'base' && - authorWalletBase ? ( - // Step 3b: External EVM Wallet -
-
+
+

Connect Wallet

+

+ Use Base wallet (MetaMask, Coinbase Wallet, etc.) +

+
- + - Back -
- ) : null - ) : ( -
-

- Author has not configured payment wallets yet. -

- )} + ) : paymentMethod === 'privy' ? ( + // Privy Payment Flow +
+ + +
+ ) : paymentMethod === 'external' ? ( + // External EVM Wallet +
+ + +
+ ) : null} {/* Trust Indicators */}
diff --git a/components/features/PaywallModal.tsx b/components/features/PaywallModal.tsx index 32cc371..69c2c60 100644 --- a/components/features/PaywallModal.tsx +++ b/components/features/PaywallModal.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; -import { SolanaPaymentFlow } from './SolanaPaymentFlow'; +import { useState, useCallback } from 'react'; import { EVMPaymentFlow } from './EVMPaymentFlow'; interface PaywallModalProps { @@ -9,58 +8,8 @@ interface PaywallModalProps { title: string; priceUsdc: number; previewContent: string; - authorWalletSolana: string | null; - authorWalletBase: string | null; -} - -type PaymentChain = 'solana' | 'base' | null; - -const CHAIN_PREFERENCE_KEY = 'clawstack_preferred_chain'; - -// Chain metadata with display info -const CHAIN_CONFIG = { - solana: { - id: 'solana' as const, - name: 'Solana', - icon: '◎', - color: '#9945FF', - bgColor: 'bg-[#9945FF]/10', - borderColor: 'border-[#9945FF]', - hoverBg: 'hover:bg-[#9945FF]/5', - hoverBorder: 'hover:border-[#9945FF]/50', - networkLabel: 'on Solana', - }, - base: { - id: 'base' as const, - name: 'Base', - icon: 'Ⓑ', - color: '#0052FF', - bgColor: 'bg-[#0052FF]/10', - borderColor: 'border-[#0052FF]', - hoverBg: 'hover:bg-[#0052FF]/5', - hoverBorder: 'hover:border-[#0052FF]/50', - networkLabel: 'on Base', - }, -}; - -/** - * Get the user's preferred payment chain from localStorage - */ -function getPreferredChain(): PaymentChain { - if (typeof window === 'undefined') return null; - const stored = localStorage.getItem(CHAIN_PREFERENCE_KEY); - if (stored === 'solana' || stored === 'base') { - return stored; - } - return null; -} - -/** - * Save the user's preferred payment chain to localStorage - */ -function setPreferredChain(chain: PaymentChain): void { - if (typeof window === 'undefined' || !chain) return; - localStorage.setItem(CHAIN_PREFERENCE_KEY, chain); + authorId: string; + recipientAddress: string; } export function PaywallModal({ @@ -68,78 +17,30 @@ export function PaywallModal({ title, priceUsdc, previewContent, - authorWalletSolana, - authorWalletBase, + recipientAddress, }: PaywallModalProps) { - const [selectedChain, setSelectedChain] = useState(null); const [paymentSuccess, setPaymentSuccess] = useState(false); const [paymentError, setPaymentError] = useState(null); - const [rememberPreference, setRememberPreference] = useState(true); const [isAnimating, setIsAnimating] = useState(false); - const allChains = [ - { ...CHAIN_CONFIG.solana, wallet: authorWalletSolana }, - { ...CHAIN_CONFIG.base, wallet: authorWalletBase }, - ]; - - const availableChains = allChains.filter((chain) => chain.wallet !== null); - - // 5.4.5: Load saved chain preference on mount - useEffect(() => { - const preferred = getPreferredChain(); - // Only auto-select if the preferred chain is available for this author - if (preferred) { - const isAvailable = availableChains.some((c) => c.id === preferred); - if (isAvailable) { - setSelectedChain(preferred); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleChainSelect = useCallback( - (chainId: 'solana' | 'base') => { - setSelectedChain(chainId); - setPaymentError(null); - // 5.4.5: Save preference if user opted in - if (rememberPreference) { - setPreferredChain(chainId); - } - }, - [rememberPreference] - ); - const handlePaymentSuccess = useCallback(() => { - // 5.4.6: Trigger success animation setIsAnimating(true); setPaymentSuccess(true); - // Delay reload to show animation setTimeout(() => { window.location.reload(); }, 1500); }, []); - // 5.4.4: Graceful error handling with UI feedback const handlePaymentError = useCallback((error: string) => { console.error('Payment error:', error); setPaymentError(error); }, []); - const handleBack = useCallback(() => { - setSelectedChain(null); - setPaymentError(null); - }, []); - const handleDismissError = useCallback(() => { setPaymentError(null); }, []); - // Get the selected chain config for display - const selectedChainConfig = selectedChain - ? CHAIN_CONFIG[selectedChain] - : null; - - // 5.4.6: Success state with animation + // Success state with animation if (paymentSuccess) { return (
@@ -154,7 +55,6 @@ export function PaywallModal({ isAnimating ? 'animate-success-bounce' : '' }`} > - {/* Animated checkmark with ring effect */}
Unlocking content...

- {/* Loading indicator */}
{/* Paywall Card */}
- {/* Header - always show price with chain context (5.4.2) */} + {/* Header */}
USDC

- {/* 5.4.2: Show chain context when selected */} - {selectedChainConfig && ( -
- {selectedChainConfig.icon} - {selectedChainConfig.networkLabel} - - )} + + + on Base +
- {/* 5.4.4: Error display */} + {/* Error display */} {paymentError && (
@@ -332,124 +228,14 @@ export function PaywallModal({
)} - {/* Chain Selection or Payment Flow */} - {availableChains.length > 0 ? ( - selectedChain === null ? ( - // 5.4.1: Enhanced Chain Selection -
-

- Choose your payment network: -

-
- {availableChains.map((chain) => { - const config = CHAIN_CONFIG[chain.id]; - return ( - - ); - })} -
- {/* 5.4.5: Remember preference checkbox */} - -
- ) : selectedChain === 'solana' && authorWalletSolana ? ( - // Solana Payment Flow -
- - -
- ) : selectedChain === 'base' && authorWalletBase ? ( - // Base/EVM Payment Flow -
- - -
- ) : null - ) : ( -
-

- Author has not configured payment wallets yet. -

-
- )} + {/* Base EVM Payment Flow */} + {/* Trust Indicators */}
@@ -502,7 +288,7 @@ export function PaywallModal({ - 24h Access + Unlimited Access
diff --git a/components/features/PrivyPaymentFlow.tsx b/components/features/PrivyPaymentFlow.tsx index e0c8d4c..19db557 100644 --- a/components/features/PrivyPaymentFlow.tsx +++ b/components/features/PrivyPaymentFlow.tsx @@ -2,33 +2,15 @@ import { useCallback, useEffect, useState } from 'react'; import { usePrivy, useSendTransaction } from '@privy-io/react-auth'; -import { - useWallets as useSolanaWallets, - useSignAndSendTransaction, -} from '@privy-io/react-auth/solana'; import { Button } from '@/components/ui/button'; -import { - createPaymentProof, - submitPaymentProof, - storePaymentProof, -} from '@/lib/solana'; import { createEVMPaymentProof, submitEVMPaymentProof, storeEVMPaymentProof, } from '@/lib/evm/payment-proof'; -import { Connection, PublicKey, Transaction } from '@solana/web3.js'; -import { - getAssociatedTokenAddress, - createTransferInstruction, - TOKEN_PROGRAM_ID, -} from '@solana/spl-token'; import { parseUnits, encodeFunctionData } from 'viem'; // USDC Constants -const SOLANA_USDC_MINT = new PublicKey( - 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' -); const BASE_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const USDC_DECIMALS = 6; @@ -50,7 +32,6 @@ interface PrivyPaymentFlowProps { postId: string; priceUsdc: number; recipientAddress: string; - chain: 'solana' | 'base'; onSuccess: () => void; onError: (error: string) => void; } @@ -65,22 +46,21 @@ type FlowStep = | 'success' | 'error'; +const CHAIN_COLOR = '#0052FF'; + /** * Privy-based payment flow using embedded wallet - * Supports both Solana and Base (EVM) USDC payments + * Supports Base (EVM) USDC payments only */ export function PrivyPaymentFlow({ postId, priceUsdc, recipientAddress, - chain, onSuccess, onError, }: PrivyPaymentFlowProps) { const { user, authenticated, login } = usePrivy(); const { sendTransaction } = useSendTransaction(); - const { wallets: solanaWallets } = useSolanaWallets(); - const { signAndSendTransaction } = useSignAndSendTransaction(); const [step, setStep] = useState('checking-wallet'); const [errorMessage, setErrorMessage] = useState(null); @@ -88,35 +68,22 @@ export function PrivyPaymentFlow({ const [balanceLoading, setBalanceLoading] = useState(true); const [txSignature, setTxSignature] = useState(null); - // Get the appropriate wallet address based on chain + // Get the EVM wallet address const getWalletAddress = useCallback(() => { if (!user) return null; - if (chain === 'solana') { - // Check Privy Solana wallets from the hook - const solWallet = solanaWallets.find((w) => w.address); - if (solWallet) return solWallet.address; - - // Fallback to linked accounts - const linkedSol = user.linkedAccounts.find( - (a) => a.type === 'wallet' && a.chainType === 'solana' - ) as { address: string } | undefined; - return linkedSol?.address || null; - } else { - // EVM wallet - const ethWallet = - user.wallet && user.wallet.chainType === 'ethereum' - ? user.wallet - : (user.linkedAccounts.find( - (a) => a.type === 'wallet' && a.chainType === 'ethereum' - ) as { address: string } | undefined); - return ethWallet?.address || null; - } - }, [user, chain, solanaWallets]); + const ethWallet = + user.wallet && user.wallet.chainType === 'ethereum' + ? user.wallet + : (user.linkedAccounts.find( + (a) => a.type === 'wallet' && a.chainType === 'ethereum' + ) as { address: string } | undefined); + return ethWallet?.address || null; + }, [user]); const walletAddress = getWalletAddress(); - // Fetch balance + // Fetch USDC balance on Base useEffect(() => { async function fetchBalance() { if (!walletAddress) { @@ -126,49 +93,29 @@ export function PrivyPaymentFlow({ setBalanceLoading(true); try { - if (chain === 'solana') { - const rpcUrl = `${window.location.origin}/api/rpc/solana`; - const connection = new Connection(rpcUrl, 'confirmed'); - const pubKey = new PublicKey(walletAddress); - const usdcAta = await getAssociatedTokenAddress( - SOLANA_USDC_MINT, - pubKey - ); - - try { - const accountInfo = - await connection.getTokenAccountBalance(usdcAta); - setBalance(Number(accountInfo.value.uiAmount) || 0); - } catch { - // No USDC account - setBalance(0); - } - } else { - // Base USDC balance - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_RPC_URL || 'https://mainnet.base.org'}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'eth_call', - params: [ - { - to: BASE_USDC_ADDRESS, - data: `0x70a08231000000000000000000000000${walletAddress.slice(2)}`, - }, - 'latest', - ], - }), - } - ); - const data = await response.json(); - if (data.result) { - const rawBalance = BigInt(data.result); - setBalance(Number(rawBalance) / 10 ** USDC_DECIMALS); + const response = await fetch( + `${process.env.NEXT_PUBLIC_BASE_RPC_URL || 'https://mainnet.base.org'}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_call', + params: [ + { + to: BASE_USDC_ADDRESS, + data: `0x70a08231000000000000000000000000${walletAddress.slice(2)}`, + }, + 'latest', + ], + }), } + ); + const data = await response.json(); + if (data.result) { + const rawBalance = BigInt(data.result); + setBalance(Number(rawBalance) / 10 ** USDC_DECIMALS); } } catch (error) { console.error('Error fetching balance:', error); @@ -179,7 +126,7 @@ export function PrivyPaymentFlow({ } fetchBalance(); - }, [walletAddress, chain]); + }, [walletAddress]); // Update step based on state useEffect(() => { @@ -203,115 +150,6 @@ export function PrivyPaymentFlow({ } }, [authenticated, walletAddress, balanceLoading, step]); - // Handle Solana payment - const handleSolanaPayment = useCallback(async () => { - if (!walletAddress) return; - - setStep('paying'); - - try { - const rpcUrl = `${window.location.origin}/api/rpc/solana`; - const connection = new Connection(rpcUrl, 'confirmed'); - - // Get a Solana wallet from Privy - const privyWallet = solanaWallets.find((w) => w.address); - if (!privyWallet) { - throw new Error( - 'Solana wallet not found. Please ensure you have a Solana wallet connected.' - ); - } - - // Create USDC transfer instruction - const senderPubkey = new PublicKey(walletAddress); - const recipientPubkey = new PublicKey(recipientAddress); - - const senderAta = await getAssociatedTokenAddress( - SOLANA_USDC_MINT, - senderPubkey - ); - const recipientAta = await getAssociatedTokenAddress( - SOLANA_USDC_MINT, - recipientPubkey - ); - - const amount = Math.floor(priceUsdc * 10 ** USDC_DECIMALS); - - const transferIx = createTransferInstruction( - senderAta, - recipientAta, - senderPubkey, - amount, - [], - TOKEN_PROGRAM_ID - ); - - // Add memo for tracking - const memoIx = { - keys: [], - programId: new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), - data: Buffer.from( - `clawstack:${postId}:${Math.floor(Date.now() / 1000)}` - ), - }; - - const { blockhash, lastValidBlockHeight } = - await connection.getLatestBlockhash(); - - const transaction = new Transaction({ - feePayer: senderPubkey, - blockhash, - lastValidBlockHeight, - }).add(transferIx, memoIx); - - setStep('confirming'); - - // Sign and send with Privy - const serializedTx = transaction.serialize({ - requireAllSignatures: false, - verifySignatures: false, - }); - - const result = await signAndSendTransaction({ - transaction: serializedTx, - wallet: privyWallet, - }); - - // Convert to hex for Solana explorer - const signatureHex = Buffer.from(result.signature).toString('hex'); - setTxSignature(signatureHex); - - setStep('submitting'); - - // Create and submit proof - const proof = createPaymentProof(signatureHex, walletAddress); - storePaymentProof(postId, proof); - const submitResult = await submitPaymentProof(postId, proof); - - if (submitResult.success && submitResult.accessGranted) { - setStep('success'); - onSuccess(); - } else { - throw new Error(submitResult.error || 'Failed to verify payment'); - } - } catch (error) { - console.error('Solana payment error:', error); - setStep('error'); - setErrorMessage( - error instanceof Error ? error.message : 'Payment failed' - ); - onError(error instanceof Error ? error.message : 'Payment failed'); - } - }, [ - walletAddress, - recipientAddress, - priceUsdc, - postId, - solanaWallets, - signAndSendTransaction, - onSuccess, - onError, - ]); - // Handle Base/EVM payment const handleEVMPayment = useCallback(async () => { if (!walletAddress) return; @@ -378,12 +216,8 @@ export function PrivyPaymentFlow({ return; } - if (chain === 'solana') { - handleSolanaPayment(); - } else { - handleEVMPayment(); - } - }, [balance, priceUsdc, chain, handleSolanaPayment, handleEVMPayment]); + handleEVMPayment(); + }, [balance, priceUsdc, handleEVMPayment]); const handleRetry = useCallback(() => { setErrorMessage(null); @@ -391,8 +225,6 @@ export function PrivyPaymentFlow({ }, []); const canAfford = balance >= priceUsdc; - const chainColor = chain === 'solana' ? '#9945FF' : '#0052FF'; - const chainName = chain === 'solana' ? 'Solana' : 'Base'; return (
@@ -428,7 +260,7 @@ export function PrivyPaymentFlow({ : 'bg-muted text-muted-foreground' }`} style={ - isActive && !isComplete ? { backgroundColor: chainColor } : {} + isActive && !isComplete ? { backgroundColor: CHAIN_COLOR } : {} } > {isComplete ? '✓' : i + 1} @@ -450,16 +282,16 @@ export function PrivyPaymentFlow({ onClick={login} variant="outline" className="w-full" - style={{ borderColor: `${chainColor}30` }} + style={{ borderColor: `${CHAIN_COLOR}30` }} > Sign In with Privy ) : !walletAddress ? ( <> - +

- Setting up your {chainName} wallet... + Setting up your Base wallet...

) : null} @@ -469,7 +301,7 @@ export function PrivyPaymentFlow({ {/* Check Balance */} {step === 'check-balance' && (
- +

Checking your USDC balance...

@@ -504,7 +336,7 @@ export function PrivyPaymentFlow({ -
- )} - - {/* Check Balance Step */} - {step === "check-balance" && ( -
- -

- Checking your USDC balance... -

-
- )} - - {/* Ready Step */} - {step === "ready" && ( -
-
-
- Your Balance - ${balance.toFixed(2)} USDC -
-
- Price - - ${priceUsdc.toFixed(2)} USDC - -
-
- - {!canAfford && ( -
- Insufficient balance. You need ${(priceUsdc - balance).toFixed(2)}{" "} - more USDC. -
- )} - - -
- )} - - {/* Paying Step */} - {step === "paying" && ( -
- -

- {paymentState.status === "creating" - ? "Creating transaction..." - : "Please approve in your wallet..."} -

-
- )} - - {/* Confirming Step */} - {step === "confirming" && ( -
- -

- Confirming transaction on Solana... -

- {paymentState.signature && ( - - View on Solscan → - - )} -
- )} - - {/* Submitting Step */} - {step === "submitting" && ( -
- -

- Verifying payment and unlocking content... -

-
- )} - - {/* Success Step */} - {step === "success" && ( -
-
- - - -
-

Payment successful!

-

- Content is now unlocked. -

-
- )} - - {/* Error Step */} - {step === "error" && ( -
-
- {errorMessage || "An error occurred"} -
- -
- )} -
- ); -} - -function LoadingSpinner() { - return ( - - - - - ); -} diff --git a/components/features/SolanaWalletButton.tsx b/components/features/SolanaWalletButton.tsx deleted file mode 100644 index 8c8998d..0000000 --- a/components/features/SolanaWalletButton.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client'; - -import { useCallback, useMemo } from 'react'; -import { useWallet } from '@solana/wallet-adapter-react'; -import { useWalletModal } from '@solana/wallet-adapter-react-ui'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface SolanaWalletButtonProps { - className?: string; - size?: 'default' | 'sm' | 'lg'; -} - -/** - * Solana Wallet Connect Button - * Shows connect button when disconnected, shows address when connected. - */ -export function SolanaWalletButton({ - className, - size = 'default', -}: SolanaWalletButtonProps) { - const { publicKey, disconnect, connecting, connected } = useWallet(); - const { setVisible } = useWalletModal(); - - // Format the wallet address for display - const displayAddress = useMemo(() => { - if (!publicKey) return null; - const base58 = publicKey.toBase58(); - return `${base58.slice(0, 4)}...${base58.slice(-4)}`; - }, [publicKey]); - - const handleClick = useCallback(() => { - if (connected) { - disconnect(); - } else { - setVisible(true); - } - }, [connected, disconnect, setVisible]); - - if (connecting) { - return ( - - ); - } - - if (connected && displayAddress) { - return ( -
-
- - {displayAddress} -
- -
- ); - } - - return ( - - ); -} - -/** - * Compact version for use in headers/navbars - */ -export function SolanaWalletButtonCompact({ - className, -}: { - className?: string; -}) { - const { publicKey, disconnect, connecting, connected } = useWallet(); - const { setVisible } = useWalletModal(); - - const displayAddress = useMemo(() => { - if (!publicKey) return null; - const base58 = publicKey.toBase58(); - return `${base58.slice(0, 4)}...${base58.slice(-4)}`; - }, [publicKey]); - - const handleClick = useCallback(() => { - if (connected) { - disconnect(); - } else { - setVisible(true); - } - }, [connected, disconnect, setVisible]); - - return ( - - ); -} diff --git a/components/features/WalletAddressDisplay.tsx b/components/features/WalletAddressDisplay.tsx index fb03574..765d263 100644 --- a/components/features/WalletAddressDisplay.tsx +++ b/components/features/WalletAddressDisplay.tsx @@ -3,7 +3,7 @@ import { useState, useCallback } from 'react'; interface WalletAddressDisplayProps { - chain: 'solana' | 'base'; + chain: 'base'; address: string; } @@ -17,12 +17,6 @@ export function WalletAddressDisplay({ const [copied, setCopied] = useState(false); const chainConfig = { - solana: { - icon: '◎', - name: 'Solana', - bgColor: 'bg-[#9945FF]/10', - textColor: 'text-[#9945FF]', - }, base: { icon: 'Ⓑ', name: 'Base', diff --git a/components/providers/PrivySystemProvider.tsx b/components/providers/PrivySystemProvider.tsx index cb002c9..c3136ca 100644 --- a/components/providers/PrivySystemProvider.tsx +++ b/components/providers/PrivySystemProvider.tsx @@ -31,9 +31,6 @@ export function PrivySystemProvider({ children }: PrivySystemProviderProps) { ethereum: { createOnLogin: 'users-without-wallets', }, - solana: { - createOnLogin: 'users-without-wallets', - }, }, }} > diff --git a/components/providers/SolanaWalletProvider.tsx b/components/providers/SolanaWalletProvider.tsx deleted file mode 100644 index 1f0811c..0000000 --- a/components/providers/SolanaWalletProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useMemo, type ReactNode } from "react"; -import { - ConnectionProvider, - WalletProvider, -} from "@solana/wallet-adapter-react"; -import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; -import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets"; -import { clusterApiUrl } from "@solana/web3.js"; - -// Import default styles for the wallet modal -import "@solana/wallet-adapter-react-ui/styles.css"; - -interface SolanaWalletProviderProps { - children: ReactNode; -} - -/** - * Solana Wallet Provider - * Wraps children with Solana connection and wallet context. - * Configured for mainnet-beta by default, with Phantom wallet support. - */ -export function SolanaWalletProvider({ children }: SolanaWalletProviderProps) { - // Configure the network endpoint - // In production, use a dedicated RPC endpoint for better reliability - const endpoint = useMemo(() => { - const customRpc = process.env.NEXT_PUBLIC_SOLANA_RPC_URL; - if (customRpc) return customRpc; - // Default to mainnet-beta for production - return clusterApiUrl("mainnet-beta"); - }, []); - - // Configure supported wallets - // Phantom is the primary wallet for ClawStack - const wallets = useMemo( - () => [ - new PhantomWalletAdapter(), - // Additional wallets can be added here: - // new SolflareWalletAdapter(), - // new TorusWalletAdapter(), - ], - [] - ); - - return ( - - - {children} - - - ); -} diff --git a/components/providers/index.tsx b/components/providers/index.tsx index 58c0551..7c4256d 100644 --- a/components/providers/index.tsx +++ b/components/providers/index.tsx @@ -1,7 +1,6 @@ 'use client'; import { type ReactNode } from 'react'; -import { SolanaWalletProvider } from './SolanaWalletProvider'; import { EVMWalletProvider } from './EVMWalletProvider'; import { ProfileModalProvider } from '@/components/features/ProfileModal'; import { PaymentModalProvider } from '@/components/features/PaymentModal'; @@ -15,22 +14,19 @@ interface ProvidersProps { /** * Combined providers wrapper for the application * Includes all necessary context providers for wallet integrations: - * - Solana: @solana/wallet-adapter (Phantom) * - EVM/Base: wagmi (MetaMask, Coinbase Wallet) - * - AuthModal: Human authentication modal (Privy-ready) + * - Privy: Human authentication with embedded wallets */ export function Providers({ children }: ProvidersProps) { return ( - - - - {children} - - - - + + + {children} + + + ); diff --git a/lib/evm/verify.ts b/lib/evm/verify.ts index 63326ef..69b897a 100644 --- a/lib/evm/verify.ts +++ b/lib/evm/verify.ts @@ -213,21 +213,35 @@ export function findUsdcTransfer(transfers: Erc20Transfer[]): Erc20Transfer { // ============================================ /** - * Validate that the USDC transfer recipient matches the expected treasury address. + * Validate that the USDC transfer recipient matches an expected address. + * Accepts payments to the treasury or to a 0xSplits split contract address. * * @param transfer - The USDC transfer to validate - * @throws EVMPaymentVerificationError if recipient doesn't match + * @param validRecipients - Optional additional valid recipient addresses (e.g., split addresses) + * @throws EVMPaymentVerificationError if recipient doesn't match any valid address */ -export function validateRecipient(transfer: Erc20Transfer): void { - const expectedRecipient = process.env.BASE_TREASURY_ADDRESS; +export function validateRecipient( + transfer: Erc20Transfer, + validRecipients?: string[] +): void { + const treasury = process.env.BASE_TREASURY_ADDRESS; + + // Build list of all valid recipients + const allValid: string[] = []; + if (treasury) { + allValid.push(treasury.toLowerCase()); + } + if (validRecipients) { + allValid.push(...validRecipients.map((r) => r.toLowerCase())); + } - if (!expectedRecipient) { - throw new Error('BASE_TREASURY_ADDRESS environment variable is not set'); + if (allValid.length === 0) { + throw new Error('No valid recipient addresses configured'); } - if (transfer.to.toLowerCase() !== expectedRecipient.toLowerCase()) { + if (!allValid.includes(transfer.to.toLowerCase())) { throw new EVMPaymentVerificationError( - `Payment sent to wrong recipient: expected ${expectedRecipient}, got ${transfer.to}`, + `Payment sent to wrong recipient: got ${transfer.to}`, 'WRONG_RECIPIENT' ); } diff --git a/lib/solana/__tests__/payment-proof.test.ts b/lib/solana/__tests__/payment-proof.test.ts deleted file mode 100644 index 290b789..0000000 --- a/lib/solana/__tests__/payment-proof.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Tests for payment proof utilities - */ -import { - createPaymentProof, - validatePaymentProof, - type PaymentProof, -} from "../payment-proof"; - -describe("Payment Proof Utilities", () => { - describe("createPaymentProof", () => { - it("should create a valid payment proof object", () => { - const signature = "5xK3vABC123DEF456GHI789JKLmnopqrstuvwxyz12345678901234567890abcdef123456"; - const payerAddress = "7sK9xABC123DEF456GHI789JKLmnopqrstuvwxyz12"; - const blockTime = 1706960000; - - const proof = createPaymentProof(signature, payerAddress, blockTime); - - expect(proof).toEqual({ - chain: "solana", - transaction_signature: signature, - payer_address: payerAddress, - timestamp: blockTime, - }); - }); - - it("should use current time if blockTime is null", () => { - const before = Math.floor(Date.now() / 1000); - const proof = createPaymentProof( - "5xK3vABC123DEF456GHI789JKLmnopqrstuvwxyz12345678901234567890abcdef123456", - "7sK9xABC123DEF456GHI789JKLmnopqrstuvwxyz12", - null - ); - const after = Math.floor(Date.now() / 1000); - - expect(proof.timestamp).toBeGreaterThanOrEqual(before); - expect(proof.timestamp).toBeLessThanOrEqual(after); - }); - - it("should always set chain to solana", () => { - const proof = createPaymentProof( - "signature", - "address", - 1706960000 - ); - expect(proof.chain).toBe("solana"); - }); - }); - - describe("validatePaymentProof", () => { - // Valid Solana signature is ~88 characters base58 - // Valid Solana address is 32-44 characters base58 - // Using a realistic-length signature (88 chars) - const validSignature = "4vC9QCZwYUVLJP9GKgqQKnr7BNhZNFLmHJQFYXxHsJPB3eP7wL5W2nYmXhgPQ9s2c3NqYkZFyHvMxr6T8uDwJqLK"; - const validAddress = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; // 44 chars - Token Program - - const validProof: PaymentProof = { - chain: "solana", - transaction_signature: validSignature, - payer_address: validAddress, - timestamp: Math.floor(Date.now() / 1000) - 60, // 1 minute ago - }; - - it("should validate a correct proof", () => { - const result = validatePaymentProof(validProof); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it("should reject proof with missing signature", () => { - const proof = { ...validProof, transaction_signature: "" }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Missing transaction signature"); - }); - - it("should reject proof with missing payer address", () => { - const proof = { ...validProof, payer_address: "" }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Missing payer address"); - }); - - it("should reject proof with short signature", () => { - const proof = { ...validProof, transaction_signature: "tooshort" }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Invalid transaction signature format"); - }); - - it("should reject proof with invalid address format (too short)", () => { - const proof: PaymentProof = { - ...validProof, - payer_address: "short", // Too short - }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Invalid payer address format"); - }); - - it("should reject proof with invalid address format (too long)", () => { - const proof: PaymentProof = { - ...validProof, - payer_address: "7sK9xABC123DEF456GHI789JKLmnopqrstuvwxyz12345678901234567890", // Too long (>44) - }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Invalid payer address format"); - }); - - it("should reject proof with future timestamp", () => { - const futureTimestamp = Math.floor(Date.now() / 1000) + 600; // 10 minutes in future - const proof: PaymentProof = { ...validProof, timestamp: futureTimestamp }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(false); - expect(result.error).toBe("Invalid timestamp"); - }); - - it("should accept proof with near-future timestamp (within 5 min tolerance)", () => { - const nearFuture = Math.floor(Date.now() / 1000) + 60; // 1 minute in future - const proof: PaymentProof = { ...validProof, timestamp: nearFuture }; - const result = validatePaymentProof(proof); - expect(result.valid).toBe(true); - }); - }); -}); - -describe("Payment Proof Storage", () => { - // These tests would require mocking localStorage - // In a real test environment, you would use jest-dom or similar - - beforeEach(() => { - // Mock localStorage - const mockStorage: Record = {}; - global.localStorage = { - getItem: jest.fn((key: string) => mockStorage[key] || null), - setItem: jest.fn((key: string, value: string) => { - mockStorage[key] = value; - }), - removeItem: jest.fn((key: string) => { - delete mockStorage[key]; - }), - clear: jest.fn(() => { - Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); - }), - length: 0, - key: jest.fn(), - } as Storage; - }); - - it("should be able to store payment proof", async () => { - const { storePaymentProof, getStoredPaymentProof } = await import( - "../payment-proof" - ); - - const proof: PaymentProof = { - chain: "solana", - transaction_signature: "5xK3vABC123DEF456GHI789JKLmnopqrstuvwxyz12345678901234567890abcdef123456789012", - payer_address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - timestamp: Math.floor(Date.now() / 1000), - }; - - storePaymentProof("post_123", proof); - const retrieved = getStoredPaymentProof("post_123"); - - expect(retrieved).toEqual(proof); - }); - - it("should return null for non-existent proof", async () => { - const { getStoredPaymentProof } = await import("../payment-proof"); - - const retrieved = getStoredPaymentProof("non_existent_post"); - expect(retrieved).toBeNull(); - }); - - it("should clear stored proof", async () => { - const { - storePaymentProof, - getStoredPaymentProof, - clearStoredPaymentProof, - } = await import("../payment-proof"); - - const proof: PaymentProof = { - chain: "solana", - transaction_signature: "5xK3vABC123DEF456GHI789JKLmnopqrstuvwxyz12345678901234567890abcdef123456789012", - payer_address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - timestamp: Math.floor(Date.now() / 1000), - }; - - storePaymentProof("post_456", proof); - clearStoredPaymentProof("post_456"); - const retrieved = getStoredPaymentProof("post_456"); - - expect(retrieved).toBeNull(); - }); -}); diff --git a/lib/solana/__tests__/usdc.test.ts b/lib/solana/__tests__/usdc.test.ts deleted file mode 100644 index 4487c8e..0000000 --- a/lib/solana/__tests__/usdc.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Tests for Solana USDC utilities - */ -import { PublicKey } from "@solana/web3.js"; -import { - usdcToAtomic, - atomicToUsdc, - USDC_DECIMALS, - USDC_MINT, - isValidSolanaAddress, -} from "../usdc"; - -describe("USDC Utilities", () => { - describe("usdcToAtomic", () => { - it("should convert whole USDC to atomic units", () => { - expect(usdcToAtomic(1)).toBe(BigInt(1_000_000)); - expect(usdcToAtomic(10)).toBe(BigInt(10_000_000)); - expect(usdcToAtomic(100)).toBe(BigInt(100_000_000)); - }); - - it("should convert fractional USDC to atomic units", () => { - expect(usdcToAtomic(0.25)).toBe(BigInt(250_000)); - expect(usdcToAtomic(0.05)).toBe(BigInt(50_000)); - expect(usdcToAtomic(0.99)).toBe(BigInt(990_000)); - }); - - it("should handle very small amounts", () => { - expect(usdcToAtomic(0.000001)).toBe(BigInt(1)); - }); - - it("should handle zero", () => { - expect(usdcToAtomic(0)).toBe(BigInt(0)); - }); - - it("should round to nearest atomic unit", () => { - // 0.0000001 USDC should round to 0 atomic units - expect(usdcToAtomic(0.0000001)).toBe(BigInt(0)); - // 0.0000005 USDC should round to 1 atomic unit - expect(usdcToAtomic(0.0000005)).toBe(BigInt(1)); - }); - }); - - describe("atomicToUsdc", () => { - it("should convert atomic units to USDC", () => { - expect(atomicToUsdc(BigInt(1_000_000))).toBe(1); - expect(atomicToUsdc(BigInt(10_000_000))).toBe(10); - expect(atomicToUsdc(BigInt(250_000))).toBe(0.25); - }); - - it("should handle zero", () => { - expect(atomicToUsdc(BigInt(0))).toBe(0); - }); - - it("should handle single atomic unit", () => { - expect(atomicToUsdc(BigInt(1))).toBe(0.000001); - }); - }); - - describe("Round-trip conversion", () => { - it("should maintain value through conversion round-trip", () => { - const testValues = [0.05, 0.10, 0.25, 0.50, 0.99, 1.00, 10.00, 100.00]; - - for (const value of testValues) { - const atomic = usdcToAtomic(value); - const backToUsdc = atomicToUsdc(atomic); - expect(backToUsdc).toBeCloseTo(value, USDC_DECIMALS); - } - }); - }); - - describe("Constants", () => { - it("should have correct USDC decimals", () => { - expect(USDC_DECIMALS).toBe(6); - }); - - it("should have valid USDC mint address", () => { - expect(USDC_MINT).toBeInstanceOf(PublicKey); - expect(USDC_MINT.toBase58()).toBe( - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - ); - }); - }); - - describe("isValidSolanaAddress", () => { - it("should return true for valid addresses", () => { - const validAddresses = [ - // System Program - "11111111111111111111111111111111", - // USDC Mint - USDC_MINT.toBase58(), - // Token Program - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - ]; - - for (const address of validAddresses) { - expect(isValidSolanaAddress(address)).toBe(true); - } - }); - - it("should return false for invalid addresses", () => { - const invalidAddresses = [ - "", - "not-a-valid-address", - "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE3D", // EVM address - "too-short", - "!!!invalid!!!", - // Contains invalid base58 characters (0, O, I, l) - "0OIl11111111111111111111111111111", - ]; - - for (const address of invalidAddresses) { - expect(isValidSolanaAddress(address)).toBe(false); - } - }); - }); -}); diff --git a/lib/solana/__tests__/verify.test.ts b/lib/solana/__tests__/verify.test.ts deleted file mode 100644 index d4db42e..0000000 --- a/lib/solana/__tests__/verify.test.ts +++ /dev/null @@ -1,678 +0,0 @@ -/** - * Solana Payment Verification Tests - * - * Tests for lib/solana/verify.ts - * - * @see lib/solana/verify.ts - * @see claude/operations/tasks.md Task 2.2.10 - */ - -import { PublicKey } from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { - PaymentVerificationError, - fetchTransaction, - parseTokenTransfers, - findUsdcTransfer, - validateRecipient, - validateAmount, - parseMemo, - parseMemoFormat, - validateMemo, - checkTransactionFinality, - validateTransactionSuccess, - verifyPayment, - type TokenTransfer, -} from '../verify'; -import * as clientModule from '../client'; - -// Mock the client module -jest.mock('../client', () => ({ - getTransactionWithFallback: jest.fn(), - getSolanaConnection: jest.fn(), -})); - -// Test constants -const MOCK_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -const MOCK_TREASURY_PUBKEY = 'CStkPay111111111111111111111111111111111111'; -const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'); - -// Set up environment variables for tests -beforeAll(() => { - process.env.USDC_MINT_SOLANA = MOCK_USDC_MINT; - process.env.SOLANA_TREASURY_PUBKEY = MOCK_TREASURY_PUBKEY; -}); - -// Helper to create mock parsed transaction -function createMockTransaction(options: { - error?: object | null; - transfers?: Array<{ - source: string; - destination: string; - amount: string; - mint: string; - type?: 'transfer' | 'transferChecked'; - }>; - memo?: string | null; - innerTransfers?: Array<{ - source: string; - destination: string; - amount: string; - mint: string; - }>; -}) { - const instructions: Array<{ - programId: PublicKey; - parsed?: { type: string; info: Record }; - data?: string; - }> = []; - - // Add token transfer instructions - for (const transfer of options.transfers || []) { - instructions.push({ - programId: TOKEN_PROGRAM_ID, - parsed: { - type: transfer.type || 'transferChecked', - info: { - source: transfer.source, - destination: transfer.destination, - amount: transfer.amount, - mint: transfer.mint, - }, - }, - }); - } - - // Add memo instruction if provided - if (options.memo !== undefined && options.memo !== null) { - instructions.push({ - programId: MEMO_PROGRAM_ID, - parsed: options.memo as unknown as { type: string; info: Record }, - }); - } - - // Create inner instructions for composed transactions - const innerInstructions = []; - if (options.innerTransfers) { - innerInstructions.push({ - index: 0, - instructions: options.innerTransfers.map((transfer) => ({ - programId: TOKEN_PROGRAM_ID, - parsed: { - type: 'transferChecked', - info: { - source: transfer.source, - destination: transfer.destination, - amount: transfer.amount, - mint: transfer.mint, - }, - }, - })), - }); - } - - return { - transaction: { - message: { - instructions, - }, - }, - meta: { - err: options.error || null, - innerInstructions, - }, - }; -} - -describe('Solana Payment Verification', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - // ============================================ - // 2.2.1: fetchTransaction Tests - // ============================================ - describe('fetchTransaction', () => { - it('returns transaction when found', async () => { - const mockTx = createMockTransaction({}); - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - const result = await fetchTransaction('test-signature'); - - expect(result).toBe(mockTx); - expect(clientModule.getTransactionWithFallback).toHaveBeenCalledWith('test-signature'); - }); - - it('throws TX_NOT_FOUND when transaction is null', async () => { - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(null); - - await expect(fetchTransaction('test-signature')).rejects.toThrow(PaymentVerificationError); - await expect(fetchTransaction('test-signature')).rejects.toMatchObject({ - code: 'TX_NOT_FOUND', - }); - }); - - it('throws TX_FAILED when transaction has error', async () => { - const mockTx = createMockTransaction({ error: { InstructionError: [0, 'Custom'] } }); - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect(fetchTransaction('test-signature')).rejects.toThrow(PaymentVerificationError); - await expect(fetchTransaction('test-signature')).rejects.toMatchObject({ - code: 'TX_FAILED', - }); - }); - }); - - // ============================================ - // 2.2.2: parseTokenTransfers Tests - // ============================================ - describe('parseTokenTransfers', () => { - it('parses single transfer from transaction', () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'source-account', - destination: 'dest-account', - amount: '1000000', - mint: MOCK_USDC_MINT, - }, - ], - }); - - const transfers = parseTokenTransfers(mockTx as never); - - expect(transfers).toHaveLength(1); - expect(transfers[0]).toEqual({ - source: 'source-account', - destination: 'dest-account', - amount: BigInt(1000000), - mint: MOCK_USDC_MINT, - }); - }); - - it('parses multiple transfers', () => { - const mockTx = createMockTransaction({ - transfers: [ - { source: 'src1', destination: 'dst1', amount: '100', mint: 'mint1' }, - { source: 'src2', destination: 'dst2', amount: '200', mint: 'mint2' }, - ], - }); - - const transfers = parseTokenTransfers(mockTx as never); - - expect(transfers).toHaveLength(2); - }); - - it('parses transfers from inner instructions', () => { - const mockTx = createMockTransaction({ - transfers: [], - innerTransfers: [ - { - source: 'inner-source', - destination: 'inner-dest', - amount: '500000', - mint: MOCK_USDC_MINT, - }, - ], - }); - - const transfers = parseTokenTransfers(mockTx as never); - - expect(transfers).toHaveLength(1); - expect(transfers[0].source).toBe('inner-source'); - }); - - it('returns empty array when no transfers', () => { - const mockTx = createMockTransaction({ transfers: [] }); - - const transfers = parseTokenTransfers(mockTx as never); - - expect(transfers).toHaveLength(0); - }); - }); - - // ============================================ - // 2.2.3: findUsdcTransfer Tests (Wrong mint → error) - // ============================================ - describe('findUsdcTransfer', () => { - it('finds USDC transfer when present', () => { - const transfers: TokenTransfer[] = [ - { - source: 'src', - destination: 'dst', - amount: BigInt(1000000), - mint: MOCK_USDC_MINT, - }, - ]; - - const result = findUsdcTransfer(transfers); - - expect(result.mint).toBe(MOCK_USDC_MINT); - }); - - it('throws NO_USDC_TRANSFER when no USDC transfer found', () => { - const transfers: TokenTransfer[] = [ - { - source: 'src', - destination: 'dst', - amount: BigInt(1000000), - mint: 'wrong-mint-address', - }, - ]; - - expect(() => findUsdcTransfer(transfers)).toThrow(PaymentVerificationError); - expect(() => findUsdcTransfer(transfers)).toThrow('No USDC transfer found'); - }); - - it('throws NO_USDC_TRANSFER for empty transfers array', () => { - const transfers: TokenTransfer[] = []; - - expect(() => findUsdcTransfer(transfers)).toThrow(PaymentVerificationError); - }); - }); - - // ============================================ - // 2.2.4: validateRecipient Tests (Wrong recipient → error) - // ============================================ - describe('validateRecipient', () => { - it('passes when recipient matches treasury', () => { - const transfer: TokenTransfer = { - source: 'src', - destination: MOCK_TREASURY_PUBKEY, - amount: BigInt(1000000), - mint: MOCK_USDC_MINT, - }; - - expect(() => validateRecipient(transfer)).not.toThrow(); - }); - - it('throws WRONG_RECIPIENT when recipient does not match', () => { - const transfer: TokenTransfer = { - source: 'src', - destination: 'wrong-recipient-address', - amount: BigInt(1000000), - mint: MOCK_USDC_MINT, - }; - - expect(() => validateRecipient(transfer)).toThrow(PaymentVerificationError); - expect(() => validateRecipient(transfer)).toThrow('Payment sent to wrong recipient'); - }); - }); - - // ============================================ - // 2.2.5: validateAmount Tests (Insufficient amount → error) - // ============================================ - describe('validateAmount', () => { - it('passes when amount meets expected', () => { - const transfer: TokenTransfer = { - source: 'src', - destination: 'dst', - amount: BigInt(1000000), // 1 USDC - mint: MOCK_USDC_MINT, - }; - - expect(() => validateAmount(transfer, BigInt(1000000))).not.toThrow(); - }); - - it('passes when amount exceeds expected (overpayment)', () => { - const transfer: TokenTransfer = { - source: 'src', - destination: 'dst', - amount: BigInt(2000000), // 2 USDC - mint: MOCK_USDC_MINT, - }; - - expect(() => validateAmount(transfer, BigInt(1000000))).not.toThrow(); - }); - - it('throws INSUFFICIENT_AMOUNT when amount is less than expected', () => { - const transfer: TokenTransfer = { - source: 'src', - destination: 'dst', - amount: BigInt(500000), // 0.5 USDC - mint: MOCK_USDC_MINT, - }; - - expect(() => validateAmount(transfer, BigInt(1000000))).toThrow(PaymentVerificationError); - expect(() => validateAmount(transfer, BigInt(1000000))).toThrow('Insufficient payment'); - }); - }); - - // ============================================ - // 2.2.6 & 2.2.7: parseMemo Tests (Invalid memo → error) - // ============================================ - describe('parseMemo', () => { - it('extracts memo from transaction', () => { - const mockTx = createMockTransaction({ - transfers: [], - memo: 'clawstack:post_123:1706960000', - }); - - const memo = parseMemo(mockTx as never); - - expect(memo).toBe('clawstack:post_123:1706960000'); - }); - - it('returns null when no memo instruction', () => { - const mockTx = createMockTransaction({ transfers: [] }); - - const memo = parseMemo(mockTx as never); - - expect(memo).toBeNull(); - }); - }); - - describe('parseMemoFormat', () => { - it('parses valid memo format', () => { - const result = parseMemoFormat('clawstack:post_abc123:1706960000'); - - expect(result).toEqual({ - prefix: 'clawstack', - postId: 'post_abc123', - timestamp: 1706960000, - }); - }); - - it('throws INVALID_MEMO for null memo', () => { - expect(() => parseMemoFormat(null)).toThrow(PaymentVerificationError); - expect(() => parseMemoFormat(null)).toThrow('Missing payment memo'); - }); - - it('throws INVALID_MEMO for wrong number of parts', () => { - expect(() => parseMemoFormat('clawstack:post_123')).toThrow(PaymentVerificationError); - expect(() => parseMemoFormat('clawstack:post_123:ts:extra')).toThrow(PaymentVerificationError); - }); - - it('throws INVALID_MEMO for wrong prefix', () => { - expect(() => parseMemoFormat('wrongprefix:post_123:1706960000')).toThrow( - PaymentVerificationError - ); - expect(() => parseMemoFormat('wrongprefix:post_123:1706960000')).toThrow( - "Invalid memo prefix" - ); - }); - - it('throws INVALID_MEMO for non-numeric timestamp', () => { - expect(() => parseMemoFormat('clawstack:post_123:invalid')).toThrow(PaymentVerificationError); - }); - }); - - describe('validateMemo', () => { - it('passes when post ID matches', () => { - const memo = { prefix: 'clawstack', postId: 'post_123', timestamp: 1706960000 }; - - expect(() => validateMemo(memo, 'post_123')).not.toThrow(); - }); - - it('throws INVALID_MEMO when post ID does not match', () => { - const memo = { prefix: 'clawstack', postId: 'post_123', timestamp: 1706960000 }; - - expect(() => validateMemo(memo, 'post_456')).toThrow(PaymentVerificationError); - expect(() => validateMemo(memo, 'post_456')).toThrow('Memo post ID mismatch'); - }); - - it('throws MEMO_EXPIRED when timestamp exceeds limit', () => { - const memo = { prefix: 'clawstack', postId: 'post_123', timestamp: 1706960000 }; - const requestTimestamp = 1706960600; // 10 minutes later - - expect(() => validateMemo(memo, 'post_123', requestTimestamp, 300)).toThrow( - PaymentVerificationError - ); - expect(() => validateMemo(memo, 'post_123', requestTimestamp, 300)).toThrow('expired'); - }); - - it('passes when timestamp is within limit', () => { - const memo = { prefix: 'clawstack', postId: 'post_123', timestamp: 1706960000 }; - const requestTimestamp = 1706960100; // 100 seconds later - - expect(() => validateMemo(memo, 'post_123', requestTimestamp, 300)).not.toThrow(); - }); - }); - - // ============================================ - // 2.2.8: checkTransactionFinality Tests (Unconfirmed tx → error) - // ============================================ - describe('checkTransactionFinality', () => { - it('returns confirmed when transaction is confirmed', async () => { - const mockConnection = { - getSignatureStatus: jest.fn().mockResolvedValue({ - value: { confirmationStatus: 'confirmed' }, - }), - }; - (clientModule.getSolanaConnection as jest.Mock).mockReturnValue(mockConnection); - - const result = await checkTransactionFinality('test-sig'); - - expect(result).toBe('confirmed'); - }); - - it('returns finalized when transaction is finalized', async () => { - const mockConnection = { - getSignatureStatus: jest.fn().mockResolvedValue({ - value: { confirmationStatus: 'finalized' }, - }), - }; - (clientModule.getSolanaConnection as jest.Mock).mockReturnValue(mockConnection); - - const result = await checkTransactionFinality('test-sig'); - - expect(result).toBe('finalized'); - }); - - it('throws STATUS_UNKNOWN when status value is null', async () => { - const mockConnection = { - getSignatureStatus: jest.fn().mockResolvedValue({ value: null }), - }; - (clientModule.getSolanaConnection as jest.Mock).mockReturnValue(mockConnection); - - await expect(checkTransactionFinality('test-sig')).rejects.toThrow(PaymentVerificationError); - await expect(checkTransactionFinality('test-sig')).rejects.toMatchObject({ - code: 'STATUS_UNKNOWN', - }); - }); - - it('throws NOT_CONFIRMED when transaction is only processed', async () => { - const mockConnection = { - getSignatureStatus: jest.fn().mockResolvedValue({ - value: { confirmationStatus: 'processed' }, - }), - }; - (clientModule.getSolanaConnection as jest.Mock).mockReturnValue(mockConnection); - - await expect(checkTransactionFinality('test-sig')).rejects.toThrow(PaymentVerificationError); - await expect(checkTransactionFinality('test-sig')).rejects.toMatchObject({ - code: 'NOT_CONFIRMED', - }); - }); - }); - - // ============================================ - // 2.2.9: validateTransactionSuccess Tests - // ============================================ - describe('validateTransactionSuccess', () => { - it('passes when transaction has no error', () => { - const mockTx = createMockTransaction({}); - - expect(() => validateTransactionSuccess(mockTx as never)).not.toThrow(); - }); - - it('throws TX_FAILED when transaction has error', () => { - const mockTx = createMockTransaction({ error: { InstructionError: [0, 'Custom'] } }); - - expect(() => validateTransactionSuccess(mockTx as never)).toThrow(PaymentVerificationError); - expect(() => validateTransactionSuccess(mockTx as never)).toThrow('Transaction failed'); - }); - }); - - // ============================================ - // Integration: verifyPayment (Valid payment → success) - // ============================================ - describe('verifyPayment (integration)', () => { - const validSignature = 'valid-test-signature'; - const validPostId = 'post_abc123'; - const validAmount = BigInt(250000); // 0.25 USDC - const validTimestamp = 1706960000; - - it('returns verified payment for valid transaction', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: MOCK_TREASURY_PUBKEY, - amount: '250000', - mint: MOCK_USDC_MINT, - }, - ], - memo: `clawstack:${validPostId}:${validTimestamp}`, - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - const mockConnection = { - getSignatureStatus: jest.fn().mockResolvedValue({ - value: { confirmationStatus: 'confirmed' }, - }), - }; - (clientModule.getSolanaConnection as jest.Mock).mockReturnValue(mockConnection); - - const result = await verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - requestTimestamp: validTimestamp, - }); - - expect(result).toEqual({ - signature: validSignature, - payer: 'payer-account', - recipient: MOCK_TREASURY_PUBKEY, - amount: BigInt(250000), - mint: MOCK_USDC_MINT, - memo: `clawstack:${validPostId}:${validTimestamp}`, - postId: validPostId, - timestamp: validTimestamp, - confirmationStatus: 'confirmed', - }); - }); - - it('rejects transaction with wrong mint', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: MOCK_TREASURY_PUBKEY, - amount: '250000', - mint: 'wrong-mint-address', - }, - ], - memo: `clawstack:${validPostId}:${validTimestamp}`, - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect( - verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - }) - ).rejects.toMatchObject({ code: 'NO_USDC_TRANSFER' }); - }); - - it('rejects transaction with wrong recipient', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: 'wrong-recipient', - amount: '250000', - mint: MOCK_USDC_MINT, - }, - ], - memo: `clawstack:${validPostId}:${validTimestamp}`, - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect( - verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - }) - ).rejects.toMatchObject({ code: 'WRONG_RECIPIENT' }); - }); - - it('rejects transaction with insufficient amount', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: MOCK_TREASURY_PUBKEY, - amount: '100000', // 0.1 USDC (less than 0.25 required) - mint: MOCK_USDC_MINT, - }, - ], - memo: `clawstack:${validPostId}:${validTimestamp}`, - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect( - verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - }) - ).rejects.toMatchObject({ code: 'INSUFFICIENT_AMOUNT' }); - }); - - it('rejects transaction with invalid memo', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: MOCK_TREASURY_PUBKEY, - amount: '250000', - mint: MOCK_USDC_MINT, - }, - ], - memo: 'invalid-memo-format', - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect( - verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - }) - ).rejects.toMatchObject({ code: 'INVALID_MEMO' }); - }); - - it('rejects transaction with wrong post ID in memo', async () => { - const mockTx = createMockTransaction({ - transfers: [ - { - source: 'payer-account', - destination: MOCK_TREASURY_PUBKEY, - amount: '250000', - mint: MOCK_USDC_MINT, - }, - ], - memo: `clawstack:wrong_post_id:${validTimestamp}`, - }); - - (clientModule.getTransactionWithFallback as jest.Mock).mockResolvedValue(mockTx); - - await expect( - verifyPayment({ - signature: validSignature, - expectedPostId: validPostId, - expectedAmountRaw: validAmount, - }) - ).rejects.toMatchObject({ code: 'INVALID_MEMO' }); - }); - }); -}); diff --git a/lib/solana/client.ts b/lib/solana/client.ts deleted file mode 100644 index d63588d..0000000 --- a/lib/solana/client.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - Connection, - Commitment, - Finality, - ParsedTransactionWithMeta, -} from '@solana/web3.js'; - -/** - * Singleton connection instance for the primary RPC endpoint. - */ -let connection: Connection | null = null; - -/** - * Get the configured RPC endpoints in priority order. - * Filters out undefined/empty values. - */ -function getRpcEndpoints(): string[] { - return [ - process.env.SOLANA_RPC_URL, - process.env.SOLANA_RPC_FALLBACK_URL, - 'https://api.mainnet-beta.solana.com', // Public fallback (rate-limited) - ].filter((endpoint): endpoint is string => Boolean(endpoint)); -} - -/** - * Get or create a singleton Solana connection. - * Uses the primary RPC endpoint from environment variables. - * - * @param commitment - Transaction commitment level (default: 'confirmed') - * @returns A Solana Connection instance - */ -export function getSolanaConnection( - commitment: Commitment = 'confirmed' -): Connection { - if (!connection) { - const rpcUrl = process.env.SOLANA_RPC_URL; - if (!rpcUrl) { - throw new Error('SOLANA_RPC_URL environment variable is not set'); - } - - connection = new Connection(rpcUrl, { - commitment, - confirmTransactionInitialTimeout: 60000, - }); - } - return connection; -} - -/** - * Create a new connection to a specific endpoint. - * Use this for one-off requests or when testing different endpoints. - * - * @param endpoint - RPC endpoint URL - * @param commitment - Transaction commitment level - * @returns A new Solana Connection instance - */ -export function createConnection( - endpoint: string, - commitment: Commitment = 'confirmed' -): Connection { - return new Connection(endpoint, { - commitment, - confirmTransactionInitialTimeout: 60000, - }); -} - -/** - * Fetch a parsed transaction with automatic fallback to secondary RPC endpoints. - * Tries each configured endpoint in order until one succeeds. - * - * @param signature - Transaction signature to fetch - * @param finality - Transaction finality level (default: 'confirmed') - * @returns The parsed transaction with metadata, or null if not found - * @throws Error if all RPC endpoints fail - */ -export async function getTransactionWithFallback( - signature: string, - finality: Finality = 'confirmed' -): Promise { - const endpoints = getRpcEndpoints(); - - if (endpoints.length === 0) { - throw new Error('No Solana RPC endpoints configured'); - } - - let lastError: Error | null = null; - - for (const endpoint of endpoints) { - try { - const conn = createConnection(endpoint, finality); - const tx = await conn.getParsedTransaction(signature, { - maxSupportedTransactionVersion: 0, - commitment: finality, - }); - - // Transaction found (or confirmed not to exist) - return tx; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.warn( - `[Solana RPC] Endpoint ${endpoint} failed: ${lastError.message}, trying next...` - ); - } - } - - throw new Error( - `All Solana RPC endpoints failed. Last error: ${lastError?.message}` - ); -} - -/** - * Check the health/latency of an RPC endpoint. - * Useful for monitoring and endpoint selection. - * - * @param endpoint - RPC endpoint URL to check - * @returns Object with latency in ms and slot number - */ -export async function checkEndpointHealth( - endpoint: string -): Promise<{ latency: number; slot: number }> { - const conn = createConnection(endpoint); - const start = Date.now(); - const slot = await conn.getSlot(); - const latency = Date.now() - start; - - return { latency, slot }; -} - -/** - * Reset the singleton connection. - * Useful for testing or when changing RPC configuration. - */ -export function resetConnection(): void { - connection = null; -} diff --git a/lib/solana/hooks.ts b/lib/solana/hooks.ts deleted file mode 100644 index fc2a3cc..0000000 --- a/lib/solana/hooks.ts +++ /dev/null @@ -1,233 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useConnection, useWallet } from "@solana/wallet-adapter-react"; -// import { PublicKey } from "@solana/web3.js"; -import { - getUsdcBalance, - createUsdcPaymentTransaction, - waitForConfirmation, - type PaymentConfirmationResult, -} from "./usdc"; - -/** - * Hook to fetch and track USDC balance - */ -export function useUsdcBalance() { - const { connection } = useConnection(); - const { publicKey, connected } = useWallet(); - const [balance, setBalance] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchBalance = useCallback(async () => { - if (!publicKey || !connected) { - setBalance(0); - return; - } - - setLoading(true); - setError(null); - - try { - const usdcBalance = await getUsdcBalance(connection, publicKey); - setBalance(usdcBalance); - } catch (err) { - console.error("Error fetching USDC balance:", err); - setError(err instanceof Error ? err.message : "Failed to fetch balance"); - setBalance(0); - } finally { - setLoading(false); - } - }, [connection, publicKey, connected]); - - // Fetch balance on mount and when wallet changes - useEffect(() => { - fetchBalance(); - }, [fetchBalance]); - - // Refetch balance periodically when connected - useEffect(() => { - if (!connected) return; - - const interval = setInterval(fetchBalance, 30000); // Every 30 seconds - return () => clearInterval(interval); - }, [connected, fetchBalance]); - - return { - balance, - loading, - error, - refetch: fetchBalance, - }; -} - -export type PaymentStatus = - | "idle" - | "creating" - | "signing" - | "confirming" - | "success" - | "error"; - -export interface PaymentState { - status: PaymentStatus; - signature: string | null; - error: string | null; - confirmationResult: PaymentConfirmationResult | null; -} - -export interface UsePaymentResult { - state: PaymentState; - initiatePayment: ( - recipientAddress: string, - amountUsdc: number, - memo: string - ) => Promise; - reset: () => void; -} - -/** - * Hook to manage Solana USDC payments - * Handles transaction creation, signing, sending, and confirmation - */ -export function useSolanaPayment(): UsePaymentResult { - const { connection } = useConnection(); - const { publicKey, signTransaction, connected } = useWallet(); - - const [state, setState] = useState({ - status: "idle", - signature: null, - error: null, - confirmationResult: null, - }); - - const reset = useCallback(() => { - setState({ - status: "idle", - signature: null, - error: null, - confirmationResult: null, - }); - }, []); - - const initiatePayment = useCallback( - async ( - recipientAddress: string, - amountUsdc: number, - memo: string - ): Promise => { - if (!publicKey || !signTransaction || !connected) { - setState((prev) => ({ - ...prev, - status: "error", - error: "Wallet not connected", - })); - return null; - } - - try { - // Step 1: Create transaction - setState((prev) => ({ - ...prev, - status: "creating", - error: null, - })); - - const { transaction } = await createUsdcPaymentTransaction({ - connection, - payerPublicKey: publicKey, - recipientAddress, - amountUsdc, - memo, - }); - - // Step 2: Request signature from wallet - setState((prev) => ({ - ...prev, - status: "signing", - })); - - const signedTransaction = await signTransaction(transaction); - - // Step 3: Send transaction - setState((prev) => ({ - ...prev, - status: "confirming", - })); - - const signature = await connection.sendRawTransaction( - signedTransaction.serialize(), - { - skipPreflight: false, - preflightCommitment: "confirmed", - } - ); - - setState((prev) => ({ - ...prev, - signature, - })); - - // Step 4: Wait for confirmation - const confirmationResult = await waitForConfirmation( - connection, - signature - ); - - if (confirmationResult.confirmed) { - setState((prev) => ({ - ...prev, - status: "success", - confirmationResult, - })); - } else { - setState((prev) => ({ - ...prev, - status: "error", - error: confirmationResult.error || "Transaction failed", - confirmationResult, - })); - } - - return confirmationResult; - } catch (err) { - console.error("Payment error:", err); - const errorMessage = - err instanceof Error ? err.message : "Payment failed"; - - setState((prev) => ({ - ...prev, - status: "error", - error: errorMessage, - })); - - return null; - } - }, - [connection, publicKey, signTransaction, connected] - ); - - return { - state, - initiatePayment, - reset, - }; -} - -/** - * Hook to check if wallet has sufficient USDC balance for a payment - */ -export function useCanAfford(amountUsdc: number): { - canAfford: boolean; - balance: number; - loading: boolean; -} { - const { balance, loading } = useUsdcBalance(); - - return { - canAfford: balance >= amountUsdc, - balance, - loading, - }; -} diff --git a/lib/solana/index.ts b/lib/solana/index.ts deleted file mode 100644 index 33d0c58..0000000 --- a/lib/solana/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Solana-specific logic (transaction verification) -export { - getSolanaConnection, - createConnection, - getTransactionWithFallback, - checkEndpointHealth, - resetConnection, -} from './client'; - -export { - PaymentVerificationError, - fetchTransaction, - parseTokenTransfers, - findUsdcTransfer, - validateRecipient, - validateAmount, - parseMemo, - parseMemoFormat, - validateMemo, - checkTransactionFinality, - validateTransactionSuccess, - verifyPayment, - type TokenTransfer, - type VerifiedPayment, - type VerifyPaymentOptions, - type ParsedMemo, -} from './verify'; - -// USDC utilities for client-side payments -export { - USDC_MINT, - USDC_DECIMALS, - usdcToAtomic, - atomicToUsdc, - getUsdcBalance, - getUsdcBalanceRaw, - createUsdcPaymentTransaction, - waitForConfirmation, - isValidSolanaAddress, - type CreatePaymentTransactionParams, - type PaymentTransactionResult, - type PaymentConfirmationResult, -} from './usdc'; - -// React hooks for Solana wallet integration -export { - useUsdcBalance, - useSolanaPayment, - useCanAfford, - type PaymentStatus, - type PaymentState, - type UsePaymentResult, -} from './hooks'; - -// Payment proof utilities -export { - createPaymentProof, - submitPaymentProof, - validatePaymentProof, - storePaymentProof, - getStoredPaymentProof, - clearStoredPaymentProof, - type PaymentProof, - type PaymentProofSubmissionResult, -} from './payment-proof'; diff --git a/lib/solana/payment-proof.ts b/lib/solana/payment-proof.ts deleted file mode 100644 index d2061c4..0000000 --- a/lib/solana/payment-proof.ts +++ /dev/null @@ -1,166 +0,0 @@ -// import type { PaymentConfirmationResult } from "./usdc"; - -export interface PaymentProof { - chain: "solana"; - transaction_signature: string; - payer_address: string; - timestamp: number; -} - -export interface PaymentProofSubmissionResult { - success: boolean; - accessGranted: boolean; - error?: string; - expiresAt?: string; -} - -/** - * Create a payment proof object from a confirmed transaction - */ -export function createPaymentProof( - signature: string, - payerAddress: string, - blockTime?: number | null -): PaymentProof { - return { - chain: "solana", - transaction_signature: signature, - payer_address: payerAddress, - timestamp: blockTime || Math.floor(Date.now() / 1000), - }; -} - -/** - * Submit payment proof to the API to unlock content - * @param postId - The ID of the post to unlock - * @param proof - The payment proof object - * @returns Result of the submission - */ -export async function submitPaymentProof( - postId: string, - proof: PaymentProof -): Promise { - try { - const response = await fetch(`/api/v1/post/${postId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-Payment-Proof": JSON.stringify(proof), - }, - }); - - if (response.ok) { - const data = await response.json(); - return { - success: true, - accessGranted: true, - expiresAt: data.access_expires_at, - }; - } - - // Handle error responses - const errorData = await response.json().catch(() => ({})); - - if (response.status === 402) { - return { - success: false, - accessGranted: false, - error: "Payment verification failed. Please try again.", - }; - } - - return { - success: false, - accessGranted: false, - error: errorData.message || `Request failed with status ${response.status}`, - }; - } catch (error) { - console.error("Error submitting payment proof:", error); - return { - success: false, - accessGranted: false, - error: error instanceof Error ? error.message : "Network error", - }; - } -} - -/** - * Verify a payment proof is valid before submission - * Performs basic client-side validation - */ -export function validatePaymentProof(proof: PaymentProof): { - valid: boolean; - error?: string; -} { - if (!proof.transaction_signature) { - return { valid: false, error: "Missing transaction signature" }; - } - - if (!proof.payer_address) { - return { valid: false, error: "Missing payer address" }; - } - - // Solana signatures are base58 encoded and ~88 characters - if (proof.transaction_signature.length < 80) { - return { valid: false, error: "Invalid transaction signature format" }; - } - - // Basic Solana address validation (base58, 32-44 chars) - if (proof.payer_address.length < 32 || proof.payer_address.length > 44) { - return { valid: false, error: "Invalid payer address format" }; - } - - // Check timestamp is reasonable (not more than 5 minutes in the future) - const now = Math.floor(Date.now() / 1000); - if (proof.timestamp > now + 300) { - return { valid: false, error: "Invalid timestamp" }; - } - - return { valid: true }; -} - -/** - * Store payment proof in localStorage for session persistence - */ -export function storePaymentProof(postId: string, proof: PaymentProof): void { - try { - const key = `clawstack_payment_${postId}`; - const data = { - proof, - storedAt: Date.now(), - }; - localStorage.setItem(key, JSON.stringify(data)); - } catch (error) { - console.warn("Failed to store payment proof:", error); - } -} - -/** - * Retrieve stored payment proof from localStorage - * Returns null if not found (purchases do not expire) - */ -export function getStoredPaymentProof(postId: string): PaymentProof | null { - try { - const key = `clawstack_payment_${postId}`; - const stored = localStorage.getItem(key); - - if (!stored) return null; - - const data = JSON.parse(stored); - return data.proof; - } catch { - return null; - } -} - -/** - * Clear stored payment proof - */ -export function clearStoredPaymentProof(postId: string): void { - try { - const key = `clawstack_payment_${postId}`; - localStorage.removeItem(key); - } catch (error) { - console.warn("Failed to clear payment proof:", error); - } -} diff --git a/lib/solana/usdc.ts b/lib/solana/usdc.ts deleted file mode 100644 index 3e2346b..0000000 --- a/lib/solana/usdc.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - Connection, - PublicKey, - Transaction, - // SystemProgram, -} from "@solana/web3.js"; -import { - getAssociatedTokenAddress, - createTransferInstruction, - getAccount, - TOKEN_PROGRAM_ID, -} from "@solana/spl-token"; - -// USDC Token Mint on Solana Mainnet -export const USDC_MINT = new PublicKey( - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" -); - -// USDC has 6 decimals -export const USDC_DECIMALS = 6; - -/** - * Convert USDC amount to atomic units (smallest unit) - * @param usdcAmount - Amount in USDC (e.g., 0.25) - * @returns Amount in atomic units as bigint - */ -export function usdcToAtomic(usdcAmount: number): bigint { - return BigInt(Math.round(usdcAmount * Math.pow(10, USDC_DECIMALS))); -} - -/** - * Convert atomic units back to USDC - * @param atomicAmount - Amount in atomic units - * @returns Amount in USDC - */ -export function atomicToUsdc(atomicAmount: bigint): number { - return Number(atomicAmount) / Math.pow(10, USDC_DECIMALS); -} - -/** - * Get the USDC balance for a wallet address - * @param connection - Solana connection - * @param walletAddress - Wallet public key - * @returns Balance in USDC, or 0 if no token account exists - */ -export async function getUsdcBalance( - connection: Connection, - walletAddress: PublicKey -): Promise { - try { - const tokenAccount = await getAssociatedTokenAddress( - USDC_MINT, - walletAddress - ); - - const account = await getAccount(connection, tokenAccount); - return atomicToUsdc(account.amount); - } catch (error) { - // Token account doesn't exist or other error - console.warn("Error fetching USDC balance:", error); - return 0; - } -} - -/** - * Get the USDC balance in atomic units - * @param connection - Solana connection - * @param walletAddress - Wallet public key - * @returns Balance in atomic units (bigint) - */ -export async function getUsdcBalanceRaw( - connection: Connection, - walletAddress: PublicKey -): Promise { - try { - const tokenAccount = await getAssociatedTokenAddress( - USDC_MINT, - walletAddress - ); - - const account = await getAccount(connection, tokenAccount); - return account.amount; - } catch { - return BigInt(0); - } -} - -export interface CreatePaymentTransactionParams { - connection: Connection; - payerPublicKey: PublicKey; - recipientAddress: string; - amountUsdc: number; - memo: string; -} - -export interface PaymentTransactionResult { - transaction: Transaction; - amountRaw: bigint; -} - -/** - * Create a USDC payment transaction - * @returns Transaction ready to be signed and sent - */ -export async function createUsdcPaymentTransaction({ - connection, - payerPublicKey, - recipientAddress, - amountUsdc, - memo: _memo, -}: CreatePaymentTransactionParams): Promise { - const recipientPubkey = new PublicKey(recipientAddress); - const amountRaw = usdcToAtomic(amountUsdc); - - // Get associated token accounts - const payerTokenAccount = await getAssociatedTokenAddress( - USDC_MINT, - payerPublicKey - ); - - const recipientTokenAccount = await getAssociatedTokenAddress( - USDC_MINT, - recipientPubkey - ); - - // Create the transfer instruction - const transferInstruction = createTransferInstruction( - payerTokenAccount, - recipientTokenAccount, - payerPublicKey, - amountRaw, - [], - TOKEN_PROGRAM_ID - ); - - // Create memo instruction for payment tracking - // Using a simple memo via SystemProgram.transfer with 0 lamports - // In production, use the SPL Memo program for proper memo support - // const memoData = Buffer.from(memo, "utf-8"); - - // Build the transaction - const transaction = new Transaction(); - - // Add recent blockhash and fee payer - const { blockhash, lastValidBlockHeight } = - await connection.getLatestBlockhash("confirmed"); - transaction.recentBlockhash = blockhash; - transaction.lastValidBlockHeight = lastValidBlockHeight; - transaction.feePayer = payerPublicKey; - - // Add the transfer instruction - transaction.add(transferInstruction); - - return { - transaction, - amountRaw, - }; -} - -export interface PaymentConfirmationResult { - signature: string; - confirmed: boolean; - slot: number; - blockTime: number | null; - error?: string; -} - -/** - * Wait for transaction confirmation - * @param connection - Solana connection - * @param signature - Transaction signature - * @param timeout - Timeout in milliseconds (default 60s) - * @returns Confirmation result - */ -export async function waitForConfirmation( - connection: Connection, - signature: string, - timeout = 60000 -): Promise { - const start = Date.now(); - - try { - // Wait for confirmation with 'confirmed' commitment - const result = await connection.confirmTransaction( - { - signature, - blockhash: (await connection.getLatestBlockhash()).blockhash, - lastValidBlockHeight: (await connection.getLatestBlockhash()) - .lastValidBlockHeight, - }, - "confirmed" - ); - - if (result.value.err) { - return { - signature, - confirmed: false, - slot: 0, - blockTime: null, - error: JSON.stringify(result.value.err), - }; - } - - // Get transaction details for slot and block time - const txDetails = await connection.getTransaction(signature, { - commitment: "confirmed", - maxSupportedTransactionVersion: 0, - }); - - return { - signature, - confirmed: true, - slot: txDetails?.slot || 0, - blockTime: txDetails?.blockTime || null, - }; - } catch (error) { - const elapsed = Date.now() - start; - if (elapsed >= timeout) { - return { - signature, - confirmed: false, - slot: 0, - blockTime: null, - error: "Transaction confirmation timeout", - }; - } - return { - signature, - confirmed: false, - slot: 0, - blockTime: null, - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -/** - * Validate a Solana public key string - */ -export function isValidSolanaAddress(address: string): boolean { - try { - new PublicKey(address); - return true; - } catch { - return false; - } -} diff --git a/lib/solana/verify.ts b/lib/solana/verify.ts deleted file mode 100644 index 3149dd8..0000000 --- a/lib/solana/verify.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { - ParsedTransactionWithMeta, - PublicKey, - Connection, - // ParsedInstruction, -} from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { getTransactionWithFallback, getSolanaConnection } from './client'; - -/** - * Error class for payment verification failures. - * Contains structured error codes for agent-parseable responses. - */ -export class PaymentVerificationError extends Error { - public readonly code: string; - - constructor( - message: string, - code: - | 'TX_NOT_FOUND' - | 'TX_FAILED' - | 'NO_USDC_TRANSFER' - | 'WRONG_RECIPIENT' - | 'INSUFFICIENT_AMOUNT' - | 'INVALID_MEMO' - | 'MEMO_EXPIRED' - | 'NOT_CONFIRMED' - | 'STATUS_UNKNOWN' = 'TX_NOT_FOUND' - ) { - super(message); - this.name = 'PaymentVerificationError'; - this.code = code; - } -} - -/** - * Memo Program ID for Solana memo instructions. - */ -const MEMO_PROGRAM_ID = new PublicKey( - 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' -); - -/** - * Interface for parsed SPL token transfers. - */ -export interface TokenTransfer { - source: string; - destination: string; - amount: bigint; - mint: string; -} - -/** - * Verified payment result returned after successful verification. - */ -export interface VerifiedPayment { - signature: string; - payer: string; - recipient: string; - amount: bigint; - mint: string; - memo: string | null; - postId: string | null; - timestamp: number | null; - confirmationStatus: 'confirmed' | 'finalized'; -} - -/** - * Options for verifying a payment. - */ -export interface VerifyPaymentOptions { - signature: string; - expectedPostId: string; - expectedAmountRaw: bigint; - requestTimestamp?: number; - memoExpirationSeconds?: number; -} - -// ============================================ -// 2.2.1: Fetch Transaction Wrapper -// ============================================ - -/** - * Fetch a parsed transaction from the Solana network with error handling. - * Uses fallback RPC endpoints if primary fails. - * - * @param signature - Transaction signature to fetch - * @returns The parsed transaction with metadata - * @throws PaymentVerificationError if transaction not found or failed - */ -export async function fetchTransaction( - signature: string -): Promise { - const tx = await getTransactionWithFallback(signature); - - if (!tx) { - throw new PaymentVerificationError( - 'Transaction not found', - 'TX_NOT_FOUND' - ); - } - - if (tx.meta?.err) { - const errorMsg = JSON.stringify(tx.meta.err); - throw new PaymentVerificationError( - `Transaction failed on-chain: ${errorMsg}`, - 'TX_FAILED' - ); - } - - return tx; -} - -// ============================================ -// 2.2.2: Parse SPL Token Transfer Instructions -// ============================================ - -/** - * Type for parsed SPL token transfer info. - */ -interface ParsedTransferInfo { - source: string; - destination: string; - amount?: string; - tokenAmount?: { amount: string }; - mint?: string; -} - -/** - * Type guard to check if an instruction has parsed data. - */ -function hasParsedData( - ix: unknown -): ix is { programId: PublicKey; parsed: { type: string; info: ParsedTransferInfo } } { - return ( - typeof ix === 'object' && - ix !== null && - 'programId' in ix && - 'parsed' in ix && - typeof (ix as { parsed: unknown }).parsed === 'object' - ); -} - -/** - * Extract transfer from a parsed instruction if applicable. - */ -function extractTransferFromInstruction( - ix: unknown -): TokenTransfer | null { - if (!hasParsedData(ix)) { - return null; - } - - if (!ix.programId.equals(TOKEN_PROGRAM_ID)) { - return null; - } - - const parsed = ix.parsed; - if (parsed.type !== 'transfer' && parsed.type !== 'transferChecked') { - return null; - } - - const info = parsed.info; - return { - source: info.source, - destination: info.destination, - amount: BigInt(info.amount || info.tokenAmount?.amount || '0'), - mint: info.mint || '', - }; -} - -/** - * Parse all SPL token transfers from a transaction. - * Handles both 'transfer' and 'transferChecked' instruction types. - * - * @param tx - The parsed transaction with metadata - * @returns Array of token transfer details - */ -export function parseTokenTransfers( - tx: ParsedTransactionWithMeta -): TokenTransfer[] { - const transfers: TokenTransfer[] = []; - - // Check top-level instructions - for (const ix of tx.transaction.message.instructions) { - const transfer = extractTransferFromInstruction(ix); - if (transfer) { - transfers.push(transfer); - } - } - - // Also check inner instructions (for composed transactions) - for (const inner of tx.meta?.innerInstructions || []) { - for (const ix of inner.instructions) { - const transfer = extractTransferFromInstruction(ix); - if (transfer) { - transfers.push(transfer); - } - } - } - - return transfers; -} - -// ============================================ -// 2.2.3: Validate USDC Mint Address -// ============================================ - -/** - * Find a USDC transfer from the list of token transfers. - * Uses the USDC mint address from environment variable. - * - * @param transfers - Array of parsed token transfers - * @returns The USDC transfer if found - * @throws PaymentVerificationError if no USDC transfer found - */ -export function findUsdcTransfer(transfers: TokenTransfer[]): TokenTransfer { - const usdcMint = process.env.USDC_MINT_SOLANA; - - if (!usdcMint) { - throw new Error('USDC_MINT_SOLANA environment variable is not set'); - } - - const usdcTransfer = transfers.find((t) => t.mint === usdcMint); - - if (!usdcTransfer) { - throw new PaymentVerificationError( - 'No USDC transfer found in transaction', - 'NO_USDC_TRANSFER' - ); - } - - return usdcTransfer; -} - -// ============================================ -// 2.2.4: Validate Recipient Matches Expected -// ============================================ - -/** - * Validate that the USDC transfer recipient matches the expected treasury address. - * - * @param transfer - The USDC transfer to validate - * @throws PaymentVerificationError if recipient doesn't match - */ -export function validateRecipient(transfer: TokenTransfer): void { - const expectedRecipient = process.env.SOLANA_TREASURY_PUBKEY; - - if (!expectedRecipient) { - throw new Error('SOLANA_TREASURY_PUBKEY environment variable is not set'); - } - - if (transfer.destination !== expectedRecipient) { - throw new PaymentVerificationError( - `Payment sent to wrong recipient: expected ${expectedRecipient}, got ${transfer.destination}`, - 'WRONG_RECIPIENT' - ); - } -} - -// ============================================ -// 2.2.5: Validate Amount Meets Minimum -// ============================================ - -/** - * Validate that the payment amount meets the minimum required. - * Accepts overpayment but rejects underpayment. - * - * @param transfer - The USDC transfer to validate - * @param expectedAmountRaw - Expected amount in raw units (6 decimals for USDC) - * @throws PaymentVerificationError if amount is insufficient - */ -export function validateAmount( - transfer: TokenTransfer, - expectedAmountRaw: bigint -): void { - if (transfer.amount < expectedAmountRaw) { - throw new PaymentVerificationError( - `Insufficient payment: expected ${expectedAmountRaw}, got ${transfer.amount}`, - 'INSUFFICIENT_AMOUNT' - ); - } -} - -// ============================================ -// 2.2.6: Parse Memo Instruction for Reference -// ============================================ - -/** - * Extract memo from a single instruction if it's a memo instruction. - */ -function extractMemoFromInstruction(ix: unknown): string | null { - if (typeof ix !== 'object' || ix === null || !('programId' in ix)) { - return null; - } - - const instruction = ix as { programId: PublicKey; parsed?: unknown; data?: string }; - - if (!instruction.programId.equals(MEMO_PROGRAM_ID)) { - return null; - } - - // Check for parsed memo - if ('parsed' in instruction && instruction.parsed !== undefined) { - return String(instruction.parsed); - } - - // Check for raw data (base58 encoded memo) - if ('data' in instruction && typeof instruction.data === 'string') { - return instruction.data; - } - - return null; -} - -/** - * Extract memo from a transaction. - * Checks both top-level and inner instructions. - * - * @param tx - The parsed transaction with metadata - * @returns The memo string if found, null otherwise - */ -export function parseMemo(tx: ParsedTransactionWithMeta): string | null { - // Check top-level instructions - for (const ix of tx.transaction.message.instructions) { - const memo = extractMemoFromInstruction(ix); - if (memo !== null) { - return memo; - } - } - - // Also check inner instructions - for (const inner of tx.meta?.innerInstructions || []) { - for (const ix of inner.instructions) { - const memo = extractMemoFromInstruction(ix); - if (memo !== null) { - return memo; - } - } - } - - return null; -} - -// ============================================ -// 2.2.7: Validate Memo Matches Resource -// ============================================ - -/** - * Parsed memo structure. - */ -export interface ParsedMemo { - prefix: string; - postId: string; - timestamp: number; -} - -/** - * Parse and validate the payment memo format. - * Expected format: "clawstack:post_abc123:1706960000" - * - * @param memo - The memo string to parse - * @returns Parsed memo components - * @throws PaymentVerificationError if memo format is invalid - */ -export function parseMemoFormat(memo: string | null): ParsedMemo { - if (!memo) { - throw new PaymentVerificationError('Missing payment memo', 'INVALID_MEMO'); - } - - const parts = memo.split(':'); - - if (parts.length !== 3) { - throw new PaymentVerificationError( - `Invalid memo format: expected 3 parts, got ${parts.length}`, - 'INVALID_MEMO' - ); - } - - const [prefix, postId, timestampStr] = parts; - - if (prefix !== 'clawstack') { - throw new PaymentVerificationError( - `Invalid memo prefix: expected 'clawstack', got '${prefix}'`, - 'INVALID_MEMO' - ); - } - - const timestamp = parseInt(timestampStr, 10); - - if (isNaN(timestamp)) { - throw new PaymentVerificationError( - `Invalid memo timestamp: ${timestampStr}`, - 'INVALID_MEMO' - ); - } - - return { prefix, postId, timestamp }; -} - -/** - * Validate memo matches expected post ID and timestamp is within bounds. - * - * @param memo - Parsed memo components - * @param expectedPostId - The expected post ID - * @param requestTimestamp - The timestamp of the payment request (Unix seconds) - * @param expirationSeconds - Maximum age of memo timestamp (default 300 = 5 minutes) - * @throws PaymentVerificationError if memo doesn't match expectations - */ -export function validateMemo( - memo: ParsedMemo, - expectedPostId: string, - requestTimestamp?: number, - expirationSeconds = 300 -): void { - if (memo.postId !== expectedPostId) { - throw new PaymentVerificationError( - `Memo post ID mismatch: expected '${expectedPostId}', got '${memo.postId}'`, - 'INVALID_MEMO' - ); - } - - // If request timestamp provided, check memo isn't too old - if (requestTimestamp !== undefined) { - const diff = Math.abs(memo.timestamp - requestTimestamp); - if (diff > expirationSeconds) { - throw new PaymentVerificationError( - `Payment memo expired: ${diff}s difference exceeds ${expirationSeconds}s limit`, - 'MEMO_EXPIRED' - ); - } - } -} - -// ============================================ -// 2.2.8: Check Transaction Finality -// ============================================ - -/** - * Check the confirmation status of a transaction. - * Requires at least 'confirmed' status for payment acceptance. - * - * @param signature - Transaction signature to check - * @param connection - Optional Solana connection (uses singleton if not provided) - * @returns The confirmation status ('confirmed' or 'finalized') - * @throws PaymentVerificationError if not sufficiently confirmed - */ -export async function checkTransactionFinality( - signature: string, - connection?: Connection -): Promise<'confirmed' | 'finalized'> { - const conn = connection || getSolanaConnection(); - const status = await conn.getSignatureStatus(signature); - - if (!status.value) { - throw new PaymentVerificationError( - 'Transaction status unknown', - 'STATUS_UNKNOWN' - ); - } - - const confirmationStatus = status.value.confirmationStatus; - - if (confirmationStatus !== 'confirmed' && confirmationStatus !== 'finalized') { - throw new PaymentVerificationError( - `Transaction not yet confirmed: status is '${confirmationStatus || 'pending'}'`, - 'NOT_CONFIRMED' - ); - } - - return confirmationStatus; -} - -// ============================================ -// 2.2.9: Handle Partial/Failed Transactions -// ============================================ - -/** - * Validate transaction was successful (no errors). - * Already handled in fetchTransaction, but exposed for explicit checks. - * - * @param tx - The parsed transaction to check - * @throws PaymentVerificationError if transaction failed - */ -export function validateTransactionSuccess( - tx: ParsedTransactionWithMeta -): void { - if (tx.meta?.err) { - const errorMsg = JSON.stringify(tx.meta.err); - throw new PaymentVerificationError( - `Transaction failed: ${errorMsg}`, - 'TX_FAILED' - ); - } -} - -// ============================================ -// Main Verification Function (combines all checks) -// ============================================ - -/** - * Complete payment verification for a Solana USDC transaction. - * Performs all validation checks in sequence: - * 1. Fetch transaction with fallback - * 2. Check transaction success (no errors) - * 3. Parse token transfers - * 4. Find USDC transfer - * 5. Validate recipient - * 6. Validate amount - * 7. Parse and validate memo - * 8. Check transaction finality - * - * @param options - Verification options - * @returns Verified payment details - * @throws PaymentVerificationError if any validation fails - */ -export async function verifyPayment( - options: VerifyPaymentOptions -): Promise { - const { - signature, - expectedPostId, - expectedAmountRaw, - requestTimestamp, - memoExpirationSeconds = 300, - } = options; - - // 1. Fetch transaction (also checks for on-chain errors) - const tx = await fetchTransaction(signature); - - // 2. Validate transaction success (explicit double-check) - validateTransactionSuccess(tx); - - // 3. Parse all token transfers - const transfers = parseTokenTransfers(tx); - - // 4. Find USDC transfer - const usdcTransfer = findUsdcTransfer(transfers); - - // 5. Validate recipient - validateRecipient(usdcTransfer); - - // 6. Validate amount - validateAmount(usdcTransfer, expectedAmountRaw); - - // 7. Parse and validate memo - const memoStr = parseMemo(tx); - const parsedMemo = parseMemoFormat(memoStr); - validateMemo(parsedMemo, expectedPostId, requestTimestamp, memoExpirationSeconds); - - // 8. Check transaction finality - const confirmationStatus = await checkTransactionFinality(signature); - - // Return verified payment details - return { - signature, - payer: usdcTransfer.source, - recipient: usdcTransfer.destination, - amount: usdcTransfer.amount, - mint: usdcTransfer.mint, - memo: memoStr, - postId: parsedMemo.postId, - timestamp: parsedMemo.timestamp, - confirmationStatus, - }; -} diff --git a/lib/splits/config.ts b/lib/splits/config.ts new file mode 100644 index 0000000..0405880 --- /dev/null +++ b/lib/splits/config.ts @@ -0,0 +1,117 @@ +/** + * 0xSplits Configuration + * + * Contract addresses and ABIs for PushSplitFactory and PushSplit on Base. + * Uses pre-deployed contracts from 0xSplits team. + */ + +import { createPublicClient, createWalletClient, http } from 'viem'; +import { base } from 'viem/chains'; +import { privateKeyToAccount } from 'viem/accounts'; + +// PushSplitFactoryV2.2 on Base mainnet +export const PUSH_SPLIT_FACTORY_ADDRESS = '0x8E8eB0cC6AE34A38B67D5Cf91ACa38f60bc3Ecf4' as const; + +// SplitsWarehouse on Base mainnet +export const SPLITS_WAREHOUSE_ADDRESS = '0x8fb66F38cF86A3d5e8768f8F1754A24A6c661Fb8' as const; + +// USDC on Base +export const BASE_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; + +// Platform treasury +export const PLATFORM_TREASURY_ADDRESS = process.env.BASE_TREASURY_ADDRESS || '0xF1F9448354F99fAe1D29A4c82DC839c16e72AfD5'; + +// ABI for PushSplitFactory.createSplit +export const PUSH_SPLIT_FACTORY_ABI = [ + { + type: 'function', + name: 'createSplit', + inputs: [ + { + name: '_splitParams', + type: 'tuple', + components: [ + { name: 'recipients', type: 'address[]' }, + { name: 'allocations', type: 'uint256[]' }, + { name: 'totalAllocation', type: 'uint256' }, + { name: 'distributionIncentive', type: 'uint16' }, + ], + }, + { name: '_owner', type: 'address' }, + { name: '_creator', type: 'address' }, + ], + outputs: [{ name: 'split', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'event', + name: 'SplitCreated', + inputs: [ + { name: 'split', type: 'address', indexed: true }, + { + name: 'splitParams', + type: 'tuple', + indexed: false, + components: [ + { name: 'recipients', type: 'address[]' }, + { name: 'allocations', type: 'uint256[]' }, + { name: 'totalAllocation', type: 'uint256' }, + { name: 'distributionIncentive', type: 'uint16' }, + ], + }, + { name: 'owner', type: 'address', indexed: false }, + { name: 'creator', type: 'address', indexed: false }, + { name: 'nonce', type: 'uint256', indexed: false }, + ], + }, +] as const; + +// ABI for PushSplit.distribute +export const PUSH_SPLIT_ABI = [ + { + type: 'function', + name: 'distribute', + inputs: [ + { + name: '_split', + type: 'tuple', + components: [ + { name: 'recipients', type: 'address[]' }, + { name: 'allocations', type: 'uint256[]' }, + { name: 'totalAllocation', type: 'uint256' }, + { name: 'distributionIncentive', type: 'uint16' }, + ], + }, + { name: '_token', type: 'address' }, + { name: '_distributor', type: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const; + +/** + * Get a public client for reading from Base. + */ +export function getPublicClient() { + return createPublicClient({ + chain: base, + transport: http(process.env.BASE_RPC_URL || process.env.NEXT_PUBLIC_BASE_RPC_URL || 'https://mainnet.base.org'), + }); +} + +/** + * Get a wallet client for write operations (split creation, distribution). + * Uses a server-side deployer private key. + */ +export function getWalletClient() { + const privateKey = process.env.SPLITS_DEPLOYER_PRIVATE_KEY; + if (!privateKey) throw new Error('SPLITS_DEPLOYER_PRIVATE_KEY not set'); + + const account = privateKeyToAccount(privateKey as `0x${string}`); + return createWalletClient({ + account, + chain: base, + transport: http(process.env.BASE_RPC_URL || process.env.NEXT_PUBLIC_BASE_RPC_URL || 'https://mainnet.base.org'), + }); +} diff --git a/lib/splits/create-split.ts b/lib/splits/create-split.ts new file mode 100644 index 0000000..a4528ee --- /dev/null +++ b/lib/splits/create-split.ts @@ -0,0 +1,142 @@ +/** + * Create and cache 0xSplits PushSplit contracts for authors. + * + * Each author gets a unique PushSplit contract that splits incoming funds: + * - 90% to the author + * - 10% to the ClawStack platform treasury + */ + +import { supabaseAdmin } from '@/lib/db/supabase-server'; +import { + getPublicClient, + getWalletClient, + PUSH_SPLIT_FACTORY_ADDRESS, + PUSH_SPLIT_FACTORY_ABI, + PLATFORM_TREASURY_ADDRESS, +} from './config'; +import { decodeEventLog } from 'viem'; + +interface CreateSplitParams { + authorId: string; + authorAddress: string; +} + +/** + * Get or create a split address for an author. + * Checks database first, creates on-chain if not found. + */ +export async function getOrCreateAuthorSplit(params: CreateSplitParams): Promise { + // 1. Check if split already exists in DB + const { data: existing } = await supabaseAdmin + .from('author_splits') + .select('split_address') + .eq('author_id', params.authorId) + .eq('chain', 'base') + .single(); + + if (existing?.split_address) return existing.split_address; + + // 2. Create split on-chain + const splitAddress = await deployPushSplit(params.authorAddress); + + // 3. Store in database + await supabaseAdmin.from('author_splits').insert({ + author_id: params.authorId, + split_address: splitAddress, + author_address: params.authorAddress, + platform_address: PLATFORM_TREASURY_ADDRESS, + author_percentage: 90.00, + platform_percentage: 10.00, + chain: 'base', + chain_id: '8453', + }); + + return splitAddress; +} + +/** + * Deploy a PushSplit contract for an author via PushSplitFactoryV2.2. + */ +async function deployPushSplit(authorAddress: string): Promise { + const walletClient = getWalletClient(); + const publicClient = getPublicClient(); + + // Recipients must be sorted by address (lowercase comparison) + const recipients = [ + { address: authorAddress.toLowerCase(), allocation: 900000n }, // 90% + { address: PLATFORM_TREASURY_ADDRESS.toLowerCase(), allocation: 100000n }, // 10% + ].sort((a, b) => a.address.localeCompare(b.address)); + + const splitParams = { + recipients: recipients.map(r => r.address as `0x${string}`), + allocations: recipients.map(r => r.allocation), + totalAllocation: 1000000n, + distributionIncentive: 0, // no distributor fee + }; + + const hash = await walletClient.writeContract({ + address: PUSH_SPLIT_FACTORY_ADDRESS, + abi: PUSH_SPLIT_FACTORY_ABI, + functionName: 'createSplit', + args: [ + splitParams, + walletClient.account.address, // owner + walletClient.account.address, // creator + ], + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + // Parse the SplitCreated event to get the split address + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: PUSH_SPLIT_FACTORY_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName === 'SplitCreated') { + const splitAddress = (decoded.args as { split: string }).split; + console.log(`PushSplit deployed at ${splitAddress} for author ${authorAddress}`); + return splitAddress; + } + } catch { + // Not the event we're looking for + continue; + } + } + + throw new Error('SplitCreated event not found in transaction receipt'); +} + +/** + * Get the split parameters for an author's split contract. + * Needed for calling distribute(). + */ +export async function getSplitParams(authorId: string) { + const { data } = await supabaseAdmin + .from('author_splits') + .select('split_address, author_address, platform_address') + .eq('author_id', authorId) + .eq('chain', 'base') + .single(); + + if (!data) return null; + + // Reconstruct the split params (must match deployment order) + const recipients = [ + { address: data.author_address.toLowerCase(), allocation: 900000n }, + { address: data.platform_address.toLowerCase(), allocation: 100000n }, + ].sort((a, b) => a.address.localeCompare(b.address)); + + return { + splitAddress: data.split_address, + params: { + recipients: recipients.map(r => r.address as `0x${string}`), + allocations: recipients.map(r => r.allocation), + totalAllocation: 1000000n, + distributionIncentive: 0, + }, + }; +} diff --git a/lib/splits/distribute.ts b/lib/splits/distribute.ts new file mode 100644 index 0000000..6ff8c1c --- /dev/null +++ b/lib/splits/distribute.ts @@ -0,0 +1,64 @@ +/** + * 0xSplits Distribution + * + * Triggers distribution of accumulated USDC in a PushSplit contract. + * PushSplit sends funds directly to recipients (90% author, 10% platform). + */ + +import { + getPublicClient, + getWalletClient, + PUSH_SPLIT_ABI, + BASE_USDC_ADDRESS, +} from './config'; +import { getSplitParams } from './create-split'; + +/** + * Distribute USDC from an author's PushSplit contract. + * This pushes accumulated funds to the author (90%) and platform (10%). + * + * @param authorId - The author's ID to look up their split contract + */ +export async function distributeSplitForAuthor(authorId: string): Promise { + const splitData = await getSplitParams(authorId); + if (!splitData) { + console.warn(`No split contract found for author ${authorId}`); + return null; + } + + return distributeSplit( + splitData.splitAddress, + splitData.params + ); +} + +/** + * Call distribute on a PushSplit contract. + */ +async function distributeSplit( + splitAddress: string, + splitParams: { + recipients: readonly `0x${string}`[]; + allocations: readonly bigint[]; + totalAllocation: bigint; + distributionIncentive: number; + } +): Promise { + const walletClient = getWalletClient(); + const publicClient = getPublicClient(); + + const hash = await walletClient.writeContract({ + address: splitAddress as `0x${string}`, + abi: PUSH_SPLIT_ABI, + functionName: 'distribute', + args: [ + splitParams, + BASE_USDC_ADDRESS, + walletClient.account.address, // distributor + ], + }); + + await publicClient.waitForTransactionReceipt({ hash }); + console.log(`Split distributed: ${splitAddress} (tx: ${hash})`); + return hash; +} diff --git a/lib/splits/index.ts b/lib/splits/index.ts new file mode 100644 index 0000000..0f42ff5 --- /dev/null +++ b/lib/splits/index.ts @@ -0,0 +1,8 @@ +export { getOrCreateAuthorSplit, getSplitParams } from './create-split'; +export { distributeSplitForAuthor } from './distribute'; +export { + PUSH_SPLIT_FACTORY_ADDRESS, + SPLITS_WAREHOUSE_ADDRESS, + PLATFORM_TREASURY_ADDRESS, + BASE_USDC_ADDRESS, +} from './config'; diff --git a/lib/x402/helpers.ts b/lib/x402/helpers.ts index a543060..8a1f643 100644 --- a/lib/x402/helpers.ts +++ b/lib/x402/helpers.ts @@ -1,56 +1,15 @@ /** * x402 Protocol Helper Functions * - * Utilities for generating payment options, memos, and validity windows. + * Utilities for generating payment options, references, and validity windows. * * @see claude/knowledge/prd.md Section 2.2 (x402 Payment Flow) - * @see claude/operations/tasks.md Tasks 2.3.2-2.3.4 */ -import { PaymentOption, X402_CONFIG, PaymentChain } from './types'; +import { PaymentOption, X402_CONFIG } from './types'; // ============================================ -// 2.3.2: Generate Payment Memo with Timestamp -// ============================================ - -/** - * Generate a payment memo for Solana transactions. - * Format: "clawstack:{postId}:{unixTimestamp}" - * - * The memo is included in the Solana transaction and used to: - * 1. Associate the payment with a specific post - * 2. Prevent replay attacks by including timestamp - * 3. Enable verification by checking memo matches expected format - * - * @param resourceId - The post ID or resource identifier - * @param resourceType - Type of resource (default: 'post') - * @returns Formatted memo string - * - * @example - * generatePaymentMemo('abc123') - * // Returns: "clawstack:abc123:1706960000" - */ -export function generatePaymentMemo( - resourceId: string, - _resourceType: string = 'post' -): string { - const timestamp = Math.floor(Date.now() / 1000); - return `${X402_CONFIG.MEMO_PREFIX}:${resourceId}:${timestamp}`; -} - -/** - * Generate a spam fee memo for anti-spam payments. - * - * @param agentId - The agent ID paying the spam fee - * @returns Formatted memo string for spam fee - */ -export function generateSpamFeeMemo(agentId: string): string { - const timestamp = Math.floor(Date.now() / 1000); - return `${X402_CONFIG.MEMO_PREFIX}:spam_fee:${agentId}:${timestamp}`; -} - -// ============================================ -// 2.3.3: Calculate Payment Validity Window +// Payment Validity Window // ============================================ /** @@ -59,10 +18,6 @@ export function generateSpamFeeMemo(agentId: string): string { * * @param validitySeconds - Optional override for validity window (default: 300) * @returns ISO 8601 timestamp string - * - * @example - * getPaymentValidUntil() - * // Returns: "2026-02-03T12:30:00.000Z" */ export function getPaymentValidUntil( validitySeconds: number = X402_CONFIG.PAYMENT_VALIDITY_SECONDS @@ -88,63 +43,24 @@ export function isPaymentTimestampValid( } // ============================================ -// 2.3.4: Build Solana Payment Option Object +// Build Base (EVM) Payment Option // ============================================ -/** - * Build a Solana payment option for the 402 response. - * Contains all information needed for a client to construct a Solana transaction. - * - * @param postId - The post ID for memo generation - * @returns Complete Solana payment option object - * - * @example - * buildSolanaPaymentOption('abc123') - * // Returns: { - * // chain: 'solana', - * // chain_id: 'mainnet-beta', - * // recipient: 'CStkPay111...', - * // token_mint: 'EPjFWdd5Aufq...', - * // token_symbol: 'USDC', - * // decimals: 6, - * // memo: 'clawstack:abc123:1706960000' - * // } - */ -export function buildSolanaPaymentOption(postId: string): PaymentOption { - const treasuryPubkey = process.env.SOLANA_TREASURY_PUBKEY; - const usdcMint = process.env.USDC_MINT_SOLANA; - - if (!treasuryPubkey) { - throw new Error('SOLANA_TREASURY_PUBKEY environment variable is not set'); - } - - if (!usdcMint) { - throw new Error('USDC_MINT_SOLANA environment variable is not set'); - } - - return { - chain: 'solana', - chain_id: 'mainnet-beta', - recipient: treasuryPubkey, - token_mint: usdcMint, - token_symbol: 'USDC', - decimals: 6, - memo: generatePaymentMemo(postId), - }; -} - /** * Build a Base (EVM) payment option for the 402 response. - * Placeholder for Phase 3 implementation. * * @param postId - The post ID for reference generation + * @param recipientAddress - Optional recipient address (split address). Defaults to treasury. * @returns Complete Base payment option object */ -export function buildBasePaymentOption(postId: string): PaymentOption { - const treasuryAddress = process.env.BASE_TREASURY_ADDRESS; +export function buildBasePaymentOption( + postId: string, + recipientAddress?: string +): PaymentOption { + const recipient = recipientAddress || process.env.BASE_TREASURY_ADDRESS; const usdcContract = process.env.USDC_CONTRACT_BASE; - if (!treasuryAddress) { + if (!recipient) { throw new Error('BASE_TREASURY_ADDRESS environment variable is not set'); } @@ -158,7 +74,7 @@ export function buildBasePaymentOption(postId: string): PaymentOption { return { chain: 'base', chain_id: '8453', - recipient: treasuryAddress, + recipient, token_contract: usdcContract, token_symbol: 'USDC', decimals: 6, @@ -168,29 +84,23 @@ export function buildBasePaymentOption(postId: string): PaymentOption { /** * Build all available payment options for a post. - * Returns both Solana and Base payment options by default. * * @param postId - The post ID - * @param chains - Optional array of chains to include (default: ['solana', 'base']) + * @param _chains - Ignored, always returns Base only + * @param recipientAddress - Optional split address for payment routing * @returns Array of payment options */ export function buildPaymentOptions( postId: string, - chains: PaymentChain[] = ['solana', 'base'] + _chains?: string[], + recipientAddress?: string ): PaymentOption[] { const options: PaymentOption[] = []; - for (const chain of chains) { - try { - if (chain === 'solana') { - options.push(buildSolanaPaymentOption(postId)); - } else if (chain === 'base') { - options.push(buildBasePaymentOption(postId)); - } - } catch (error) { - // Log but don't fail if one chain is not configured - console.warn(`Failed to build payment option for ${chain}:`, error); - } + try { + options.push(buildBasePaymentOption(postId, recipientAddress)); + } catch (error) { + console.warn('Failed to build payment option for base:', error); } return options; @@ -198,62 +108,33 @@ export function buildPaymentOptions( /** * Build payment options for spam fee payment. - * Returns both Solana and Base payment options by default. * * @param agentId - The agent ID paying the spam fee - * @param chains - Optional array of chains to include (default: ['solana', 'base']) * @returns Array of payment options for spam fee - * - * @see claude/operations/tasks.md Task 2.5.1 */ -export function buildSpamFeePaymentOptions( - agentId: string, - chains: PaymentChain[] = ['solana', 'base'] -): PaymentOption[] { +export function buildSpamFeePaymentOptions(agentId: string): PaymentOption[] { const options: PaymentOption[] = []; - for (const chain of chains) { - try { - if (chain === 'solana') { - const treasuryPubkey = process.env.SOLANA_TREASURY_PUBKEY; - const usdcMint = process.env.USDC_MINT_SOLANA; - - if (!treasuryPubkey || !usdcMint) { - throw new Error('Solana environment variables not configured'); - } - - options.push({ - chain: 'solana', - chain_id: 'mainnet-beta', - recipient: treasuryPubkey, - token_mint: usdcMint, - token_symbol: 'USDC', - decimals: 6, - memo: generateSpamFeeMemo(agentId), - }); - } else if (chain === 'base') { - // Phase 3: Add Base spam fee payment option - const treasuryAddress = process.env.BASE_TREASURY_ADDRESS; - const usdcContract = process.env.USDC_CONTRACT_BASE; + try { + const treasuryAddress = process.env.BASE_TREASURY_ADDRESS; + const usdcContract = process.env.USDC_CONTRACT_BASE; - if (!treasuryAddress || !usdcContract) { - throw new Error('Base environment variables not configured'); - } - - const timestamp = Math.floor(Date.now() / 1000); - options.push({ - chain: 'base', - chain_id: '8453', - recipient: treasuryAddress, - token_contract: usdcContract, - token_symbol: 'USDC', - decimals: 6, - reference: `0xclawstack_spam_fee_${agentId}_${timestamp}`, - }); - } - } catch (error) { - console.warn(`Failed to build spam fee option for ${chain}:`, error); + if (!treasuryAddress || !usdcContract) { + throw new Error('Base environment variables not configured'); } + + const timestamp = Math.floor(Date.now() / 1000); + options.push({ + chain: 'base', + chain_id: '8453', + recipient: treasuryAddress, + token_contract: usdcContract, + token_symbol: 'USDC', + decimals: 6, + reference: `0xclawstack_spam_fee_${agentId}_${timestamp}`, + }); + } catch (error) { + console.warn('Failed to build spam fee option for base:', error); } return options; diff --git a/lib/x402/index.ts b/lib/x402/index.ts index dc7fb78..64ebed4 100644 --- a/lib/x402/index.ts +++ b/lib/x402/index.ts @@ -2,7 +2,7 @@ * x402 Payment Protocol Implementation * * This module provides the x402 HTTP 402 Payment Required protocol - * implementation for ClawStack micropayments. + * implementation for ClawStack micropayments on Base. * * @see claude/knowledge/prd.md Section 2.2 (x402 Payment Flow) */ @@ -21,11 +21,8 @@ export { X402_CONFIG } from './types'; // Helper functions export { - generatePaymentMemo, - generateSpamFeeMemo, getPaymentValidUntil, isPaymentTimestampValid, - buildSolanaPaymentOption, buildBasePaymentOption, buildPaymentOptions, usdcToRaw, diff --git a/lib/x402/types.ts b/lib/x402/types.ts index 5fba415..f6b17b3 100644 --- a/lib/x402/types.ts +++ b/lib/x402/types.ts @@ -5,13 +5,12 @@ * Payment Required protocol implementation. * * @see claude/knowledge/prd.md Section 2.2 (x402 Payment Flow) - * @see claude/operations/tasks.md Task 2.3.1 */ /** * Supported blockchain networks for payments. */ -export type PaymentChain = 'solana' | 'base'; +export type PaymentChain = 'base'; /** * Payment option structure returned in 402 responses. @@ -21,15 +20,12 @@ export interface PaymentOption { /** Blockchain network identifier */ chain: PaymentChain; - /** Chain-specific network ID (e.g., 'mainnet-beta' for Solana, '8453' for Base) */ + /** Chain-specific network ID ('8453' for Base) */ chain_id: string; - /** Recipient wallet/contract address */ + /** Recipient wallet/contract address (split address or treasury) */ recipient: string; - /** Token mint address (Solana SPL tokens) */ - token_mint?: string; - /** Token contract address (EVM ERC-20 tokens) */ token_contract?: string; @@ -39,9 +35,6 @@ export interface PaymentOption { /** Token decimal places (6 for USDC) */ decimals: number; - /** Payment memo/reference (Solana - included in transaction) */ - memo?: string; - /** Payment reference (EVM - used for tracking) */ reference?: string; } @@ -153,7 +146,6 @@ export interface PostForPayment { id: string; display_name: string; avatar_url: string | null; - wallet_solana: string | null; wallet_base: string | null; }; } diff --git a/lib/x402/verify.ts b/lib/x402/verify.ts index cded3f7..57631b7 100644 --- a/lib/x402/verify.ts +++ b/lib/x402/verify.ts @@ -2,10 +2,7 @@ * x402 Payment Verification Module * * Handles parsing of X-Payment-Proof headers and routing to - * chain-specific verifiers (Solana/Base). - * - * @see claude/knowledge/prd.md Section 2.2 (x402 Payment Flow) - * @see claude/operations/tasks.md Tasks 2.3.6-2.3.10 + * the Base (EVM) verifier. */ import { @@ -15,10 +12,6 @@ import { X402_CONFIG, } from './types'; import { usdcToRaw, rawToUsdc } from './helpers'; -import { - verifyPayment as verifySolanaPayment, - PaymentVerificationError, -} from '@/lib/solana/verify'; import { verifyEVMPayment, EVMPaymentVerificationError, @@ -33,10 +26,6 @@ import { Redis } from '@upstash/redis'; let redis: Redis | null = null; -/** - * Get Redis client singleton. - * Returns null if Upstash is not configured. - */ function getRedis(): Redis | null { if (redis) return redis; @@ -54,19 +43,12 @@ function getRedis(): Redis | null { } // ============================================ -// 2.3.6: Parse X-Payment-Proof Header +// Parse X-Payment-Proof Header // ============================================ /** * Parse the X-Payment-Proof header from a request. * Expected format: JSON with chain, transaction_signature, payer_address, timestamp - * - * @param header - The raw header value (may be null) - * @returns Parsed PaymentProof or null if invalid/missing - * - * @example - * parsePaymentProof('{"chain":"solana","transaction_signature":"5xK3v...","payer_address":"7sK9x...","timestamp":1706959800}') - * // Returns: { chain: 'solana', transaction_signature: '5xK3v...', ... } */ export function parsePaymentProof(header: string | null): PaymentProof | null { if (!header) { @@ -76,7 +58,6 @@ export function parsePaymentProof(header: string | null): PaymentProof | null { try { const proof = JSON.parse(header); - // Validate required fields if (!proof.chain || typeof proof.chain !== 'string') { console.warn('Payment proof missing or invalid chain'); return null; @@ -92,22 +73,18 @@ export function parsePaymentProof(header: string | null): PaymentProof | null { return null; } - // Validate chain is supported - if (proof.chain !== 'solana' && proof.chain !== 'base') { + // Only support Base + if (proof.chain !== 'base') { console.warn(`Unsupported payment chain: ${proof.chain}`); return null; } - // Validate transaction signature format based on chain - if (proof.chain === 'base') { - // EVM tx hashes are 66 chars (0x + 64 hex) - if (!isValidTransactionHash(proof.transaction_signature)) { - console.warn('Invalid EVM transaction hash format'); - return null; - } + // EVM tx hashes are 66 chars (0x + 64 hex) + if (!isValidTransactionHash(proof.transaction_signature)) { + console.warn('Invalid EVM transaction hash format'); + return null; } - // Timestamp is optional but should be a number if present const timestamp = typeof proof.timestamp === 'number' ? proof.timestamp : Math.floor(Date.now() / 1000); @@ -125,26 +102,15 @@ export function parsePaymentProof(header: string | null): PaymentProof | null { } // ============================================ -// 2.3.8: Payment Proof Caching +// Payment Proof Caching // ============================================ -/** Cache TTL in seconds (1 hour) */ const CACHE_TTL_SECONDS = 3600; -/** - * Generate cache key for a payment proof. - */ function getPaymentCacheKey(network: string, signature: string): string { return `payment:verified:${network}:${signature}`; } -/** - * Check if a payment has already been verified and cached. - * - * @param network - The blockchain network - * @param signature - The transaction signature - * @returns True if payment is cached as verified - */ export async function checkPaymentCache( network: string, signature: string @@ -161,12 +127,6 @@ export async function checkPaymentCache( } } -/** - * Cache a verified payment to avoid re-verification. - * - * @param network - The blockchain network - * @param signature - The transaction signature - */ export async function cacheVerifiedPayment( network: string, signature: string @@ -186,22 +146,17 @@ export async function cacheVerifiedPayment( } // ============================================ -// 2.3.7: Route to Chain-Specific Verifier +// Verify Payment (Base only) // ============================================ /** - * Verify a payment proof by routing to the appropriate chain verifier. - * Currently supports Solana; Base will be added in Phase 3. - * - * @param proof - The parsed payment proof - * @param post - The post being accessed - * @returns Verification result + * Verify a payment proof by routing to the Base EVM verifier. */ export async function verifyPayment( proof: PaymentProof, post: PostForPayment ): Promise { - // Check for double-spend: verify transaction hasn't been used before + // Check for double-spend const { data: existingPayment } = await supabaseAdmin .from('payment_events') .select('id, resource_type, resource_id') @@ -220,7 +175,7 @@ export async function verifyPayment( }; } - // Check cache first + // Check cache const isCached = await checkPaymentCache(proof.chain, proof.transaction_signature); if (isCached) { return { @@ -228,86 +183,21 @@ export async function verifyPayment( payment: { signature: proof.transaction_signature, payer: proof.payer_address, - recipient: proof.chain === 'solana' - ? process.env.SOLANA_TREASURY_PUBKEY || '' - : process.env.BASE_TREASURY_ADDRESS || '', + recipient: process.env.BASE_TREASURY_ADDRESS || '', amount_raw: usdcToRaw(post.price_usdc || 0), amount_usdc: (post.price_usdc || 0).toFixed(2), - network: proof.chain, - chain_id: proof.chain === 'solana' ? 'mainnet-beta' : '8453', + network: 'base', + chain_id: '8453', }, }; } - // Route to appropriate verifier - switch (proof.chain) { - case 'solana': - return verifySolanaPaymentProof(proof, post); - case 'base': - return verifyBasePaymentProof(proof, post); - default: - return { - success: false, - error: `Unsupported payment chain: ${proof.chain}`, - error_code: 'UNSUPPORTED_CHAIN', - }; - } -} - -/** - * Verify a Solana payment proof. - */ -async function verifySolanaPaymentProof( - proof: PaymentProof, - post: PostForPayment -): Promise { - try { - const expectedAmountRaw = usdcToRaw(post.price_usdc || 0); - - const verifiedPayment = await verifySolanaPayment({ - signature: proof.transaction_signature, - expectedPostId: post.id, - expectedAmountRaw, - requestTimestamp: proof.timestamp, - memoExpirationSeconds: X402_CONFIG.PAYMENT_VALIDITY_SECONDS, - }); - - // Cache the verified payment - await cacheVerifiedPayment('solana', proof.transaction_signature); - - return { - success: true, - payment: { - signature: verifiedPayment.signature, - payer: verifiedPayment.payer, - recipient: verifiedPayment.recipient, - amount_raw: verifiedPayment.amount, - amount_usdc: rawToUsdc(verifiedPayment.amount), - network: 'solana', - chain_id: 'mainnet-beta', - }, - }; - } catch (error) { - if (error instanceof PaymentVerificationError) { - return { - success: false, - error: error.message, - error_code: error.code, - }; - } - - console.error('Unexpected error verifying Solana payment:', error); - return { - success: false, - error: 'Unexpected verification error', - error_code: 'INTERNAL_ERROR', - }; - } + // Verify on Base + return verifyBasePaymentProof(proof, post); } /** * Verify a Base (EVM) payment proof. - * Routes to the EVM verifier for USDC transfer validation. */ async function verifyBasePaymentProof( proof: PaymentProof, @@ -358,36 +248,25 @@ async function verifyBasePaymentProof( } // ============================================ -// 2.3.9: Record Payment Event on Success +// Record Payment Event & Grant Access // ============================================ /** - * Platform fee in basis points (500 = 5%) + * Platform fee in basis points (1000 = 10%) */ const PLATFORM_FEE_BPS = parseInt(process.env.PLATFORM_FEE_BPS || '1000', 10); -/** - * Calculate platform fee from gross amount. - */ export function calculatePlatformFee(grossAmountRaw: bigint): bigint { return (grossAmountRaw * BigInt(PLATFORM_FEE_BPS)) / BigInt(10000); } -/** - * Calculate author amount after platform fee. - */ export function calculateAuthorAmount(grossAmountRaw: bigint): bigint { return grossAmountRaw - calculatePlatformFee(grossAmountRaw); } /** - * Record a verified payment event in the database. - * Calculates 95/5 split between author and platform. - * - * @param proof - The payment proof - * @param post - The post that was paid for - * @param verificationResult - The verification result with payment details - * @returns The inserted payment event ID or null on error + * Record a verified payment event in the database and grant permanent access. + * Calculates 90/10 split between author and platform. */ export async function recordPaymentEvent( proof: PaymentProof, @@ -404,11 +283,7 @@ export async function recordPaymentEvent( const platformFeeRaw = calculatePlatformFee(grossAmountRaw); const authorAmountRaw = calculateAuthorAmount(grossAmountRaw); - // Get recipient address based on network - const recipientAddress = - payment.network === 'solana' - ? post.author.wallet_solana - : post.author.wallet_base; + const recipientAddress = post.author.wallet_base; if (!recipientAddress) { console.error( @@ -439,12 +314,12 @@ export async function recordPaymentEvent( .single(); if (error) { - // Check for unique constraint violation (double-spend attempt) if (error.code === '23505') { console.warn( `Payment already recorded: ${payment.network}:${payment.signature}` ); - // Not an error - payment was already processed + // Still grant access even if payment was already recorded + await grantArticleAccess(post.id, payment.payer, 'already_recorded', payment.signature, payment.network); return 'already_recorded'; } @@ -456,6 +331,12 @@ export async function recordPaymentEvent( `Payment recorded: ${data.id} - ${rawToUsdc(grossAmountRaw)} USDC (author: ${rawToUsdc(authorAmountRaw)}, platform: ${rawToUsdc(platformFeeRaw)})` ); + // Grant permanent article access + await grantArticleAccess(post.id, payment.payer, data.id, payment.signature, payment.network); + + // Fire-and-forget: distribute split funds + void triggerSplitDistribution(post.author_id); + return data.id; } catch (error) { console.error('Unexpected error recording payment:', error); @@ -463,9 +344,51 @@ export async function recordPaymentEvent( } } +/** + * Grant permanent access to an article for a payer. + */ +async function grantArticleAccess( + postId: string, + payerAddress: string, + paymentEventId: string, + transactionSignature: string, + network: string +): Promise { + try { + await supabaseAdmin + .from('article_access') + .upsert( + { + post_id: postId, + payer_address: payerAddress.toLowerCase(), + payment_event_id: paymentEventId === 'already_recorded' ? null : paymentEventId, + transaction_signature: transactionSignature, + network, + }, + { onConflict: 'post_id,payer_address' } + ); + + console.log(`Access granted: ${payerAddress} → post ${postId}`); + } catch (error) { + console.error('Failed to grant article access:', error); + } +} + +/** + * Fire-and-forget: trigger 0xSplits distribution for an author's split contract. + */ +async function triggerSplitDistribution(authorId: string): Promise { + try { + const { distributeSplitForAuthor } = await import('@/lib/splits'); + await distributeSplitForAuthor(authorId); + } catch (error) { + // Non-fatal: funds sit in split contract until next distribution + console.error('Failed to distribute split:', error); + } +} + /** * Increment paid view count for a post. - * Uses a simple update - for high-traffic, consider using a database function. */ export async function incrementPaidViewCount( postId: string, @@ -482,26 +405,17 @@ export async function incrementPaidViewCount( } // ============================================ -// 2.5.2: Spam Fee Payment Verification +// Spam Fee Payment Verification (Base only) // ============================================ /** * Verify a spam fee payment proof. - * Similar to content payment verification but for anti-spam fees. - * - * @param proof - The payment proof - * @param agentId - The agent ID paying the spam fee - * @param expectedFeeUsdc - Expected spam fee amount in USDC - * @returns Verification result - * - * @see claude/operations/tasks.md Task 2.5.2 */ export async function verifySpamFeePayment( proof: PaymentProof, agentId: string, expectedFeeUsdc: string ): Promise { - // Check cache first const isCached = await checkPaymentCache(proof.chain, proof.transaction_signature); if (isCached) { return { @@ -509,83 +423,16 @@ export async function verifySpamFeePayment( payment: { signature: proof.transaction_signature, payer: proof.payer_address, - recipient: proof.chain === 'solana' - ? process.env.SOLANA_TREASURY_PUBKEY || '' - : process.env.BASE_TREASURY_ADDRESS || '', + recipient: process.env.BASE_TREASURY_ADDRESS || '', amount_raw: usdcToRaw(parseFloat(expectedFeeUsdc)), amount_usdc: expectedFeeUsdc, - network: proof.chain, - chain_id: proof.chain === 'solana' ? 'mainnet-beta' : '8453', + network: 'base', + chain_id: '8453', }, }; } - // Route to appropriate verifier - switch (proof.chain) { - case 'solana': - return verifySolanaSpamFeePayment(proof, agentId, expectedFeeUsdc); - case 'base': - return verifyBaseSpamFeePayment(proof, agentId, expectedFeeUsdc); - default: - return { - success: false, - error: `Unsupported payment chain: ${proof.chain}`, - error_code: 'UNSUPPORTED_CHAIN', - }; - } -} - -/** - * Verify a Solana spam fee payment. - */ -async function verifySolanaSpamFeePayment( - proof: PaymentProof, - agentId: string, - expectedFeeUsdc: string -): Promise { - try { - const expectedAmountRaw = usdcToRaw(parseFloat(expectedFeeUsdc)); - - // Verify the payment with spam_fee memo - const verifiedPayment = await verifySolanaPayment({ - signature: proof.transaction_signature, - expectedPostId: `spam_fee:${agentId}`, - expectedAmountRaw, - requestTimestamp: proof.timestamp, - memoExpirationSeconds: X402_CONFIG.PAYMENT_VALIDITY_SECONDS, - }); - - // Cache the verified payment - await cacheVerifiedPayment('solana', proof.transaction_signature); - - return { - success: true, - payment: { - signature: verifiedPayment.signature, - payer: verifiedPayment.payer, - recipient: verifiedPayment.recipient, - amount_raw: verifiedPayment.amount, - amount_usdc: rawToUsdc(verifiedPayment.amount), - network: 'solana', - chain_id: 'mainnet-beta', - }, - }; - } catch (error) { - if (error instanceof PaymentVerificationError) { - return { - success: false, - error: error.message, - error_code: error.code, - }; - } - - console.error('Unexpected error verifying Solana spam fee payment:', error); - return { - success: false, - error: 'Unexpected verification error', - error_code: 'INTERNAL_ERROR', - }; - } + return verifyBaseSpamFeePayment(proof, agentId, expectedFeeUsdc); } /** @@ -599,7 +446,6 @@ async function verifyBaseSpamFeePayment( try { const expectedAmountRaw = usdcToRaw(parseFloat(expectedFeeUsdc)); - // Verify the payment - for spam fees we use a special reference format const verifiedPayment = await verifyEVMPayment({ transactionHash: proof.transaction_signature as `0x${string}`, expectedPostId: `spam_fee:${agentId}`, @@ -608,7 +454,6 @@ async function verifyBaseSpamFeePayment( referenceExpirationSeconds: X402_CONFIG.PAYMENT_VALIDITY_SECONDS, }); - // Cache the verified payment await cacheVerifiedPayment('base', proof.transaction_signature); return { @@ -644,13 +489,6 @@ async function verifyBaseSpamFeePayment( /** * Record a spam fee payment event in the database. * Spam fees go 100% to platform treasury (no author split). - * - * @param proof - The payment proof - * @param agentId - The agent ID paying the spam fee - * @param verificationResult - The verification result with payment details - * @returns The inserted payment event ID or null on error - * - * @see claude/operations/tasks.md Task 2.5.4 */ export async function recordSpamFeePayment( proof: PaymentProof, @@ -664,8 +502,6 @@ export async function recordSpamFeePayment( const payment = verificationResult.payment; const grossAmountRaw = payment.amount_raw; - - // Spam fees go 100% to platform (no author split) const platformFeeRaw = grossAmountRaw; const authorAmountRaw = BigInt(0); @@ -674,13 +510,13 @@ export async function recordSpamFeePayment( .from('payment_events') .insert({ resource_type: 'spam_fee' as const, - resource_id: agentId, // Agent paying the fee + resource_id: agentId, network: payment.network, chain_id: payment.chain_id, transaction_signature: payment.signature, payer_address: payment.payer, - recipient_id: null, // No author recipient for spam fees - recipient_address: payment.recipient, // Platform treasury + recipient_id: null, + recipient_address: payment.recipient, gross_amount_raw: Number(grossAmountRaw), platform_fee_raw: Number(platformFeeRaw), author_amount_raw: Number(authorAmountRaw), @@ -692,7 +528,6 @@ export async function recordSpamFeePayment( .single(); if (error) { - // Check for unique constraint violation (double-spend attempt) if (error.code === '23505') { console.warn( `Spam fee payment already recorded: ${payment.network}:${payment.signature}` diff --git a/types/database.ts b/types/database.ts index 950d440..a2a17b1 100644 --- a/types/database.ts +++ b/types/database.ts @@ -640,6 +640,110 @@ export interface Database { }, ]; }; + article_access: { + Row: { + id: string; + post_id: string; + payer_address: string; + payer_id: string | null; + payment_event_id: string | null; + transaction_signature: string; + network: string; + granted_at: string; + created_at: string; + }; + Insert: { + id?: string; + post_id: string; + payer_address: string; + payer_id?: string | null; + payment_event_id?: string | null; + transaction_signature: string; + network?: string; + granted_at?: string; + created_at?: string; + }; + Update: { + id?: string; + post_id?: string; + payer_address?: string; + payer_id?: string | null; + payment_event_id?: string | null; + transaction_signature?: string; + network?: string; + granted_at?: string; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'article_access_post_id_fkey'; + columns: ['post_id']; + referencedRelation: 'posts'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'article_access_payer_id_fkey'; + columns: ['payer_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'article_access_payment_event_id_fkey'; + columns: ['payment_event_id']; + referencedRelation: 'payment_events'; + referencedColumns: ['id']; + }, + ]; + }; + author_splits: { + Row: { + id: string; + author_id: string; + split_address: string; + author_address: string; + platform_address: string; + author_percentage: number; + platform_percentage: number; + chain: string; + chain_id: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + author_id: string; + split_address: string; + author_address: string; + platform_address: string; + author_percentage?: number; + platform_percentage?: number; + chain?: string; + chain_id?: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + author_id?: string; + split_address?: string; + author_address?: string; + platform_address?: string; + author_percentage?: number; + platform_percentage?: number; + chain?: string; + chain_id?: string; + created_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'author_splits_author_id_fkey'; + columns: ['author_id']; + referencedRelation: 'agents'; + referencedColumns: ['id']; + }, + ]; + }; }; Views: Record; Functions: Record; @@ -718,3 +822,17 @@ export type CrossPostLogInsert = Database['public']['Tables']['cross_post_logs']['Insert']; export type CrossPostLogUpdate = Database['public']['Tables']['cross_post_logs']['Update']; + +export type ArticleAccess = + Database['public']['Tables']['article_access']['Row']; +export type ArticleAccessInsert = + Database['public']['Tables']['article_access']['Insert']; +export type ArticleAccessUpdate = + Database['public']['Tables']['article_access']['Update']; + +export type AuthorSplit = + Database['public']['Tables']['author_splits']['Row']; +export type AuthorSplitInsert = + Database['public']['Tables']['author_splits']['Insert']; +export type AuthorSplitUpdate = + Database['public']['Tables']['author_splits']['Update'];