A Next.js application demonstrating how to integrate Polymarket's CLOB Client and Builder Relayer Client for gasless trading with builder order attribution, using Magic Link for web2-style authentication and non-custodial wallet provisioning.
This demo shows developers how to:
- Authenticate users via Magic Link email login for web2-style onboarding
- Provision an EOA wallet automatically via Magic's TKMS (TEE Key Management System)
- Deploy a Gnosis Safe Proxy Wallet using the builder-relayer-client
- Obtain User API Credentials from the CLOB
- Set token approvals for CTF Contract, CTF Exchange, Neg Risk Exchange, and Neg Risk Adapter
- Place orders with builder attribution using remote signing
- Prerequisites
- Quick Start
- Core Integration Patterns
- Key Implementation Details
- Project Structure
- Environment Variables
- Key Dependencies
Before running this demo, you need:
-
Builder API Credentials from Polymarket
- Visit
polymarket.com/settings?tab=builderto obtain your Builder credentials - You'll need:
API_KEY,SECRET, andPASSPHRASE
- Visit
-
Polygon RPC URL
- Any Polygon mainnet RPC (Alchemy, Infura, or public RPC)
-
Magic Link API Key
- Sign up at magic.link and create an app
- Get your Publishable API Key from the Magic Dashboard
npm installCreate .env.local:
# Polygon RPC endpoint
NEXT_PUBLIC_POLYGON_RPC_URL=your_RPC_URL
# Magic Link API key (from magic.link dashboard)
NEXT_PUBLIC_MAGIC_API_KEY=pk_live_XXXXXXXXXXXXXXXX
# Builder credentials (from polymarket.com/settings?tab=builder)
POLYMARKET_BUILDER_API_KEY=your_builder_api_key
POLYMARKET_BUILDER_SECRET=your_builder_secret
POLYMARKET_BUILDER_PASSPHRASE=your_builder_passphrasenpm run devThis application demonstrates two distinct user flows:
- User authenticates via Magic email login
- Magic provisions a non-custodial EOA wallet via TKMS
- Initialize RelayClient with builder config
- Derive Safe address (deterministic from Magic EOA)
- Deploy Safe using RelayClient
- Obtain User API Credentials via temporary ClobClient
- Set token approvals (USDC.e + outcome tokens) in batch transaction
- Initialize authenticated ClobClient with credentials + builder config
- Ready to trade with builder attribution
- User authenticates via Magic Link email login (retrieves existing wallet)
- Initialize RelayClient with builder config
- Load (or derive) existing User API Credentials
- Verify Safe is deployed (skip deployment)
- Verify token approvals (skip if already approved)
- Initialize authenticated ClobClient with credentials + builder config
- Ready to trade with builder attribution
Files: lib/magic.ts, providers/WalletContext.tsx, providers/WalletProvider.tsx
Users authenticate via Magic Link's UI, which handles email/social login and automatically provisions a non-custodial EOA wallet. No browser extension required.
// lib/magic.ts - Magic singleton
import { Magic as MagicBase } from "magic-sdk";
let magic: MagicBase | null = null;
export default function getMagic(): MagicBase | null {
if (typeof window === "undefined") return null;
if (magic) return magic;
magic = new MagicBase(process.env.NEXT_PUBLIC_MAGIC_API_KEY!, {
network: { rpcUrl: POLYGON_RPC_URL, chainId: polygon.id },
});
return magic;
}
// providers/WalletProvider.tsx - Creates viem + ethers clients
const magic = getMagic();
const walletClient = createWalletClient({
chain: polygon,
transport: custom(magic.rpcProvider),
});
// Ethers signer for @polymarket libraries
const ethersProvider = new providers.Web3Provider(magic.rpcProvider);
const ethersSigner = ethersProvider.getSigner();
// Usage in components:
import { useWallet } from "@/providers/WalletContext";
const { eoaAddress, connect, disconnect, ethersSigner } = useWallet();
await connect(); // Opens Magic auth UIFile: app/api/polymarket/sign/route.ts
Builder credentials are stored server-side and accessed via a remote signing endpoint. This keeps your builder credentials secure while enabling order attribution or relay authentication.
// Server-side API route
import {
BuilderApiKeyCreds,
buildHmacSignature,
} from "@polymarket/builder-signing-sdk";
const BUILDER_CREDENTIALS: BuilderApiKeyCreds = {
key: process.env.POLYMARKET_BUILDER_API_KEY!,
secret: process.env.POLYMARKET_BUILDER_SECRET!,
passphrase: process.env.POLYMARKET_BUILDER_PASSPHRASE!,
};
export async function POST(request: NextRequest) {
const { method, path, body } = await request.json();
const sigTimestamp = Date.now().toString();
const signature = buildHmacSignature(
BUILDER_CREDENTIALS.secret,
parseInt(sigTimestamp),
method,
path,
body
);
return NextResponse.json({
POLY_BUILDER_SIGNATURE: signature,
POLY_BUILDER_TIMESTAMP: sigTimestamp,
POLY_BUILDER_API_KEY: BUILDER_CREDENTIALS.key,
POLY_BUILDER_PASSPHRASE: BUILDER_CREDENTIALS.passphrase,
});
}Why remote signing?
- Builder credentials never exposed to client
- Secure HMAC signature generation
- Required for builder order attribution (with ClobClient) or authentication (RelayClient)
File: hooks/useRelayClient.ts
The RelayClient is initialized with the user's Magic EOA signer (from WalletContext) and builder config. It's used for Safe deployment, token approvals, and CTF operations.
import { RelayClient } from "@polymarket/builder-relayer-client";
import { BuilderConfig } from "@polymarket/builder-signing-sdk";
import { useWallet } from "@/providers/WalletContext";
const { ethersSigner } = useWallet();
const builderConfig = new BuilderConfig({
remoteBuilderConfig: {
url: "/api/polymarket/sign", // Your remote signing endpoint
},
});
const relayClient = new RelayClient(
"https://relayer-v2.polymarket.com/",
137, // Polygon chain ID
ethersSigner,
builderConfig
);Key Points:
- Uses shared
ethersSignerfrom WalletContext - Requires builder's config for authentication
- Used for Safe deployment and approvals
- Persisted throughout trading session
File: hooks/useSafeDeployment.ts
The Safe address is deterministically derived from the user's Magic EOA, then deployed if it doesn't exist.
import { deriveSafe } from "@polymarket/builder-relayer-client/dist/builder/derive";
import { getContractConfig } from "@polymarket/builder-relayer-client/dist/config";
// Step 1: Derive Safe address (deterministic)
const config = getContractConfig(137); // Polygon
const safeAddress = deriveSafe(eoaAddress, config.SafeContracts.SafeFactory);
// Step 2: Check if Safe is deployed
const deployed = await relayClient.getDeployed(safeAddress);
// Step 3: Deploy Safe if needed (Magic handles signature)
if (!deployed) {
const response = await relayClient.deploy();
const result = await response.wait();
console.log("Safe deployed at:", result.proxyAddress);
}Important:
- Safe address is deterministic - same EOA always gets same Safe address
- Safe is the "funder" address that holds USDC.e and outcome tokens
- One-time deployment per EOA on user's first login
- Magic handles the signature request
File: hooks/useUserApiCredentials.ts
User API Credentials are obtained by creating a temporary ClobClient and calling deriveApiKey(), createApiKey(), or createOrDeriveApiKey().
import { ClobClient } from "@polymarket/clob-client";
import { useWallet } from "@/providers/WalletContext";
const { ethersSigner } = useWallet();
// Create temporary CLOB client (no credentials yet)
const tempClient = new ClobClient(
"https://clob.polymarket.com",
137, // Polygon chain ID
ethersSigner
);
// Try to derive existing credentials (for returning users)
let creds;
try {
creds = await tempClient.deriveApiKey(); // Magic handles signature
} catch (error) {
// If derive fails, create new credentials
creds = await tempClient.createApiKey(); // Magic handles signature
}
// creds = { key: string, secret: string, passphrase: string }Flow:
- First-time users:
createApiKey()creates new credentials - Returning users:
deriveApiKey()retrieves existing credentials - Both methods require user signature (EIP-712)
- Credentials are stored in localStorage for future sessions
Important:
Credentials alone are not enough to place new orders. However, they can be used to view orders and to cancel limit orders. Storing the user's credentials in localStorage is not recommended for production due to XSS vulnerability risks. This demo prioritizes simplicity over security—in production, use secure httpOnly cookies or server-side session management instead.
Why temporary client?
- Credentials are needed to create the authenticated client
- Temporary client is destroyed after obtaining credentials
Files: hooks/useTokenApprovals.ts, utils/approvals.ts
Before trading, the Safe must approve multiple contracts to spend USDC.e and manage outcome tokens. This involves setting approvals for both ERC-20 (USDC.e) and ERC-1155 (outcome tokens).
USDC.e (ERC-20) Approvals:
- CTF Contract:
0x4d97dcd97ec945f40cf65f87097ace5ea0476045 - CTF Exchange:
0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E - Neg Risk CTF Exchange:
0xC5d563A36AE78145C45a50134d48A1215220f80a - Neg Risk Adapter:
0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296
Outcome Token (ERC-1155) Approvals:
- CTF Exchange:
0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E - Neg Risk CTF Exchange:
0xC5d563A36AE78145C45a50134d48A1215220f80a - Neg Risk Adapter:
0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296
import { createAllApprovalTxs, checkAllApprovals } from "@/utils/approvals";
// Step 1: Check existing approvals
const approvalStatus = await checkAllApprovals(safeAddress);
if (approvalStatus.allApproved) {
console.log("All approvals already set");
// Skip approval step
} else {
// Step 2: Create approval transactions
const approvalTxs = createAllApprovalTxs();
// Returns array of SafeTransaction objects
// Step 3: Execute all approvals in a single batch
const response = await relayClient.execute(
approvalTxs,
"Set all token approvals for trading"
);
await response.wait();
console.log("All approvals set successfully");
}Each approval transaction is a SafeTransaction:
// ERC-20 approval (USDC.e)
{
to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // USDC.e address
operation: OperationType.Call,
data: erc20Interface.encodeFunctionData('approve', [
spenderAddress,
MAX_UINT256 // Unlimited approval
]),
value: '0'
}
// ERC-1155 approval (outcome tokens)
{
to: '0x4d97dcd97ec945f40cf65f87097ace5ea0476045', // CTF Contract address
operation: OperationType.Call,
data: erc1155Interface.encodeFunctionData('setApprovalForAll', [
operatorAddress,
true // Enable operator
]),
value: '0'
}Polymarket's trading system uses different contracts for different market types:
- CTF Contract: Manages outcome tokens (ERC-1155)
- CTF Exchange: Standard binary markets
- Neg Risk CTF Exchange: Negative risk markets (mutually exclusive outcomes)
- Neg Risk Adapter: Converts between neg risk and standard markets
Setting all approvals upfront ensures:
- Users can trade in any market type
- One-time setup (approvals persist across sessions)
- Gasless execution via RelayClient
- Single user signature for all approvals
Before setting approvals, the app checks onchain state:
// Check USDC.e approval
const allowance = await publicClient.readContract({
address: USDC_E_ADDRESS,
abi: ERC20_ABI,
functionName: "allowance",
args: [safeAddress, spenderAddress],
});
const isApproved = allowance >= threshold; // 1000000000000 (1M USDC.e)
// Check outcome token approval
const isApprovedForAll = await publicClient.readContract({
address: CTF_CONTRACT_ADDRESS,
abi: ERC1155_ABI,
functionName: "isApprovedForAll",
args: [safeAddress, operatorAddress],
});Key Points:
- Uses batch execution via
relayClient.execute()for gas efficiency - Sets unlimited approvals (MaxUint256) for ERC-20 tokens
- Sets operator approvals for ERC-1155 outcome tokens
- One-time setup per Safe (persists across sessions)
- User signs once to approve all transactions (Magic handles signature)
- Gasless for the user
File: hooks/useClobClient.ts
After obtaining User API Credentials, create the authenticated ClobClient with builder config.
import { ClobClient } from "@polymarket/clob-client";
import { BuilderConfig } from "@polymarket/builder-signing-sdk";
import { useWallet } from "@/providers/WalletContext";
const { ethersSigner } = useWallet();
const builderConfig = new BuilderConfig({
remoteBuilderConfig: {
url: "/api/polymarket/sign",
},
});
const clobClient = new ClobClient(
"https://clob.polymarket.com",
137, // Polygon chain ID
ethersSigner,
userApiCredentials, // { key, secret, passphrase }
2, // signatureType = 2 for EOA associated to a Gnosis Safe proxy wallet
safeAddress, // funder address from step 4
undefined, // mandatory placeholder
false,
builderConfig // Builder order attribution
);Parameters Explained:
- ethersSigner: Shared signer from WalletContext
- userApiCredentials: Obtained from Step 5
- signatureType = 2: Type indicating EOA associated to a Gnosis Safe proxy wallet
- safeAddress: The Safe proxy wallet address that holds funds
- builderConfig: Enables order attribution
This is the persistent client used for all trading operations.
File: hooks/useClobOrder.ts
With the authenticated ClobClient, you can place orders with builder attribution.
// Create order
const order = {
tokenID: "0x...", // Outcome token address
price: 0.65, // Price in decimal (65 cents)
size: 10, // Number of shares
side: "BUY", // or 'SELL'
feeRateBps: 0,
expiration: 0, // 0 = Good-til-Cancel
taker: "0x0000000000000000000000000000000000000000",
};
// Submit order (Magic handles signature)
const response = await clobClient.createAndPostOrder(
order,
{ negRisk: false }, // Market-specific flag
OrderType.GTC
);
console.log("Order ID:", response.orderID);Key Points:
- Orders are signed by the user's Magic EOA
- Executed from the Safe address (funder)
- Builder attribution is automatic via builderConfig
- Gasless execution (no gas fees for users)
Cancel Order:
await clobClient.cancelOrder({ orderID: "order_id_here" });polymarket-magic-safe/
├── app/
│ ├── api/
│ │ └── polymarket/
│ │ └── sign/
│ │ └── route.ts # Remote signing endpoint
│ └── page.tsx # Main application UI
│
├── lib/
│ └── magic.ts # Magic singleton instance
│
├── hooks/
│ ├── useTradingSession.ts # Session orchestration (main flow)
│ ├── useRelayClient.ts # RelayClient initialization
│ ├── useSafeDeployment.ts # Safe deployment logic
│ ├── useUserApiCredentials.ts # User API credential derivation
│ ├── useTokenApprovals.ts # Token approval management
│ ├── useClobClient.ts # Authenticated CLOB client
│ └── useClobOrder.ts # Order placement/cancellation
│
├── providers/
│ ├── index.tsx # Combined providers export
│ ├── WalletContext.tsx # Wallet context and useWallet hook
│ ├── WalletProvider.tsx # Magic auth + viem/ethers clients
│ ├── TradingProvider.tsx # Trading session + client context
│ └── QueryProvider.tsx # React Query provider
│
├── utils/
│ ├── session.ts # Session persistence (localStorage)
│ └── approvals.ts # Token approval utilities
│
└── constants/
├── polymarket.ts # API URLs and constants
└── tokens.ts # Token addresses
This is the orchestrator that manages the entire trading session lifecycle:
// Coordinates:
// 1. Initialize RelayClient with builder config
// 2. Derive Safe address
// 3. Check if Safe is deployed → deploy if needed
// 4. Get User API Credentials → derive or create
// 5. Check token approvals → approve if needed (batch)
// 6. Save session to localStorage
// 7. Initialize authenticated ClobClient
const {
tradingSession,
currentStep,
initializeTradingSession,
endTradingSession,
relayClient,
isTradingSessionComplete,
} = useTradingSession();Read this hook first to understand the complete flow.
Create .env.local:
# Required: Polygon RPC
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-rpc.com
# Required: Magic Link API key (from magic.link dashboard)
NEXT_PUBLIC_MAGIC_API_KEY=pk_live_XXXXXXXXXXXXXXXX
# Required: Builder credentials (from polymarket.com/settings?tab=builder)
POLYMARKET_BUILDER_API_KEY=your_builder_api_key
POLYMARKET_BUILDER_SECRET=your_builder_secret
POLYMARKET_BUILDER_PASSPHRASE=your_builder_passphrase| Package | Version | Purpose |
|---|---|---|
magic-sdk |
^31.2.0 | Authentication / Embedded Wallet |
@polymarket/clob-client |
^4.22.8 | Order placement, User API credentials |
@polymarket/builder-relayer-client |
^0.0.6 | Safe deployment, token approvals, CTF operations |
@polymarket/builder-signing-sdk |
^0.0.8 | Builder credential HMAC signatures |
viem |
^2.39.2 | Ethereum interactions, RPC calls |
ethers |
^5.8.0 | Wallet signing, EIP-712 messages |
@tanstack/react-query |
^5.90.10 | Server state management |
next |
16.0.3 | React framework, API routes |
User (email login)
↓
[Magic Link Auth]
↓
Magic EOA (via TKMS)
↓
┌────────────────────────────────────────────────────┐
│ Trading Session Initialization │
├────────────────────────────────────────────────────┤
│ 1. Initialize RelayClient (with builder config) │
│ 2. Derive Safe address from EOA │
│ 3. Check if Safe deployed → deploy if needed │
│ 4. Get User API Credentials (derive or create) │
│ 5. Set token approvals (batch execution): │
│ - USDC.e → 4 spenders (ERC-20) │
│ - Outcome tokens → 3 operators (ERC-1155) │
│ 6. Save session to localStorage │
└────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────┐
│ Authenticated ClobClient │
├────────────────────────────────────────────────────┤
│ - User API Credentials │
│ - Builder Config (remote signing) │
│ - Safe address (funder) │
│ - Magic EOA signer │
└────────────────────────────────────────────────────┘
↓
Place Orders
(Standard + Neg Risk markets)
(with builder attribution)
- Verify
NEXT_PUBLIC_MAGIC_API_KEYis set correctly in.env.local - Check Magic dashboard for any API key restrictions
- Ensure you're using a Publishable API Key (starts with
pk_)
- Check builder credentials in
.env.local - Verify
/api/polymarket/signendpoint is accessible - Check browser console for errors
- Check Polygon RPC URL is valid
- User must approve signature via Magic
- Verify builder credentials are configured correctly
- Check browser console for relay service errors
- Safe must be deployed first
- User must approve transaction signature via Magic
- Verify builder relay service is operational
- Verify trading session is complete
- Check Safe has USDC.e balance
- Wait 2-3 seconds for CLOB sync
Questions or issues? Reach out on Telegram: @notyrjo
MIT
Built for builders exploring the Polymarket ecosystem with Magic Link authentication