Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changeset/chubby-peas-sip.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
Updated the `@turnkey/gas-station` SDK to align with the audited smart contract changes. The audit resulted in several interface updates:

**Contract Changes:**

- **New contract addresses**: Updated both delegate and execution contract addresses to the newly deployed versions
- **EIP-712 field name changes**: The canonical delegate contract interface uses simplified field names (`to`, `value`, `data`) instead of the previous descriptive names (`outputContract`, `ethAmount`, `arguments`)

**SDK Updates:**

- Updated `DEFAULT_EXECUTION_CONTRACT` address from `0x4ece92b06C7d2d99d87f052E0Fca47Fb180c3348` to `0x00000000008c57a1CE37836a5e9d36759D070d8c`
- Updated `DEFAULT_DELEGATE_CONTRACT` address from `0xC2a37Ee08cAc3778d9d05FF0a93FD5B553C77E3a` to `0x000066a00056CD44008768E2aF00696e19A30084`
- Updated EIP-712 Execution typehash field names to match the contract's canonical interface
Expand All @@ -17,6 +19,7 @@ Updated the `@turnkey/gas-station` SDK to align with the audited smart contract
- Updated documentation and examples to reflect the new field names

**Files Modified:**

- `packages/gas-station/src/config.ts` - Updated contract addresses
- `packages/gas-station/src/intentBuilder.ts` - Updated EIP-712 type definitions and message objects
- `packages/gas-station/src/policyUtils.ts` - Updated policy condition field references and documentation
5 changes: 5 additions & 0 deletions examples/with-x402/.env.local.example
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.
3 changes: 3 additions & 0 deletions examples/with-x402/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
42 changes: 42 additions & 0 deletions examples/with-x402/.gitignore
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/
73 changes: 73 additions & 0 deletions examples/with-x402/README.md
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.
98 changes: 98 additions & 0 deletions examples/with-x402/app/api/verify-payment/route.ts
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,
}),
});
Comment on lines +20 to +32
Copy link
Contributor

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.


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 },
);
}
}
22 changes: 22 additions & 0 deletions examples/with-x402/app/constants.ts
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" },
};
42 changes: 42 additions & 0 deletions examples/with-x402/app/globals.css
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);
}
66 changes: 66 additions & 0 deletions examples/with-x402/app/layout.tsx
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>
);
}
Loading
Loading