lucidAGENTS
Packages

@lucid-agents/payments

Bidirectional x402 payments, SIWX, policies, and atomic accounting.

The payments extension receives x402 payments, creates payment-enabled outbound Fetch clients, verifies SIWX credentials, enforces incoming and outgoing policies, and records payment history. One runtime owns this complexity; HTTP adapters do not install their own paywalls.

Installation

bun add @lucid-agents/payments @lucid-agents/core @lucid-agents/http

The package already declares the x402 protocol dependencies it uses. Install optional pg or stripe peers only when selecting those Node-specific integrations.

Receive x402 payments

import { z } from 'zod';
import { createAgent } from '@lucid-agents/core';
import { http } from '@lucid-agents/http';
import { payments, paymentsFromEnv } from '@lucid-agents/payments';

const agent = await createAgent({
  name: 'merchant',
  version: '1.0.0',
})
  .use(payments({ config: paymentsFromEnv() }))
  .use(http())
  .addEntrypoint({
    key: 'quote',
    input: z.object({ symbol: z.string() }),
    output: z.object({ price: z.number() }),
    price: '0.01',
    async handler({ input }) {
      return {
        output: { price: input.symbol === 'ETH' ? 3_000 : 0 },
      };
    },
  })
  .build();

Prices are USD decimal strings without a currency symbol. A flat string applies to invoke; use { invoke, stream } when operations have different prices. An entrypoint without a price is free.

If x402 and MPP are both installed, every priced entrypoint must choose one rail with paymentProtocol: 'x402' | 'mpp'.

Configuration

payments({
  config: {
    payTo: '0xabc0000000000000000000000000000000000000',
    facilitatorUrl: 'https://facilitator.example',
    facilitatorAuth: process.env.PAYMENTS_FACILITATOR_AUTH,
    network: 'eip155:84532',
    storage: { type: 'in-memory' },
  },
});

Supported aliases normalize to CAIP-2 identifiers:

AliasCanonical network
baseeip155:8453
base-sepoliaeip155:84532
ethereumeip155:1
sepoliaeip155:11155111
solanasolana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
solana-devnetsolana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1

paymentsFromEnv(overrides?, env?) reads:

  • PAYMENTS_RECEIVABLE_ADDRESS;
  • FACILITATOR_URL or PAYMENTS_FACILITATOR_URL;
  • NETWORK or PAYMENTS_NETWORK;
  • FACILITATOR_AUTH or PAYMENTS_FACILITATOR_AUTH;
  • PAYMENTS_DESTINATION=stripe and STRIPE_SECRET_KEY for Stripe mode.

Pass an explicit environment record in runtimes that do not expose process.env. Configuration is validated when a priced or SIWX entrypoint activates the payments runtime.

const config = paymentsFromEnv(
  { network: 'eip155:84532' },
  platformEnvironment
);

payments({ config: false }) explicitly disables the capability. An absent environment configuration produces no active payments runtime, allowing one build artifact to support free and paid deployments.

Authorization lifecycle

The HTTP extension owns one authorization transaction for invoke, stream, and task creation:

  1. Payments verifies x402 or SIWX and returns a stable subject without reserving or settling.
  2. An invoke wins its idempotency claim.
  3. admit() evaluates incoming policies and atomically reserves capacity.
  4. The entrypoint executes, or asynchronous stream/task work is accepted.
  5. finalize() first moves accounting into a durable, non-expiring staged batch, then settles and commits it; abort() releases a pre-settlement reservation.

Invalid input, failed handlers/output validation, failed admission, and failed settlement release provisional or staged capacity. If final accounting fails after an irreversible settlement, the staged batch remains counted until reconciliation instead of expiring open. A stream or task finalizes when its asynchronous work is successfully admitted because its HTTP operation is already live or accepted.

SIWX entitlements are checked before either x402 or MPP challenges, allowing both rails to reuse the same paid entitlement. If settlement becomes irreversible and later response persistence fails, HTTP retains the idempotency claim so a retry cannot execute or charge twice.

Storage boundaries

The root package is portable and defaults to isolated in-memory payment and SIWX storage. Durable backends are explicit subpaths. Merely selecting sqlite or postgres in configuration without passing its factory fails closed.

In memory

payments({ config: { ...config } });
// Equivalent: storage: { type: 'in-memory' }

Use this for tests, edge-style runtimes, or intentionally ephemeral processes.

SQLite

import {
  sqlitePaymentStorageFactory,
  sqliteSIWxStorageFactory,
} from '@lucid-agents/payments/storage/sqlite';

payments({
  config: {
    ...config,
    storage: {
      type: 'sqlite',
      sqlite: { dbPath: '.data/payments.db' },
    },
    siwx: {
      enabled: true,
      storage: {
        type: 'sqlite',
        sqlite: { dbPath: '.data/siwx.db' },
      },
    },
  },
  storageFactory: sqlitePaymentStorageFactory,
  siwxStorageFactory: sqliteSIWxStorageFactory,
});

SQLite uses Bun's SQLite runtime and is intended for one local process.

Postgres

import {
  postgresPaymentStorageFactory,
  postgresSIWxStorageFactory,
} from '@lucid-agents/payments/storage/postgres';

payments({
  agentId: 'merchant-production',
  config: {
    ...config,
    storage: {
      type: 'postgres',
      postgres: { connectionString: process.env.DATABASE_URL! },
    },
  },
  storageFactory: postgresPaymentStorageFactory,
  siwxStorageFactory: postgresSIWxStorageFactory,
});

