11import { Request , Response , NextFunction } from 'express' ;
2+ import nacl from 'tweetnacl' ;
3+ import bs58 from 'bs58' ;
4+ import { createHash } from 'crypto' ;
25
36// x402 Payment Protocol — Solana USDC server middleware
4- // Spec: https://www.x402.org/
5- // Facilitator : https://x402.coinbase.com
7+ // Self-verified Ed25519 signatures (no external facilitator needed)
8+ // Spec inspiration : https://www. x402.org/
69
7- const COINBASE_FACILITATOR_URL = 'https://x402.coinbase.com' ;
810const USDC_DECIMALS = 6 ;
11+ const MAX_PAYMENT_AGE_SECONDS = 600 ; // 10 minutes
912
1013export interface X402ServerConfig {
1114 payToWallet : string ;
1215 network : 'solana' | 'solana-devnet' ;
13- facilitatorUrl ?: string ;
1416 priceUsd : number ;
1517 description ?: string ;
1618}
@@ -81,30 +83,122 @@ export function send402(res: Response, config: X402ServerConfig, resource: strin
8183 } ) ;
8284}
8385
84- async function verifyWithFacilitator ( payload : any , facilitatorUrl : string ) : Promise < boolean > {
86+ interface X402PaymentPayload {
87+ paymentOption : {
88+ scheme : string ;
89+ network : string ;
90+ asset : string ;
91+ maxAmountRequired : string ;
92+ payTo : string ;
93+ validUntil ?: number ;
94+ } ;
95+ signature : string ; // base58 Ed25519 signature
96+ payer : string ; // base58 public key
97+ nonce : string ;
98+ timestamp : number ;
99+ }
100+
101+ /**
102+ * Verify x402 payment payload by checking Ed25519 signature server-side.
103+ * 1. Reconstruct the canonical message from the payment option fields
104+ * 2. SHA-256 hash it
105+ * 3. Verify Ed25519 signature against the claimed payer's public key
106+ * 4. Check timestamp freshness and payment option matches our config
107+ *
108+ * Returns the verified payer public key (base58) or null if invalid.
109+ */
110+ function verifyPaymentSignature ( payload : X402PaymentPayload , config : X402ServerConfig ) : string | null {
85111 try {
86- const response = await fetch ( `${ facilitatorUrl } /verify` , {
87- method : 'POST' ,
88- headers : { 'Content-Type' : 'application/json' } ,
89- body : JSON . stringify ( payload ) ,
112+ const { paymentOption, signature, payer, nonce, timestamp } = payload ;
113+
114+ // Validate required fields
115+ if ( ! paymentOption || ! signature || ! payer || ! nonce || ! timestamp ) {
116+ console . error ( '[x402] Missing required fields in payment payload' ) ;
117+ return null ;
118+ }
119+
120+ // Check timestamp freshness (reject stale payments)
121+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
122+ if ( Math . abs ( now - timestamp ) > MAX_PAYMENT_AGE_SECONDS ) {
123+ console . error ( `[x402] Payment timestamp too old/future: ${ timestamp } vs ${ now } ` ) ;
124+ return null ;
125+ }
126+
127+ // Verify payment option matches our config
128+ if ( paymentOption . payTo !== config . payToWallet ) {
129+ console . error ( `[x402] payTo mismatch: ${ paymentOption . payTo } !== ${ config . payToWallet } ` ) ;
130+ return null ;
131+ }
132+
133+ const expectedAmount = usdToLamports ( config . priceUsd ) ;
134+ if ( BigInt ( paymentOption . maxAmountRequired ) < BigInt ( expectedAmount ) ) {
135+ console . error ( `[x402] Amount too low: ${ paymentOption . maxAmountRequired } < ${ expectedAmount } ` ) ;
136+ return null ;
137+ }
138+
139+ // Reconstruct the canonical message (must match client-side construction)
140+ const message = JSON . stringify ( {
141+ scheme : paymentOption . scheme ,
142+ network : paymentOption . network ,
143+ asset : paymentOption . asset ,
144+ amount : paymentOption . maxAmountRequired ,
145+ payTo : paymentOption . payTo ,
146+ nonce,
147+ timestamp,
148+ validUntil : paymentOption . validUntil ?? timestamp + 300 ,
90149 } ) ;
91- if ( ! response . ok ) return false ;
92- const result = await response . json ( ) as { valid ?: boolean ; settled ?: boolean } ;
93- return result . valid === true && ( result . settled === true || result . settled === undefined ) ;
150+
151+ // SHA-256 hash of the message
152+ const messageBytes = new TextEncoder ( ) . encode ( message ) ;
153+ const messageHash = createHash ( 'sha256' ) . update ( messageBytes ) . digest ( ) ;
154+
155+ // Decode payer public key and signature from base58
156+ let publicKeyBytes : Uint8Array ;
157+ let signatureBytes : Uint8Array ;
158+ try {
159+ publicKeyBytes = bs58 . decode ( payer ) ;
160+ signatureBytes = bs58 . decode ( signature ) ;
161+ } catch {
162+ console . error ( '[x402] Failed to decode base58 payer/signature' ) ;
163+ return null ;
164+ }
165+
166+ if ( publicKeyBytes . length !== 32 ) {
167+ console . error ( `[x402] Invalid public key length: ${ publicKeyBytes . length } ` ) ;
168+ return null ;
169+ }
170+
171+ if ( signatureBytes . length !== 64 ) {
172+ console . error ( `[x402] Invalid signature length: ${ signatureBytes . length } ` ) ;
173+ return null ;
174+ }
175+
176+ // Verify Ed25519 signature
177+ const valid = nacl . sign . detached . verify (
178+ new Uint8Array ( messageHash ) ,
179+ signatureBytes ,
180+ publicKeyBytes ,
181+ ) ;
182+
183+ if ( ! valid ) {
184+ console . error ( '[x402] Ed25519 signature verification failed' ) ;
185+ return null ;
186+ }
187+
188+ // Signature verified — return the proven payer identity
189+ return payer ;
94190 } catch ( err ) {
95- console . error ( '[x402] Facilitator verification error:' , err ) ;
96- return false ;
191+ console . error ( '[x402] Signature verification error:' , err ) ;
192+ return null ;
97193 }
98194}
99195
100196/**
101197 * x402 middleware for paid endpoints (agents/bots, no JWT required).
102- * If X-PAYMENT header present → verify with Coinbase facilitator → allow.
198+ * If X-PAYMENT header present → verify Ed25519 signature server-side → allow.
103199 * Otherwise → 402 with USDC payment instructions.
104200 */
105201export function createX402Middleware ( config : X402ServerConfig ) {
106- const facilitatorUrl = config . facilitatorUrl || COINBASE_FACILITATOR_URL ;
107-
108202 return async ( req : Request , res : Response , next : NextFunction ) : Promise < void > => {
109203 const paymentHeader = req . headers [ 'x-payment' ] as string | undefined ;
110204
@@ -120,34 +214,37 @@ export function createX402Middleware(config: X402ServerConfig) {
120214 return ;
121215 }
122216
217+ let payload : X402PaymentPayload ;
123218 try {
124- const payload = JSON . parse ( Buffer . from ( paymentHeader , 'base64' ) . toString ( ) ) ;
125-
126- const verified = await verifyWithFacilitator ( payload , facilitatorUrl ) ;
127- if ( ! verified ) {
128- res . status ( 402 ) . json ( { error : 'Payment verification failed. Please try again.' } ) ;
129- return ;
130- }
131-
132- // Track payment stats
133- const endpoint = req . baseUrl + req . path ;
134- stats . totalPayments ++ ;
135- stats . totalRevenue += config . priceUsd ;
136- if ( ! stats . byEndpoint [ endpoint ] ) {
137- stats . byEndpoint [ endpoint ] = { count : 0 , revenue : 0 } ;
138- }
139- stats . byEndpoint [ endpoint ] . count ++ ;
140- stats . byEndpoint [ endpoint ] . revenue += config . priceUsd ;
141-
142- console . log ( `[x402] Payment received: ${ endpoint } from ${ payload . payer || 'unknown' } — $${ config . priceUsd } ` ) ;
143-
144- // Attach payer info for downstream usage tracking
145- req . wallet = payload . payer || 'x402-anonymous' ;
146-
147- next ( ) ;
219+ payload = JSON . parse ( Buffer . from ( paymentHeader , 'base64' ) . toString ( ) ) ;
148220 } catch ( err ) {
149221 console . error ( '[x402] Payment parsing error:' , err ) ;
150- res . status ( 400 ) . json ( { error : 'Invalid payment payload' } ) ;
222+ res . status ( 400 ) . json ( { error : 'Invalid payment payload — could not decode' } ) ;
223+ return ;
151224 }
225+
226+ // Verify Ed25519 signature server-side (proves wallet ownership)
227+ const verifiedPayer = verifyPaymentSignature ( payload , config ) ;
228+ if ( ! verifiedPayer ) {
229+ res . status ( 402 ) . json ( { error : 'Payment verification failed. Invalid signature or stale payment.' } ) ;
230+ return ;
231+ }
232+
233+ // Track payment stats
234+ const endpoint = req . baseUrl + req . path ;
235+ stats . totalPayments ++ ;
236+ stats . totalRevenue += config . priceUsd ;
237+ if ( ! stats . byEndpoint [ endpoint ] ) {
238+ stats . byEndpoint [ endpoint ] = { count : 0 , revenue : 0 } ;
239+ }
240+ stats . byEndpoint [ endpoint ] . count ++ ;
241+ stats . byEndpoint [ endpoint ] . revenue += config . priceUsd ;
242+
243+ console . log ( `[x402] Payment verified: ${ endpoint } from ${ verifiedPayer } — $${ config . priceUsd } ` ) ;
244+
245+ // Attach cryptographically verified payer identity
246+ req . wallet = verifiedPayer ;
247+
248+ next ( ) ;
152249 } ;
153250}
0 commit comments