npm package: @policylayer/sdk
Description: Non-custodial spending controls for AI agent wallets. Enforce limits without holding keys.
Author: PolicyLayer
Latest version: 1.4.0

# @policylayer/sdk

> Non-custodial spending controls for AI agent wallets

[![npm version](https://img.shields.io/npm/v/@policylayer/sdk.svg)](https://www.npmjs.com/package/@policylayer/sdk)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org)
[![Server Only](https://img.shields.io/badge/runtime-server--only-blue.svg)](#runtime-requirements)

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

```bash
npm install @policylayer/sdk
```

**Install per adapter:**

```bash
# 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/ethereum
```

> **Light 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 `crypto` module
- **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](https://dashboard.policylayer.com/settings).

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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.

```typescript
// 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

```typescript
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:

```typescript
// 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:

```typescript
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 address
- `X-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

```typescript
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 string
```

### Error Handling

```typescript
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

1. **Parse Headers**: Extract payment details from 402 response headers
2. **Normalise Amount**: Convert decimal string to bigint using currency-specific decimals (precision-safe, no parseFloat)
3. **Gate 1 (Validate)**: Send intent to PolicyLayer API, receive authorisation token
4. **Gate 2 (Verify)**: Verify authorisation before payment execution
5. **Execute Payment**: Send payment to facilitator with `X-PolicyLayer-Auth` header 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.

```typescript
// 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:**

```typescript
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 accepted
- `logLevel`: `'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 `decisionSigningKeys` with your org's public key from dashboard
- [ ] Set `expectedPolicyHash` to 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 `decisionSigningKeys` yet, at least set `logLevel: '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`:

```typescript
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:

```typescript
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:**

```typescript
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:**

```typescript
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

```typescript
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:

```typescript
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 token
```

1. **Gate 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`).

2. **Gate 2**: Before signing, SDK verifies the approval token. If the intent was modified after approval, fingerprints won't match → rejected.

3. **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:

```typescript
// 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:

```typescript
import { parseUnits } from 'ethers';
amount: parseUnits('100', 6).toString() // 100 USDC
```

---

## TypeScript

Full TypeScript support:

```typescript
import {
  PolicyWallet,
  PolicyConfig,
  PolicyError,
  PolicyCounters,
  PolicyMetadata,
  SendIntent,
  SendResult,
  DecisionProof,
  WalletAdapter,
  WalletAdapterMetadata,
  ResolvedIntent,
  LogLevel,
} from '@policylayer/sdk';
```

---

## Links

- [Documentation](https://docs.policylayer.com)
- [Dashboard](https://app.policylayer.com)
- [GitHub](https://github.com/PolicyLayer/PolicyLayer)
- [Report Issues](https://github.com/PolicyLayer/PolicyLayer/issues)

## License

MIT