Use a stable agentId to isolate agents sharing one database. Postgres transactions and advisory locking enforce the same reservation contract across processes.

All backends provide atomic total/rate reservations and durable staged settlement batches. Before payment, every applicable limit, rate, and history record moves into one non-expiring batch; after payment, that batch commits to history atomically. A post-settlement storage error leaves the staged amount counted instead of failing open after the reservation TTL. agent.close() releases storage resources.

Payment policies

Policy groups are conjunctive: every configured group must allow the payment.

payments({
  config: {
    ...config,
    policyGroups: [
      {
        name: 'daily-budget',
        outgoingLimits: {
          global: {
            maxPaymentUsd: 5,
            maxTotalUsd: 50,
            windowMs: 86_400_000,
          },
        },
        incomingLimits: {
          global: { maxPaymentUsd: 10, maxTotalUsd: 500 },
          perSender: {
            '0x1234567890123456789012345678901234567890': {
              maxTotalUsd: 25,
            },
          },
        },
        allowedRecipients: ['trusted.example'],
        blockedSenders: ['0xbad0000000000000000000000000000000000000'],
        rateLimits: {
          maxPayments: 100,
          windowMs: 3_600_000,
        },
      },
    ],
  },
});

Scopes resolve from most specific to least specific: endpoint, target/sender, then global. Incoming sender rules use only a cryptographically verified payer address from x402 or MPP; caller-controlled headers are never sender identity. Outgoing recipient rules use the destination URL.

MPP payments enter the same incoming policy and accounting transaction as x402. A policy that requires sender or USD amount data fails closed when the verified rail does not provide usable values.

Rate enforcement has one source of truth: configured PaymentStorage. createRateLimiter() remains a standalone process-local utility for custom integrations; the runtime does not maintain a duplicate rate counter.

Node applications can load policy JSON through the Node entrypoint:

import { policiesFromConfig } from '@lucid-agents/payments/node';

SIWX

SIWX protects a free auth-only route or lets a wallet reuse a paid entitlement:

const agent = await createAgent({
  name: 'members',
  version: '1.0.0',
})
  .use(
    payments({
      config: {
        ...config,
        siwx: {
          enabled: true,
          defaultStatement: 'Sign in to Members',
          expirationSeconds: 300,
          storage: { type: 'in-memory' },
        },
      },
    })
  )
  .use(http())
  .addEntrypoint({
    key: 'profile',
    siwx: { authOnly: true },
    async handler({ auth }) {
      return { output: { address: auth?.address } };
    },
  })
  .addEntrypoint({
    key: 'report',
    price: '0.05',
    siwx: { enabled: true },
    async handler({ auth }) {
      return { output: { address: auth?.address } };
    },
  })
  .build();

Nonces are consumed atomically. Replays, malformed signatures, expired payloads, and resource/domain mismatches are rejected. Never enable skipSignatureVerification outside tests.

Make paid outbound calls

When wallets() is installed, obtain a payment-aware Fetch function from the bound runtime:

const paidFetch = await agent.payments?.getFetchWithPayment(agent);

const response = await paidFetch?.(
  'https://seller.example/entrypoints/data/invoke',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ input: { symbol: 'ETH' } }),
  }
);

createRuntimePaymentContext() and createX402Fetch() support lower-level construction. Never place a server private key in an edge or client bundle.

Stripe destination mode

Stripe mode resolves a Base crypto deposit address for each challenge. Install the optional stripe peer and provide stripe instead of payTo:

payments({
  config: {
    stripe: { secretKey: process.env.STRIPE_SECRET_KEY! },
    facilitatorUrl: 'https://facilitator.example',
    network: 'eip155:8453',
  },
});

Stripe utilities live at @lucid-agents/payments/providers/stripe and load dynamically only in destination mode.

PaymentsRuntime

The extension exposes one complete runtime directly:

type PaymentsRuntime = {
  readonly config: PaymentsConfig;
  readonly isActive: boolean;
  requirements(entrypoint, kind): RuntimePaymentRequirement;
  activate(entrypoint): void;
  resolvePrice(entrypoint, kind): string | null;
  authorize(
    request,
    entrypoint,
    kind,
    verifiedPayment?
  ): Promise<IncomingPaymentAuthorization>;
  authorizeSIWx(
    request,
    entrypoint,
    kind
  ): Promise<IncomingPaymentAuthorization | undefined>;
  getFetchWithPayment(runtime, network?): Promise<FetchFunction | null>;
  readonly paymentTracker?: PaymentTracker;
  readonly policyGroups?: PaymentPolicyGroup[];
  close(): Promise<void>;
};

The runtime is passed through without adapter/core wrappers. Shared configuration and runtime contracts are defined in @lucid-agents/types/payments and @lucid-agents/types/siwx.

Exports and runtime portability

The portable root exports the extension, in-memory implementations, incoming authorization, validation, pricing, x402 clients, SIWX helpers, policies, and tracker utilities.

Node-only functionality is isolated:

SubpathPurpose
@lucid-agents/payments/nodeFile/config policy loading.
@lucid-agents/payments/storage/sqliteBun SQLite payment and SIWX factories.
@lucid-agents/payments/storage/postgresPostgres payment and SIWX factories.
@lucid-agents/payments/providers/stripeStripe destination provider.

Domain types should be imported from @lucid-agents/types/payments or @lucid-agents/types/siwx; package-owned implementation types such as PaymentStorage and factory types remain available from @lucid-agents/payments.

On this page