botlock-agent-sdk

Drop-in fetch() replacement for AI agents. Automatically detects, pays, and retries HTTP 402 paywalls. USDC settlement on Solana with configurable per-request and lifetime budget caps.

Overview

Replace fetch() with client.fetch(). On a 200 response, it's a pure passthrough. On a 402, the SDK:

  1. Parses the x402 envelope and challenge token
  2. Validates it against your safety policy (network, mint, amount, recipient)
  3. Builds, signs, and submits a USDC SPL transfer on Solana
  4. Retries the original request with X-PAYMENT and x-paywall-challenge
  5. Returns the unlocked Response with res.paywallPayment attached
Your policy is enforced client-side before anything is signed. A malicious or misconfigured server cannot drain your wallet — all checks happen before the transaction is built.

Installation

bash
npm install botlock-agent-sdk \
  @solana/web3.js \
  @solana/spl-token \
  @x402-solana/core

The Solana and x402 packages are peer dependencies — your project stays in control of versions.

Fund your agent wallet

The agent needs SOL (for transaction fees) and USDC (for payments).

bash
# Devnet SOL (for fees)
solana airdrop 2 --url devnet

# Devnet USDC — get from Circle faucet:
# https://faucet.circle.com
# Mint: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU

# Check balances
solana balance --url devnet
spl-token balance 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --url devnet

Quick Start

js
import { createAgentPaywallClient, fromKeypairFile } from "botlock-agent-sdk";

const client = createAgentPaywallClient({
  network: "devnet",
  signer: fromKeypairFile(),
  maxAmountMicroUsdc: 10_000,
  maxTotalMicroUsdc: 1_000_000,
});

const res = await client.fetch("https://publisher.com/articles/ai-trends");
const data = await res.json();

if (res.paywallPayment) {
  console.log("paid:", res.paywallPayment.signature);
  console.log("amount:", res.paywallPayment.amountMicroUsdc, "micro-USDC");
}

console.log(client.spend());
// { totalMicroUsdc: 1000, count: 1, lastSignature: "..." }

Signers

The SDK needs to sign Solana transactions. Pick the helper that matches your setup.

Keypair file (Solana CLI default)

js
import { fromKeypairFile } from "botlock-agent-sdk";

signer: fromKeypairFile()
signer: fromKeypairFile("/path/to/keypair.json")

JSON array (solana-keygen format)

js
import { fromSecretKeyArray } from "botlock-agent-sdk";

const arr = JSON.parse(fs.readFileSync("keypair.json", "utf8"));
signer: fromSecretKeyArray(arr)

Base58 secret key (from env var)

js
import { fromSecretKeyBase58 } from "botlock-agent-sdk";

signer: fromSecretKeyBase58(process.env.AGENT_WALLET_SECRET)

Existing @solana/web3.js Keypair

js
import { fromKeypair } from "botlock-agent-sdk";
import { Keypair } from "@solana/web3.js";

signer: fromKeypair(Keypair.generate())

Custom signer (HSM / KMS / browser wallet)

js
const signer = {
  publicKey: myPublicKey,
  async signTransaction(tx) {
    await myKms.sign(tx);
    return tx;
  },
};

signer: signer

Configuration

Pass these options to createAgentPaywallClient({ ... }).

OptionDefaultDescription
signerrequiredKeypair or custom signer. See Signers section.
network"devnet""devnet", "mainnet-beta", or "testnet".
rpcUrlpublic RPCOverride Solana RPC endpoint. Use a paid RPC in production.
maxAmountMicroUsdcunlimitedHard per-request cap. SDK refuses any 402 that asks for more.
maxTotalMicroUsdcunlimitedLifetime budget for this client instance. Throws when exceeded.
allowedMintsanyRestrict acceptable USDC mint addresses.
allowedRecipientsanyRestrict acceptable payTo ATAs.
autoPaytrueIf false, 402s pass through unchanged — SDK will not pay.
userAgentbotlock-agent-sdk/0.1User-Agent sent with all requests.
confirmCommitment"confirmed"Solana commitment level for transaction confirmation.
onChallenge(info)Hook called before each payment. Return false to refuse.
onPayment(info)Hook called after each successful payment.

Safety Guards

Every check runs client-side before any transaction is built. Violations throw typed errors immediately — no SOL or USDC leaves the wallet.

What is checkedHow to configure
Network mismatch (mainnet claim on devnet)Automatic — always enforced
Amount exceeds per-request capmaxAmountMicroUsdc
Cumulative spend exceeds session budgetmaxTotalMicroUsdc
Asset mint not in allowlistallowedMints
Recipient ATA not in allowlistallowedRecipients
Programmatic approvalonChallenge — return false to block
Insufficient USDC balanceAutomatic — checked before transaction build

Hooks

onChallenge — approve or refuse before paying

