-
Notifications
You must be signed in to change notification settings - Fork 43
x402 X Turnkey demo #1093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
x402 X Turnkey demo #1093
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| NEXT_PUBLIC_ORGANIZATION_ID="your-turnkey-organization-id" | ||
| NEXT_PUBLIC_AUTH_PROXY_ID="your-turnkey-auth-proxy-config-id" | ||
|
|
||
| NEXT_PUBLIC_FACILITATOR_URL=https://www.x402.org/facilitator # This is a public, free, community maintained facilitator URL for x402; replace if you have your own | ||
| NEXT_PUBLIC_RESOURCE_WALLET_ADDRESS=0xYourResourceWalletAddressHere # This is the resource wallet address where payments will be sent. Replace with your own eth wallet address. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "extends": "next/core-web-vitals" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||
|
|
||
| # dependencies | ||
| /node_modules | ||
| /.pnp | ||
| .pnp.js | ||
|
|
||
| # testing | ||
| /coverage | ||
|
|
||
| # next.js | ||
| /.next/ | ||
| /out/ | ||
|
|
||
| # production | ||
| /build | ||
|
|
||
| # misc | ||
| .DS_Store | ||
| *.pem | ||
|
|
||
| # debug | ||
| npm-debug.log* | ||
| yarn-debug.log* | ||
| yarn-error.log* | ||
|
|
||
| # local env files | ||
| .env*.local | ||
|
|
||
| # vercel | ||
| .vercel | ||
|
|
||
| # typescript | ||
| *.tsbuildinfo | ||
| next-env.d.ts | ||
|
|
||
| # Playwright | ||
| node_modules/ | ||
| /test-results/ | ||
| /playwright-report/ | ||
| /blob-report/ | ||
| /playwright/.cache/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # x402 with Turnkey Embedded Wallets | ||
|
|
||
| This example demonstrates how to use Coinbase's x402 payment protocol with Turnkey's embedded wallets for seamless payment authentication. | ||
|
|
||
| ## Overview | ||
|
|
||
| This demo shows how to build a custom x402 middleware that integrates with Turnkey's embedded wallet system instead of relying on browser wallet extensions. | ||
|
|
||
| ## How It Works | ||
|
|
||
| ### 1. Middleware-Based Access Control | ||
|
|
||
| The demo uses Next.js middleware to gate access to protected content. The middleware (`middleware.ts`) intercepts requests to `/protected/*` routes and checks for a `payment-session` cookie: | ||
|
|
||
| - **If the cookie exists**: The user has made a valid payment and can access the protected content | ||
| - **If the cookie is missing**: The user is redirected to the paywall page to complete payment | ||
|
|
||
| This means the server is responsible for gating the access to the protected page. The cookie only lasts for 5 minutes and can be cleared before if needed. | ||
|
|
||
| ### 2. Turnkey-Powered Payment Authorization | ||
|
|
||
| The payment page (`app/paywall/page.tsx`) uses Turnkey's embedded wallets to sign the payment authorization: | ||
|
|
||
| 1. **Authentication**: Users log in to a Turnkey sub-org using the [`@turnkey/react-wallet-kit`](../../packages/react-native-wallet-kit/) package, which handles authentication with different methods | ||
| 2. **Sign EIP-712 Payload**: The user signs an EIP-3009 `TransferWithAuthorization` message for USDC on Base Sepolia using the [`@turnkey/viem`](../../packages/viem/) package. This is a gasless transfer authorization that includes: | ||
| - The sender (`from`) and recipient (`to`) addresses | ||
| - The payment amount (0.01 USDC in this demo) | ||
| - Validity period (5 minutes) | ||
| - A unique nonce to prevent replay attacks | ||
| 3. **Encode & Submit**: The signed authorization is encoded in x402 format using the [`x402`](https://www.npmjs.com/package/x402) package and submitted to the server for verification | ||
|
|
||
| ### 3. Payment Verification with Public Facilitator | ||
|
|
||
| After the payload is signed, the [server route](./app/api/verify-payment/route.ts) uses a [public x402 facilitator](https://www.x402.org/facilitator) to verify the payment: | ||
|
|
||
| 1. **Decode Payment**: The EIP-712 signed payment payload is decoded from the x402 format using the [`x402`](https://www.npmjs.com/package/x402) package | ||
| 2. **Verify**: The facilitator's `/verify` endpoint validates that: | ||
| - The signature is valid and signed by the claimed payer | ||
| - The payment meets the requirements (amount, asset, recipient, timing) | ||
| - The nonce hasn't been used before | ||
| 3. **Settle**: If valid, the facilitator's `/settle` endpoint records the payment as settled | ||
| 4. **Set Cookie**: Upon successful verification, a `payment-session` cookie is set with a 5-minute expiration | ||
|
|
||
| The facilitator acts as a neutral third party that verifies payments without requiring the merchant to run their own verification infrastructure. You can also host your own facilitator. | ||
|
|
||
| ## Running The App | ||
|
|
||
| To start, ensure you have a [Turnkey organization setup](https://docs.turnkey.com/getting-started/quickstart) with [Auth Proxy enabled.](https://docs.turnkey.com/sdks/react/getting-started#turnkey-organization-setup) | ||
|
|
||
| ### Configure your `.env.local` file | ||
|
|
||
| Copy the [`.env.local.example`](./.env.local.example) file and rename it to `.env.local` then fill in the following fields: | ||
|
|
||
| ``` | ||
| NEXT_PUBLIC_ORGANIZATION_ID="your-turnkey-organization-id" | ||
| NEXT_PUBLIC_AUTH_PROXY_ID="your-turnkey-auth-proxy-config-id" | ||
|
|
||
| NEXT_PUBLIC_FACILITATOR_URL=https://www.x402.org/facilitator # This is a public, free, community maintained facilitator URL for x402; replace if you have your own | ||
| NEXT_PUBLIC_RESOURCE_WALLET_ADDRESS=0xYourResourceWalletAddressHere # This is the resource wallet address where payments will be sent. Replace with your own eth wallet address. | ||
| ``` | ||
|
|
||
| You can use any Ethereum wallet address to act as a resource wallet. The Base Sepolia USDC will go there. | ||
|
|
||
| ### Start the Development Server | ||
|
|
||
| Install the packages and run the dev server: | ||
|
|
||
| ```bash | ||
| pnpm i | ||
| pnpm run dev | ||
| ``` | ||
|
|
||
| You'll see the demo running on http://localhost:3000. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { cookies } from "next/headers"; | ||
| import { exact } from "x402/schemes"; | ||
| import { PAYMENT_REQUIREMENTS, COOKIE_TIMEOUT_SECONDS } from "../../constants"; | ||
|
|
||
| export async function POST(req: Request) { | ||
| try { | ||
| const { paymentHeader } = await req.json(); | ||
|
|
||
| if (!paymentHeader) { | ||
| return NextResponse.json( | ||
| { success: false, error: "Payment header is required" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| // Decode the payment header | ||
| const decodedPayment = exact.evm.decodePayment(paymentHeader); | ||
|
|
||
| const facilitatorUrl = | ||
| process.env.NEXT_PUBLIC_FACILITATOR_URL || | ||
| "https://www.x402.org/facilitator"; | ||
|
|
||
| // Call facilitator to verify | ||
| const verifyResponse = await fetch(`${facilitatorUrl}/verify`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| paymentPayload: decodedPayment, | ||
| paymentRequirements: PAYMENT_REQUIREMENTS, | ||
| }), | ||
| }); | ||
|
|
||
| const verifyResult = await verifyResponse.json(); | ||
|
|
||
| if (!verifyResult.isValid) { | ||
| return NextResponse.json( | ||
| { | ||
| success: false, | ||
| x402Version: 1, | ||
| error: verifyResult.invalidReason || "Payment verification failed", | ||
| accepts: [PAYMENT_REQUIREMENTS], | ||
| payer: verifyResult.payer, | ||
| }, | ||
| { status: 402 }, // Payment Required! | ||
| ); | ||
| } | ||
|
|
||
| // Call facilitator to settle the payment | ||
| const settleResponse = await fetch(`${facilitatorUrl}/settle`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| paymentPayload: decodedPayment, | ||
| paymentRequirements: PAYMENT_REQUIREMENTS, | ||
| }), | ||
| }); | ||
|
|
||
| const settleResult = await settleResponse.json(); | ||
|
|
||
| if (!settleResult.success) { | ||
| return NextResponse.json( | ||
| { | ||
| success: false, | ||
| error: settleResult.errorReason || "Settle failed", | ||
| }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
|
|
||
| // Payment is valid and settled, set the cookie | ||
| const cookieStore = await cookies(); | ||
| // This should be a JWT signed by the server following best practices for a session token | ||
| // See: https://nextjs.org/docs/app/guides/authentication#stateless-sessions | ||
| cookieStore.set("payment-session", paymentHeader, { | ||
| maxAge: COOKIE_TIMEOUT_SECONDS, // 5 minutes | ||
| }); | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| success: true, | ||
| payer: verifyResult.payer, | ||
| }, | ||
| { status: 200 }, | ||
| ); | ||
| } catch (error) { | ||
| console.error("Error verifying payment:", error); | ||
| return NextResponse.json( | ||
| { | ||
| success: false, | ||
| x402Version: 1, | ||
| error: error instanceof Error ? error.message : "Internal server error", | ||
| accepts: [PAYMENT_REQUIREMENTS], | ||
| }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // USDC address on Base Sepolia | ||
| export const USDC_ADDRESS = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; | ||
|
|
||
| // 0.01 USDC (6 decimals) | ||
| export const PAYMENT_AMOUNT = "10000"; | ||
|
|
||
| // Cookie timeout | ||
| export const COOKIE_TIMEOUT_SECONDS = 300; | ||
|
|
||
| // Payment requirements | ||
| export const PAYMENT_REQUIREMENTS = { | ||
| scheme: "exact" as const, | ||
| network: "base-sepolia" as const, | ||
| maxAmountRequired: PAYMENT_AMOUNT, // 0.01 USDC | ||
| resource: "http://example.com/", | ||
| description: "Access to protected content", | ||
| mimeType: "text/html", | ||
| payTo: process.env.NEXT_PUBLIC_RESOURCE_WALLET_ADDRESS!, | ||
| maxTimeoutSeconds: COOKIE_TIMEOUT_SECONDS, | ||
| asset: USDC_ADDRESS, // USDC on Base Sepolia | ||
| extra: { name: "USDC", version: "2" }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| @import "tailwindcss"; | ||
|
|
||
| @font-face { | ||
| font-family: "Inter"; | ||
| font-style: normal; | ||
| font-weight: 400; | ||
| font-display: swap; | ||
| src: url("/fonts/inter/Inter-Regular.woff2?v=3.19") format("woff2"); | ||
| } | ||
|
|
||
| @font-face { | ||
| font-family: "Inter"; | ||
| font-style: normal; | ||
| font-weight: 600; | ||
| font-display: swap; | ||
| src: url("/fonts/inter/Inter-SemiBold.woff2?v=3.19") format("woff2"); | ||
| } | ||
|
|
||
| * { | ||
| font-family: | ||
| "Inter", | ||
| -apple-system, | ||
| BlinkMacSystemFont, | ||
| sans-serif; | ||
| } | ||
|
|
||
| :root { | ||
| font-family: | ||
| "Inter", | ||
| -apple-system, | ||
| BlinkMacSystemFont, | ||
| sans-serif; | ||
| } | ||
|
|
||
| button { | ||
| cursor: pointer; | ||
| transition: transform 0.1s ease; | ||
| } | ||
|
|
||
| button:active { | ||
| transform: scale(0.95); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| "use client"; | ||
|
|
||
| import { Geist, Geist_Mono } from "next/font/google"; | ||
| import "./globals.css"; | ||
| import { CreateSubOrgParams, TurnkeyProvider } from "@turnkey/react-wallet-kit"; | ||
| import "@turnkey/react-wallet-kit/styles.css"; | ||
|
|
||
| const geistSans = Geist({ | ||
| variable: "--font-geist-sans", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| const geistMono = Geist_Mono({ | ||
| variable: "--font-geist-mono", | ||
| subsets: ["latin"], | ||
| }); | ||
|
|
||
| const createSubOrgParams: CreateSubOrgParams = { | ||
| customWallet: { | ||
| walletName: "ETH Wallet", | ||
| walletAccounts: [ | ||
| { | ||
| addressFormat: "ADDRESS_FORMAT_ETHEREUM", | ||
| curve: "CURVE_SECP256K1", | ||
| pathFormat: "PATH_FORMAT_BIP32", | ||
| path: "m/44'/60'/0'/0/0", | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| export default function RootLayout({ | ||
| children, | ||
| }: Readonly<{ | ||
| children: React.ReactNode; | ||
| }>) { | ||
| return ( | ||
| <html lang="en"> | ||
| <head> | ||
| <title>Turnkey x402</title> | ||
| <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> | ||
| </head> | ||
| <body | ||
| className={`${geistSans.variable} ${geistMono.variable} antialiased`} | ||
| > | ||
| <TurnkeyProvider | ||
| config={{ | ||
| organizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!, | ||
| authProxyConfigId: process.env.NEXT_PUBLIC_AUTH_PROXY_ID!, | ||
| auth: { | ||
| createSuborgParams: { | ||
| emailOtpAuth: createSubOrgParams, | ||
| smsOtpAuth: createSubOrgParams, | ||
| walletAuth: createSubOrgParams, | ||
| passkeyAuth: createSubOrgParams, | ||
| oauth: createSubOrgParams, | ||
| }, | ||
| }, | ||
| }} | ||
| > | ||
| {children} | ||
| </TurnkeyProvider> | ||
| </body> | ||
| </html> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh! So we do use a public facilitator already. Glorious.