@policylayer/sdk
v1.4.0
Published
Non-custodial spending controls for AI agent wallets. Enforce limits without holding keys.
Maintainers
Keywords
Readme
@policylayer/sdk
Non-custodial spending controls for AI agent wallets
AI agents need wallet access to make payments. But unrestricted access means one bug, one prompt injection, or one infinite loop away from draining your entire treasury.
PolicyLayer enforces spending limits on AI agent wallets without holding your private keys. Your keys stay on your server. We just say yes or no.
Installation
npm install @policylayer/sdkInstall per adapter:
# Core (just Viem adapter)
npm install @policylayer/sdk
# Ethers
npm install @policylayer/sdk ethers
# Solana
npm install @policylayer/sdk @solana/web3.js @solana/spl-token bs58 tweetnacl
# Coinbase CDP
npm install @policylayer/sdk @coinbase/coinbase-sdk decimal.js
# Privy
npm install @policylayer/sdk @privy-io/node
# Dynamic
npm install @policylayer/sdk @dynamic-labs/sdk-react-core @dynamic-labs/ethereumLight install: Only Viem is bundled. Adapter-specific dependencies are optional peer deps—install only what you need.
Runtime requirements:
- Node.js 18+ — Uses Node's
cryptomodule - Browser: Requires Node polyfills (crypto, buffer) if bundling for browser. For pure browser use, run PolicyWallet calls server-side or use a framework with Node polyfills.
Module format: CJS only. Works with webpack, esbuild, vite (with Node polyfills for browser builds).
Production requirement: You must configure decisionSigningKeys in production (NODE_ENV=production) for transactions to succeed. Get your signing key from the PolicyLayer Dashboard.
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
decisionSigningKeys: [process.env.POLICYLAYER_SIGNING_KEY!], // Required in production
});Quick Start by Adapter
Ethers
import { PolicyWallet, createEthersAdapter } from '@policylayer/sdk';
const adapter = await createEthersAdapter(
process.env.PRIVATE_KEY!,
process.env.RPC_URL!
);
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});
await wallet.send({
chain: 'ethereum',
asset: 'eth',
to: '0xRecipient...',
amount: '1000000000000000000', // 1 ETH in wei
});Viem
import { PolicyWallet, createViemAdapter } from '@policylayer/sdk';
import { mainnet } from 'viem/chains';
const adapter = await createViemAdapter(
'0xPrivateKey...' as `0x${string}`,
process.env.RPC_URL!,
mainnet
);
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});Solana
import { PolicyWallet, createSolanaAdapter } from '@policylayer/sdk';
const adapter = await createSolanaAdapter(
process.env.SOLANA_PRIVATE_KEY!, // base58 encoded
'https://api.mainnet-beta.solana.com'
);
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});
await wallet.send({
chain: 'solana',
asset: 'sol',
to: 'RecipientPubkey...',
amount: '1000000000', // 1 SOL in lamports
});Coinbase CDP
import { PolicyWallet, createNewCoinbaseWallet } from '@policylayer/sdk';
// Create a new wallet (handles Coinbase.configure internally)
const { adapter, walletData } = await createNewCoinbaseWallet(
{
apiKeyName: process.env.COINBASE_API_KEY_NAME!,
privateKey: process.env.COINBASE_PRIVATE_KEY!,
},
'base-mainnet'
);
// IMPORTANT: walletData contains the wallet seed - store securely!
// Use KMS, Secrets Manager, or encrypted storage. Never log in production.
await secretsManager.store('coinbase-wallet', walletData);
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});
// To restore an existing wallet:
// import { createCoinbaseAdapter } from '@policylayer/sdk';
// const adapter = await createCoinbaseAdapter(savedWalletData, 'base-mainnet');Privy
import { PolicyWallet, createPrivyAdapter } from '@policylayer/sdk';
const adapter = await createPrivyAdapter({
appId: process.env.PRIVY_APP_ID!,
appSecret: process.env.PRIVY_APP_SECRET!,
walletId: 'user-wallet-id',
address: '0xWalletAddress',
rpcUrl: 'https://eth.llamarpc.com',
chainId: 1,
});
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});Dynamic
Note: Dynamic is browser-first. Run PolicyWallet calls in a server action/API route, or add Node polyfills (crypto, buffer) to your bundler config.
// In a Next.js API route or server action:
import { PolicyWallet, createDynamicAdapter } from '@policylayer/sdk';
const adapter = await createDynamicAdapter(
walletClient, // from Dynamic SDK
publicClient, // viem PublicClient
);
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
});X402 Payment Protocol
The SDK supports the X402 Payment Required protocol for handling HTTP 402 responses. When an API returns 402 Payment Required, x402 headers specify payment details. PolicyLayer validates these payments through the two-gate enforcement model before execution.
Basic Usage
import { X402Client, PolicyConfig } from '@policylayer/sdk';
const policyConfig: PolicyConfig = {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
};
const x402Client = new X402Client(policyConfig);
// Make API request that returns 402
const response = await fetch('https://api.example.com/resource');
if (response.status === 402) {
// Complete two-gate flow: validate → verify → execute
const paymentResponse = await x402Client.pay(response, 'https://api.example.com/resource');
console.log('Payment completed:', paymentResponse.status);
}Manual Control Flow
For more control over the payment process:
// Step 1: Parse 402 response and validate with Gate 1
const validation = await x402Client.handle402Response(response);
console.log('Payment approved:', validation.approved);
console.log('Amount:', validation.intent.normalizedAmount);
// Step 2: Execute payment (includes Gate 2 verification)
const paymentResponse = await x402Client.executePayment(validation, {
facilitatorUrl: 'https://facilitator.example.com/pay',
headers: {
'X-Custom-Header': 'value'
}
});Client-Side Validation
Add stricter client-side limits before policy validation:
const x402Client = new X402Client(policyConfig, {
maxAmountPerRequest: '100000000', // Max 100 USDC (6 decimals)
allowedRecipients: [
'0x1234...', // Only allow payments to specific addresses
],
blockedRecipients: [
'0xbad...', // Block specific addresses
],
});Duplicate detection is enforced server-side; no client flag is required.
Required Headers
X402 responses must include:
X-Payment-Amount: Amount as decimal string (e.g., "10.5")X-Payment-Currency: Currency code (USDC, ETH, DAI, etc.)X-Payment-Recipient: Wallet addressX-Payment-Chain: Chain name (base, base-sepolia, ethereum, arbitrum, optimism)X-Payment-Facilitator(optional): Payment facilitator URL
Supported Currencies
Built-in decimal support for:
| Currency | Decimals | |----------|----------| | USDC | 6 | | USDT | 6 | | DAI | 18 | | ETH | 18 | | WETH | 18 |
Manual Header Parsing
import { parseX402Headers, normaliseAmount, hashHeaders } from '@policylayer/sdk';
// Parse headers from Response or Record
const headers = parseX402Headers(response.headers);
console.log(headers);
// {
// amount: '10.5',
// currency: 'USDC',
// recipient: '0x...',
// chain: 'base-sepolia',
// facilitator: 'https://...'
// }
// Normalise amount to bigint (precision-safe)
const amountBigInt = normaliseAmount('10.5', 'USDC');
console.log(amountBigInt); // 10500000n (6 decimals)
// Create hash for duplicate detection
const headersHash = hashHeaders(headers);
console.log(headersHash); // SHA-256 hex stringError Handling
import { X402HeaderError, X402ValidationError, X402PaymentError } from '@policylayer/sdk';
try {
await x402Client.pay(response);
} catch (error) {
if (error instanceof X402HeaderError) {
// Missing or invalid headers
console.error('Header error:', error.message, error.field);
} else if (error instanceof X402ValidationError) {
// Validation failed (amount, decimals, currency)
console.error('Validation error:', error.message, error.code);
} else if (error instanceof X402PaymentError) {
// Payment execution failed
console.error('Payment error:', error.message, error.code);
}
}How It Works
- Parse Headers: Extract payment details from 402 response headers
- Normalise Amount: Convert decimal string to bigint using currency-specific decimals (precision-safe, no parseFloat)
- Gate 1 (Validate): Send intent to PolicyLayer API, receive authorisation token
- Gate 2 (Verify): Verify authorisation before payment execution
- Execute Payment: Send payment to facilitator with
X-PolicyLayer-Authheader binding authorisation token
Authorisation Binding
The authorisation token from Gate 1 is bound to the facilitator request via the X-PolicyLayer-Auth header. This ensures the payment is traceable and policy-enforced.
// Authorisation token automatically added to facilitator request
const response = await x402Client.executePayment(validation);
// Request includes: { headers: { 'X-PolicyLayer-Auth': '<token>' } }Production Hardening
Strongly recommended for production:
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
// Verify API responses are genuinely from PolicyLayer
decisionSigningKeys: [process.env.POLICYLAYER_PUBLIC_KEY!],
// Detect if someone swapped your policy
expectedPolicyHash: 'sha256:abc123...',
// Silence logs in production
logLevel: 'silent',
});Security defaults (if not configured):
decisionSigningKeys: None — API responses accepted without signature verification (TLS only)expectedPolicyHash: None — any policy acceptedlogLevel:'info'— logs Gate 1/2 status to console
Where to find these values:
decisionSigningKeys: Dashboard → Settings → Decision Signing Key (copy the public key)expectedPolicyHash: Dashboard → Policies → click policy → copy hash from details panel
Checklist:
- [ ] Set
decisionSigningKeyswith your org's public key from dashboard - [ ] Set
expectedPolicyHashto your policy's hash - [ ] Set
logLevel: 'silent'in production - [ ] Gate 2 errors are truncated to 200 chars in logs (no sensitive data leaked)
Fail-closed tip: If you can't set
decisionSigningKeysyet, at least setlogLevel: 'warn'and monitor for "signature verification skipped" warnings in your telemetry. This helps you track when you're running without signature verification.
Asset Binding
For non-native assets (ERC-20, SPL tokens), provide tokenAddress:
await wallet.send({
chain: 'ethereum',
asset: 'usdc',
to: '0xRecipient...',
amount: '1000000', // 1 USDC
tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC contract
});Note: tokenAddress is included in the intent fingerprint. If you approve a transaction for one token contract, it cannot be swapped for a different contract.
Asset Resolver
For convenience, configure an asset resolver to avoid passing tokenAddress every time:
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
assetResolver: (asset, chain) => {
const tokens: Record<string, Record<string, string>> = {
ethereum: {
usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
usdt: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
},
base: {
usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
},
};
return tokens[chain]?.[asset];
},
});
// Now tokenAddress is resolved automatically
await wallet.send({ chain: 'base', asset: 'usdc', to: '0x...', amount: '1000000' });Chain Binding
The SDK verifies your adapter's chain matches the intent's chain before signing.
Default chain IDs:
| Chain | ID | |-------|-----| | ethereum | 1 | | base | 8453 | | base-sepolia | 84532 | | polygon | 137 | | arbitrum | 42161 | | optimism | 10 | | sepolia | 11155111 | | solana | 101 |
Override for custom chains:
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
chainIds: {
'blast-mainnet': 81457,
'zksync-era': 324,
'scroll': 534352,
},
});
await wallet.send({ chain: 'blast-mainnet', asset: 'eth', to: '0x...', amount: '...' });Timeouts & Retries
Defaults:
timeoutMs: 30000 (30 seconds)maxRetries: 2 (total attempts = 3)- Worst-case latency: ~90 seconds with exponential backoff
For low-latency agents:
const wallet = new PolicyWallet(adapter, {
apiUrl: 'https://api.policylayer.com',
apiKey: process.env.POLICYLAYER_API_KEY!,
timeoutMs: 8000, // Fail fast
maxRetries: 0, // No retries
});Error Handling
import { PolicyError } from '@policylayer/sdk';
try {
await wallet.send(intent);
} catch (error) {
if (error instanceof PolicyError) {
switch (error.code) {
case 'POLICY_DECISION_DENY':
console.log('Blocked by policy:', error.message);
console.log('Remaining budget:', error.details?.counters?.remainingDaily);
break;
case 'ADAPTER_TX_FAILED':
console.log('Transaction failed:', error.message);
console.log('Provider error:', error.details?.cause);
break;
case 'NETWORK_ERROR':
console.log('API unreachable, retry later');
break;
default:
console.log(`Error [${error.code}]: ${error.message}`);
}
}
}Telemetry Integration
Forward policy decisions to your observability stack:
try {
const result = await wallet.send(intent);
// Log successful transactions with proof
telemetry.track('policy.allowed', {
hash: result.hash,
policyHash: result.decisionProof?.policyHash,
remainingDaily: result.counters?.remainingDaily,
});
} catch (error) {
if (error instanceof PolicyError) {
telemetry.track('policy.error', {
code: error.code,
message: error.message,
policyHash: error.details?.policyHash,
reason: error.details?.reason,
counters: error.details?.counters,
});
}
}All Error Codes
Policy errors:
| Code | Meaning |
|------|---------|
| POLICY_DECISION_DENY | Policy rejected the transaction |
| POLICY_DECISION_REVIEW | Transaction requires manual review |
| INTENT_FINGERPRINT_MISMATCH | Transaction was tampered with |
| POLICY_HASH_MISMATCH | Policy changed unexpectedly |
| AUTH_EXPIRED | Approval token expired (60s window) |
| AUTH_USED | Token already used (replay prevention) |
| AUTH_INVALID | Gate 2 rejected the token |
| AUTH_MISMATCH | Gate 2 fingerprint mismatch |
| NETWORK_ERROR | Failed to reach policy API |
| VALIDATION_ERROR | Gate 1 request failed |
| VERIFICATION_ERROR | Gate 2 request failed |
Input errors:
| Code | Meaning |
|------|---------|
| INVALID_AMOUNT_FORMAT | Amount must be whole-number string |
| TOKEN_ADDRESS_REQUIRED | Non-native asset needs tokenAddress |
| UNKNOWN_CHAIN | Chain not in chainIds map |
| CHAIN_MISMATCH | Adapter on wrong chain |
Adapter errors:
| Code | Meaning |
|------|---------|
| ADAPTER_TX_FAILED | Transaction execution failed |
| ADAPTER_TX_NOT_FOUND | Transaction not found after waiting |
| ADAPTER_TX_HASH_MISSING | Transaction hash not in response |
| ADAPTER_NO_PROVIDER | Signer/wallet missing provider |
| ADAPTER_NO_ACCOUNT | WalletClient missing account |
| ADAPTER_TOKEN_ADDRESS_REQUIRED | Token address needed for transfer |
| ADAPTER_INVALID_KEY | Invalid private key format |
| ADAPTER_NO_RPC_URL | No RPC URL configured for chain |
| ADAPTER_UNSUPPORTED_NETWORK | Network not supported by adapter |
| ADAPTER_RPC_ERROR | RPC call failed |
| ADAPTER_NO_FETCH | Fetch implementation required |
| ADAPTER_AMOUNT_OVERFLOW | Amount exceeds safe integer range |
| ADAPTER_INVALID_AMOUNT | Amount cannot be negative |
| TRANSFER_FAILED | Coinbase transfer failed |
Policy denial reasons (in error.message):
| Reason | Meaning |
|--------|---------|
| DAILY_LIMIT | Daily spending cap reached |
| PER_TX_LIMIT | Single transaction too large |
| HOURLY_LIMIT | Hourly rate limit hit |
| TX_FREQUENCY_LIMIT | Too many transactions per hour |
| RECIPIENT_NOT_WHITELISTED | Address not on approved list |
How It Works
Agent → Gate 1 (Validate) → Gate 2 (Verify) → Sign & Broadcast
↓ ↓
Check limits Detect tampering
Reserve amount Single-use tokenGate 1: SDK sends transaction intent to PolicyLayer API. Policy engine checks limits, reserves budget, returns signed approval token with intent fingerprint (includes
chain,asset,to,amount,tokenAddress).Gate 2: Before signing, SDK verifies the approval token. If the intent was modified after approval, fingerprints won't match → rejected.
Execute: Only after both gates pass does the SDK sign with your local private key and broadcast.
Amount Formatting
Amounts must be strings in the smallest unit:
// USDC (6 decimals): 1 USDC = 1000000
amount: '1000000'
// ETH (18 decimals): 1 ETH = 1000000000000000000
amount: '1000000000000000000'
// SOL (9 decimals): 1 SOL = 1000000000
amount: '1000000000'Helper with ethers:
import { parseUnits } from 'ethers';
amount: parseUnits('100', 6).toString() // 100 USDCTypeScript
Full TypeScript support:
import {
PolicyWallet,
PolicyConfig,
PolicyError,
PolicyCounters,
PolicyMetadata,
SendIntent,
SendResult,
DecisionProof,
WalletAdapter,
WalletAdapterMetadata,
ResolvedIntent,
LogLevel,
} from '@policylayer/sdk';Links
License
MIT