js
const client = createAgentPaywallClient({
  // ...
  onChallenge: async ({ url, amountMicroUsdc, envelope }) => {
    console.log(`About to pay ${amountMicroUsdc} µUSDC for ${url}`);

    if (amountMicroUsdc > 5_000) return false;

    return true;
  },
});

onPayment — record after each payment

js
const client = createAgentPaywallClient({
  // ...
  onPayment: async ({ url, signature, amountMicroUsdc, payTo, network }) => {
    await db.insert({ url, signature, amountMicroUsdc, ts: new Date() });
  },
});

Errors thrown inside onPayment are silently swallowed — they won't break the response flow.

Error Handling

All SDK errors extend PaywallError and carry a .code string.

js
import {
  PaymentRefusedError,
  PaymentBudgetExceededError,
  UnsupportedChallengeError,
  OnChainError,
  VerificationRejectedError,
} from "botlock-agent-sdk";

try {
  const res = await client.fetch("https://publisher.com/article");
} catch (err) {
  if (err instanceof PaymentRefusedError) {
    // Policy refused: wrong network, mint, recipient, or amount too high.
  } else if (err instanceof PaymentBudgetExceededError) {
    // Lifetime budget exhausted.
  } else if (err instanceof UnsupportedChallengeError) {
    // The 402 was malformed or uses an unsupported payment scheme.
  } else if (err instanceof OnChainError) {
    // RPC failure, insufficient balance, or tx rejected on-chain.
  } else if (err instanceof VerificationRejectedError) {
    // Payment submitted on-chain but server returned 402/403 anyway.
    console.error("sig:", err.details.signature);
  } else {
    throw err;
  }
}
Error classCodeWhen it throws
PaymentRefusedErrorPAYMENT_REFUSEDNetwork/mint/recipient/amount violates your policy
PaymentBudgetExceededErrorBUDGET_EXCEEDEDmaxTotalMicroUsdc would be exceeded
UnsupportedChallengeErrorUNSUPPORTED_CHALLENGE402 is malformed or unsupported scheme
OnChainErrorON_CHAIN_ERRORRPC, balance, or confirmation failure
VerificationRejectedErrorVERIFICATION_REJECTEDServer rejected payment after on-chain confirmation

LangChain Integration

js
import { createAgentPaywallClient, fromKeypairFile } from "botlock-agent-sdk";
import { paywallFetchTool } from "botlock-agent-sdk/langchain";
import { createOpenAIToolsAgent, AgentExecutor } from "langchain/agents";
import { ChatOpenAI } from "@langchain/openai";

const client = createAgentPaywallClient({
  network: "mainnet-beta",
  signer: fromKeypairFile(),
  maxAmountMicroUsdc: 5_000,
});

const tool = paywallFetchTool(client, {
  allowHost: (host) => host.endsWith("trusted-publisher.com"),
});

const llm = new ChatOpenAI({ model: "gpt-4o" });
const agent = await createOpenAIToolsAgent({ llm, tools: [tool], prompt });
const executor = AgentExecutor.fromAgentAndTools({ agent, tools: [tool] });

const result = await executor.invoke({
  input: "Fetch the article at https://trusted-publisher.com/articles/ai-2026",
});

OpenAI function-calling (manual)

js
const tool = paywallFetchTool(client);

// tool.name        -> "paywall_fetch"
// tool.description -> natural language description for the model
// tool.schema      -> JSON Schema of arguments ({ url, method?, headers? })
// tool.invoke(args) -> Promise<string> (response body as text)

Concurrency & Idempotency

Concurrent client.fetch() calls to the same URL+nonce coalesce automatically. If ten parallel requests all receive the same 402, only one payment is submitted. All ten callers receive the unlocked response.

js
// Safe — only ONE payment is sent for these concurrent calls
const [a, b, c] = await Promise.all([
  client.fetch("https://publisher.com/article"),
  client.fetch("https://publisher.com/article"),
  client.fetch("https://publisher.com/article"),
]);

// All three responses are the same unlocked content
// client.spend().count === 1
Create separate client instances if you need independent payment tracking per caller or per-agent budget isolation.

Spend Tracking

js
const stats = client.spend();
// {
//   totalMicroUsdc: 4500,
//   count: 3,
//   lastSignature: "3jK9...",
//   limit: 1_000_000,
//   remaining: 995_500
// }

The spend tracker resets when the client is re-created (i.e. per Node.js process). Use onPayment to persist receipts across restarts.

res.paywallPayment shape

js
res.paywallPayment = {
  url:             "https://publisher.com/article",
  signature:       "3jK9xZ...",
  amountMicroUsdc: 1000,
  payTo:           "7xKpT...",
  asset:           "EPjFW...",
  network:         "mainnet-beta",
  challengeToken:  "tok_9fK2...",
}

// Note: paywallPayment is non-enumerable — won't appear in JSON.stringify(res)