← All Docs

SDK Overview

SDK Overview

Install, configure, and operate the PolicyLayer SDK.


Installation

Coming Soon

The SDK package will be available on npm soon. For now, local development requires workspace installation.


Initialising PolicyWallet

import { createEthersAdapter, PolicyWallet } from '@policylayer/sdk';

export async function createPolicyWallet() {
  const adapter = await createEthersAdapter(
    process.env.PRIVATE_KEY!,
    process.env.RPC_URL!
  );

  return new PolicyWallet(adapter, {
    apiUrl: process.env.POLICYLAYER_API_URL!,
    apiKey: process.env.POLICYLAYER_API_KEY!,
    expectedPolicyHash: process.env.POLICYLAYER_POLICY_HASH, // optional
    metadata: {
      orgId: process.env.ORG_ID!,
      walletId: 'my-wallet',
      label: 'my-agent'
    },
    logLevel: process.env.NODE_ENV === 'production' ? 'error' : 'info',
  });
}

Required Parameters

  • apiUrl – PolicyLayer API base URL (e.g. http://localhost:3001 for local development)
  • apiKey – Required agent key; scopes policies and counters

Optional Parameters

  • expectedPolicyHash – SHA-256 of the active policy for integrity checks (fails closed if mismatch)
  • metadata – Object forwarded to API for analytics/audit logs (orgId, walletId, label, custom fields)
  • logLevel – Controls verbosity: 'silent' | 'error' | 'warn' | 'info' | 'debug' (default: 'info')
  • assetDecimals – Map of token symbol/address → decimals; overrides formatting for custom assets
  • fetch – Provide fetch implementation for Node 18- environments (Node 18+ includes global fetch)
  • logger – Supply custom logger (pino/winston) to redirect SDK logs

Configuration Reference

FieldTypeRequiredDescription
apiUrlstringPolicyLayer API base URL
apiKeystringAgent authentication credential
expectedPolicyHashstringOptional SHA-256 for integrity verification
metadataobjectCustom fields for audit logs (orgId, walletId, label, etc.)
assetDecimalsObject (symbol → decimals)Custom decimal overrides per asset
chainIdsObject (chain → chainId)Custom chain ID mappings
assetResolverFunction(chain, asset) => tokenAddress for token resolution
decisionSigningKeysObject[]Public keys for ECDSA signature verification
timeoutMsnumberAPI request timeout (default: 30000)
maxRetriesnumberRetry attempts for network errors (default: 3)
fetchtypeof fetchFetch polyfill (required for Node 18-)
loggerLoggerCustom logger instance
logLevelLogLevelLog verbosity level

Supported Chains (Default)

The SDK includes built-in chain ID mappings:

Chain NameChain IDNotes
ethereum1Mainnet
eth-sepolia11155111Testnet
base8453Mainnet
base-sepolia84532Testnet
polygon137Mainnet
polygon-mumbai80001Testnet
arbitrum42161Mainnet
optimism10Mainnet
avalanche43114Mainnet
bsc56BNB Chain
solana0Special (non-EVM)

Override via chainIds config if needed.

Asset Decimals Resolution

The SDK resolves asset decimals in this order:

  1. User-provided assetDecimals config
  2. Built-in defaults (ETH=18, USDC=6, USDT=6, DAI=18, WBTC=8)
  3. Adapter’s default (typically 18)

Note: The SDK requires a working fetch. Node 18+ exposes it globally; older runtimes must pass config.fetch.

⚠️ Deprecated: policyId parameter is deprecated (policy group now derived from API key server-side). Remove from new integrations.


Sending a Transaction

const wallet = await createPolicyWallet();

try {
  const result = await wallet.send({
    chain: 'ethereum',  // lowercase: 'ethereum', 'base', etc.
    asset: 'eth',       // lowercase: 'eth', 'usdc', 'dai', etc.
    to: '0xRecipient...',
    amount: '25000000000000000', // ALWAYS string in base units (wei)
    memo: 'Invoice #982',         // optional, included in intent fingerprint
  });

  console.log('Hash:', result.hash);
  console.log('Fee (wei):', result.fee.toString());
  console.log('Allowed:', result.allowed);
  if (result.counters) {
    console.log('Counters:', result.counters);
  }
  if (result.decisionProof) {
    console.log('Decision proof:', result.decisionProof);
  }
} catch (error) {
  if (error instanceof PolicyError) {
    console.error(error.code, error.message);
    console.error('Counters:', error.details?.counters);
    console.error('Decision proof:', error.details?.decisionProof);
  } else {
    throw error;
  }
}

⚠️ CRITICAL: Amount Format

Amounts MUST be strings containing only digits (no decimals, no scientific notation):

  • ETH: "1000000000000000000" = 1 ETH (18 decimals)
  • USDC: "1000000" = 1 USDC (6 decimals)
  • DAI: "1000000000000000000" = 1 DAI (18 decimals)

Invalid examples that will throw INVALID_AMOUNT_FORMAT:

  • "1.5" - Decimals not allowed
  • "1e18" - Scientific notation not allowed
  • 1000000 - Must be string, not number (throws INVALID_AMOUNT_TYPE)

Convert to base units before calling: parseEther("1.5")"1500000000000000000"

SendIntent Structure

interface SendIntent {
  chain: string;         // Required: Chain identifier (lowercase): 'ethereum', 'base', 'solana', etc.
  asset: string;         // Required: Asset identifier (lowercase): 'eth', 'usdc', or contract address
  to: string;            // Required: Recipient address
  amount: string;        // Required: Amount in base units - DIGITS ONLY (no decimals)
  memo?: string;         // Optional: Included in intent fingerprint via SHA-256
  tokenAddress?: string; // Optional: Required for non-native ERC-20s without assetResolver
}

SendResult Structure

interface SendResult {
  hash: string;                    // Transaction hash
  fee: bigint;                     // Gas fee paid (in base units, e.g. wei)
  allowed: boolean;                // Always true (failures throw PolicyError)
  status: 'success';               // Always 'success' (failures throw PolicyError)
  receipt?: TransactionReceipt;    // Transaction receipt (undefined if skipConfirmation)
  counters?: PolicyCounters;       // Optional: Live spending counters (if available)
  decisionProof?: DecisionProof;   // Optional: Decision proof components (if available)
}

See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.

Note: result.fee is the gas fee paid for the transaction (in wei for EVM chains), not the transaction amount.

Note: Failures throw PolicyError rather than returning a failed result. The allowed and status fields are always true and 'success' respectively on return.

Fail-Closed Behaviour

PolicyWallet.send fails closed—no signing occurs until both policy gates succeed. If Gate 1, Gate 2, or blockchain broadcast fails, the SDK throws an error and never signs the transaction.


Validating Before Sending (Dry Run)

Use wallet.validate() to check if a transaction would be allowed without actually sending it. This is useful for:

  • UI feedback – Show users if their transaction will succeed before they confirm
  • Pre-flight checks – Validate inputs in batch jobs before committing to execution
  • Budget planning – Check remaining limits without consuming them

Usage

const intent = {
  chain: 'ethereum',
  asset: 'eth',
  to: '0xRecipient...',
  amount: '25000000000000000',  // 0.025 ETH
};

const result = await wallet.validate(intent);

if (result.allowed) {
  console.log('Transaction would be allowed');
  console.log('Remaining daily:', result.counters?.remainingDaily);
  // Optionally proceed with wallet.send(intent)
} else {
  console.log('Transaction would be denied:', result.reason);
  console.log('Today spent:', result.counters?.todaySpent);
}

ValidateResult Structure

interface ValidateResult {
  allowed: boolean;                // Would the transaction pass policy?
  reason?: string;                 // Denial reason (if not allowed)
  counters?: PolicyCounters;       // Current spending state
  decisionProof?: DecisionProof;   // Decision proof components
}

Key Differences from send()

Aspectvalidate()send()
Counters consumed❌ No✅ Yes
Auth token issued❌ No✅ Yes
Transaction signed❌ No✅ Yes
Blockchain broadcast❌ No✅ Yes
Audit logged❌ No✅ Yes
Returns on denial{ allowed: false }Throws PolicyError

When to Use

  • Before confirmation dialogs – Check policy before asking user to confirm
  • Form validation – Validate amount field as user types (debounced)
  • Batch planning – Pre-validate a list of payments before executing any
  • Debugging – Test policy configuration without side effects

Note: validate() returns the current counter state. Between validation and execution, counters may change if other transactions occur. Always handle send() errors gracefully.


Error Codes

Policy Errors

CodeMeaningNext Step
POLICY_DECISION_DENYPolicy blocked the transferCheck counters; adjust limits or whitelist
POLICY_DECISION_REVIEWManual review required (future)Wait for human approval
POLICY_DECISION_UNKNOWNUnrecognised decision from policy APICheck API logs; contact support
POLICY_HASH_MISMATCHDashboard hash ≠ SDK expectationUpdate expectedPolicyHash in config
POLICY_HASH_MISSINGGate 1 didn’t return policy hashCheck API implementation

Authentication Errors

CodeMeaningNext Step
AUTH_INVALIDGate 2 rejected authorisation tokenToken tampered or invalid; retry send
AUTH_EXPIREDToken expired (>60s since Gate 1)Generate fresh token via new send
AUTH_USEDToken already consumed (replay attempt)Generate fresh token via new send
AUTH_MISMATCHIntent fingerprint mismatchTampering detected; investigate
AUTH_TOKEN_MISSINGGate 1 didn’t return auth tokenCheck API implementation

Validation Errors

CodeMeaningNext Step
VALIDATION_ERRORGate 1/2 validation failedCheck request format
INTENT_FINGERPRINT_MISMATCHIntent modified between gatesTampering detected; investigate
INVALID_AMOUNT_TYPEAmount not provided as stringFix: amount: "1000000" (string, not number)
INVALID_AMOUNT_EMPTYAmount is empty stringProvide valid amount
INVALID_AMOUNT_FORMATAmount contains non-digit charsDigits only: "1000000" (no decimals like "1.5", no "1e18")
INVALID_INTENT_FIELDRequired field missing/invalidCheck SendIntent structure

Network/System Errors

CodeMeaningNext Step
NETWORK_ERRORNetwork request failedCheck API connectivity; retry
FETCH_UNAVAILABLENo fetch implementation foundProvide config.fetch for Node 18-
VERIFICATION_ERRORGate 2 verification failedCheck logs; retry
UNKNOWN_ERRORUnexpected failureInspect error details; contact support

Chain/Token Errors

CodeMeaningNext Step
UNKNOWN_CHAINChain identifier not recognisedCheck chain name (lowercase: ‘ethereum’, ‘base’, etc.)
CHAIN_MISMATCHAdapter connected to wrong chainCreate adapter for correct chain
TOKEN_ADDRESS_REQUIREDNon-native asset without addressProvide tokenAddress or configure assetResolver

Signature Errors

CodeMeaningNext Step
SIGNATURE_MISSINGResponse missing signatureCheck decisionSigningKeys config
INVALID_SIGNATURESignature verification failedPotential tampering; investigate

Transaction Errors

CodeMeaningNext Step
TX_EXECUTION_FAILEDTransaction reverted on-chainCheck recipient, amount, gas; review revert reason

Adapter Errors

CodeMeaningAdapter
ADAPTER_NO_PROVIDERSigner missing providerEthers
ADAPTER_TX_FAILEDTransaction execution failedAll
ADAPTER_TX_NOT_FOUNDTransaction not found after waitingAll
ADAPTER_TOKEN_ADDRESS_REQUIREDTransfer needs token addressAll
ADAPTER_INVALID_KEYInvalid private key formatSolana
ADAPTER_NO_ACCOUNTWallet account missingViem
ADAPTER_NO_RPC_URLChain has no configured RPCCoinbase
ADAPTER_UNSUPPORTED_NETWORKNetwork not supportedCoinbase
ADAPTER_RPC_ERRORRPC call failedPrivy
ADAPTER_NO_FETCHMissing fetch implementationPrivy
ADAPTER_AMOUNT_OVERFLOWAmount exceeds MAX_SAFE_INTEGERSolana
ADAPTER_INVALID_AMOUNTNegative amount providedSolana
ADAPTER_TX_HASH_MISSINGTransaction hash not returnedCoinbase
TRANSFER_FAILEDcreateTransfer call failedCoinbase
SIGNING_UNAVAILABLEWallet address cannot signCoinbase

Error Object Structure

class PolicyError extends Error {
  code: string;                // Error code from table above
  message: string;             // Human-readable description
  details?: {
    counters?: PolicyCounters;     // Current spending state (if available)
    decisionProof?: DecisionProof; // Decision proof (if available)
  };
}

See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.


Decision Proofs

Successful wallet.send() responses include decision proof components:

{
  "policyHash": "ba500a3fee18ba269cd...",
  "decision": "allow",
  "signature": "0x...",
  "signatureIssuedAt": "2025-02-14T12:34:56Z",
  "apiKeyId": "api_key_123",
  "intentFingerprint": "7c5b..."
}

Store proofs for:

  • Compliance reporting
  • Audit trail reconciliation
  • Dashboard log verification

Retrieving Last Decision Proof

// Get last decision proof without making new transaction
const proof = wallet.getLatestDecisionProof();
if (proof) {
  console.log('Last decision:', proof.decision);
  console.log('Policy hash:', proof.policyHash);
}

Helper Methods

Access Underlying Adapter

// Get direct access to wallet adapter
const adapter = wallet.getAdapter();
const balance = await adapter.getBalance();
console.log('Balance:', balance.toString());

Get Wallet Address

// Get wallet address
const address = await wallet.getAddress();
console.log('Address:', address);

Get Wallet Balance

// Get native token balance (e.g. ETH)
const balance = await wallet.getBalance();
console.log('Balance:', balance.toString(), 'wei');

Adapters

  • ethers – Default, broad ecosystem support
  • viem – Modern TypeScript-first option (peer dependency)
  • Solana – Includes lamports helper utilities (bundled)
  • Coinbase, Dynamic, Privy – Embedded or custodial options

All adapters implement the WalletAdapter interface defined in the SDK (packages/sdk/src/adapters/types.ts). You can create custom adapters by implementing these methods.


Advanced Topics

Automatic Features

The SDK automatically handles:

  • Nonce generation – Unique nonce (UUID) generated per request to prevent fingerprint collisions
  • Idempotency keys – Automatic idempotencyKey generation to prevent duplicate transactions on retry
  • Single-use tokens – Authorization tokens consumed at Gate 2, preventing replay attacks
  • Intent fingerprinting – SHA-256 hash of canonicalized intent for tamper detection

Memo Usage

The memo field is optional but useful for:

  • Correlation IDs (invoice numbers, order IDs)
  • Audit trail context
  • Customer reference numbers
const result = await wallet.send({
  chain: 'ethereum',
  asset: 'usdc',
  to: '0xCustomer...',
  amount: '5000000',  // 5 USDC
  memo: 'Refund for order #12345'
});

The memo is included in the intent fingerprint (as SHA-256 hash) and appears in audit logs.

Retry Behaviour

Important: Once Gate 1 succeeds, spending counters are updated immediately (hard reservation). If Gate 2 or blockchain broadcast fails afterward, the transaction still consumes your budget.

For failed transactions after Gate 1:

  • Counters remain updated (not rolled back)
  • Generate new transaction to retry
  • Budget already consumed

This prevents race conditions and “double spending” via concurrent requests.

Transaction Lifecycle

  1. SDK generates nonce + idempotency key
  2. Gate 1 evaluates policy → reserves amount → issues token
  3. Gate 2 verifies token + fingerprint → marks consumed
  4. SDK signs transaction locally with your keys
  5. SDK broadcasts to blockchain
  6. SDK returns hash + fee + counters + decision proof

If any step fails, SDK throws error and does not proceed to next step.

x402 Duplicate Detection

For HTTP 402 payments, PolicyLayer prevents accidental double-payments using a 5-minute deduplication window.

What makes a request “duplicate”:

The API computes a hash from these canonicalised fields:

FieldNormalisation
amountTrimmed (exact string match)
chainLowercase (e.g., base)
currencyUppercase (e.g., USDC)
endpointTrimmed URL
recipientLowercase wallet address

If these five fields produce the same hash within 5 minutes for the same agent, the request is rejected as X402_DUPLICATE_PAYMENT.

Scoping:

  • Detection is scoped to org + agent (API key)
  • Two different agents can make identical payments without collision
  • The same agent making the same request twice within 5 minutes = duplicate

Why this exists:

HTTP 402 retries are common—network timeouts, SDK restarts, or user impatience can trigger repeated calls. Without deduplication, agents could accidentally pay twice for the same resource.

Handling duplicates:

const result = await x402Wallet.pay(headers, endpoint);

if (!result.allowed && result.reason === 'X402_DUPLICATE_PAYMENT') {
  // This exact payment was already approved within last 5 minutes
  // Safe to proceed—original approval is still valid
  console.log('Using existing approval');
}

Not configurable: The 5-minute window is fixed. This provides consistent security guarantees. If your use case requires identical payments within 5 minutes, vary one field (e.g., add timestamp to endpoint query string).


Integration Tips

  • Use memo for correlation IDs – Intent fingerprint includes SHA-256 hash of memo
  • Store decision proofs – Reconcile SDK responses with dashboard audit logs
  • Handle errors gracefully – Check error.details.counters to diagnose policy blocks
  • Rotate API keys – When agent roles change, only latest key remains valid
  • Test with small amounts – Use testnet/sepolia for integration testing

TypeScript Interface Reference

Complete type definitions from the SDK (packages/sdk/src/types.ts).

PolicyCounters

export interface PolicyCounters {
  todaySpent?: string;         // Amount spent today (optional, in base units)
  remainingDaily?: string;     // Remaining daily budget (optional, in base units)
  [key: string]: string | undefined;  // Extensible for custom counters
}

Note: All fields are optional. The API may return additional counter fields beyond todaySpent and remainingDaily (e.g. hourly counters, transaction counts). Use index signature [key: string] to access them.

DecisionProof

export interface DecisionProof {
  policyHash: string;              // Required: SHA-256 hash of active policy
  decision: 'allow' | 'deny' | 'review';  // Required: Policy decision
  intentFingerprint: string;       // Required: SHA-256 hash of transaction intent
  signature?: string;              // Optional: HMAC signature of decision
  signatureIssuedAt?: string;      // Optional: ISO timestamp when signature issued
  apiKeyId?: string;               // Optional: API key that authorised the decision
}

Note: Only policyHash, decision, and intentFingerprint are guaranteed. Other fields (signature, signatureIssuedAt, apiKeyId) may be absent depending on API configuration.

PolicyMetadata

export interface PolicyMetadata {
  orgId?: string;
  walletId?: string;
  label?: string;
  [key: string]: string | undefined;  // Extensible for custom metadata
}

Usage: Custom metadata fields forwarded to the policy API for analytics and audit logs. All fields are optional and user-defined.

SendIntent

export interface SendIntent {
  chain: string;         // Required: Chain identifier (lowercase): 'ethereum', 'base', 'solana', etc.
  asset: string;         // Required: Asset identifier (lowercase): 'eth', 'usdc', or contract address
  to: string;            // Required: Recipient address
  amount: string;        // Required: Amount in base units - DIGITS ONLY (regex: ^\d+$)
  memo?: string;         // Optional: Included in intent fingerprint via SHA-256
  tokenAddress?: string; // Optional: Required for non-native ERC-20s without assetResolver
}

Amount validation: Must be a string of digits only. "1.5" and "1e18" will throw INVALID_AMOUNT_FORMAT.

SendResult

export interface SendResult {
  hash: string;                    // Transaction hash
  fee: bigint;                     // Gas fee paid (0n if skipConfirmation)
  allowed: boolean;                // Always true (failures throw PolicyError)
  status: 'success';               // Always 'success' (failures throw PolicyError)
  receipt?: TransactionReceipt;    // Present unless skipConfirmation
  counters?: PolicyCounters;       // Optional: Live spending counters
  decisionProof?: DecisionProof;   // Optional: Decision proof components
}

Note: Failed transactions throw PolicyError rather than returning a result with status: 'failed'.

ValidateResult

export interface ValidateResult {
  allowed: boolean;                // Would the transaction pass policy checks?
  reason?: string;                 // Denial reason (only present if allowed=false)
  counters?: PolicyCounters;       // Current spending counters (not consumed)
  decisionProof?: DecisionProof;   // Decision proof components
}

Note: Unlike SendResult, validation results return { allowed: false } for denials rather than throwing. Counters are read-only—no budget consumed.

PolicyError

export class PolicyError extends Error {
  code: string;                // Error code (see Error Codes section)
  message: string;             // Human-readable description
  details?: {
    counters?: PolicyCounters;     // Current spending state (if available)
    decisionProof?: DecisionProof; // Decision proof (if available)
  };
}

API Denial Reasons

When the policy API denies a transaction, the reason field indicates why. These are returned in PolicyError.message or in the API response body.

Asset Policy Denials

These apply to standard asset transfers via PolicyWallet.send():

ReasonMeaningHow to Fix
RECIPIENT_NOT_WHITELISTEDRecipient address not in allowlistAdd recipient to whitelist in dashboard
TX_FREQUENCY_LIMITToo many transactions in time windowWait for window to reset or increase limit
PER_TX_LIMITSingle transaction exceeds max amountReduce amount or increase per-tx limit
HOURLY_LIMITHourly spending cap reachedWait for next hour or increase limit
DAILY_LIMITDaily spending cap reachedWait for next day or increase limit
NO_POLICY_FOR_ASSETNo policy configured for this assetCreate policy for asset in dashboard
POLICY_NOT_FOUNDReferenced policy does not existCheck policy ID is correct
POLICY_ACCESS_DENIEDAPI key not authorised for this policyCheck API key permissions
POLICY_HASH_MISMATCHPolicy changed since SDK initialisedUpdate expectedPolicyHash or re-fetch
ASSET_MISMATCHTransaction asset doesn’t match policy assetUse correct asset for this policy
UNSUPPORTED_CHAINChain not supported by this policyAdd chain support or use different policy
UNSUPPORTED_ASSETAsset type not supportedAdd asset to policy or use different asset
INVALID_INTENTMalformed or invalid transaction intentCheck intent structure matches SDK spec
IDEMPOTENCY_KEY_MISMATCHDuplicate request with different intentUse unique idempotency key per request
FINGERPRINT_MISMATCHIntent modified between Gate 1 and Gate 2Potential tampering; investigate
POLICY_MODIFIED_AFTER_AUTHPolicy changed between gatesRetry transaction

x402 Policy Denials

These apply to HTTP 402 payment protocol transactions:

ReasonMeaningHow to Fix
X402_NO_DEFAULT_POLICYNo x402 policy configured for agentSet up default x402 policy in dashboard
X402_DUPLICATE_PAYMENTSame payment already processed within 5 minSee Duplicate Detection
X402_RECIPIENT_NOT_ALLOWEDRecipient not in allowlistAdd recipient to x402 allowlist
X402_RECIPIENT_BLOCKEDRecipient is blocklistedRemove from blocklist or use different recipient
X402_RECIPIENT_MISMATCHRecipient doesn’t match pinned recipient for endpointUse correct recipient for this endpoint
X402_CURRENCY_NOT_ALLOWEDCurrency not permitted for this endpointAdd currency to allowed list
X402_HEADERS_HASH_MISMATCHRequest headers hash verification failedSDK bug or tampering; contact support

x402 Limit Denials

Rate and spending limits for x402, applied at agent (aggregate) or endpoint level:

ReasonMeaningHow to Fix
X402_AGGREGATE_AMOUNT_LIMITSingle request exceeds agent’s max per-requestReduce amount or increase agent limit
X402_AGGREGATE_DAILY_LIMITAgent’s total daily spend reachedWait for daily reset or increase limit
X402_AGGREGATE_FREQUENCY_LIMITAgent’s requests/minute limit hitSlow down or increase agent rate limit
X402_ENDPOINT_AMOUNT_LIMITRequest exceeds endpoint’s max per-requestReduce amount or increase endpoint limit
X402_ENDPOINT_DAILY_LIMITEndpoint’s daily spend cap reachedWait for reset or increase endpoint limit
X402_ENDPOINT_FREQUENCY_LIMITEndpoint’s requests/minute limit hitSlow down or increase endpoint rate limit

Note: x402 enforces limits at two levels. Agent limits (aggregate) apply across all endpoints. Endpoint limits are sub-limits within the agent cap. Requests must pass both.

System Errors

ReasonMeaningHow to Fix
INTERNAL_ERRORUnexpected server errorCheck API logs; contact support
SERVICE_UNAVAILABLEAPI temporarily unavailableRetry with backoff
INVALID_API_KEYAPI key not recognisedCheck key is correct and not revoked

Next Steps