VDM NexusDocsBeta

Self-host the facilitator

Run your own x402 facilitator backed by @x402/svm.

A facilitator is the component that turns a partially-signed x402 payment into a real on-chain Solana transaction. Self-hosting one means no third-party dependency, full control of the fee-payer key, and no per-transaction fee to an external service.

This page walks through running the same facilitator we use in production, either inside apps/nexus or as a standalone service.

What you need

RequirementWhy
A Solana keypair (64-byte base58 secret)Used both as the deposit recipient and the fee payer in our consolidated topology
SOL on the target networkFee payer pays Solana fees (~0.000005 SOL per tx)
Node.js 20+@x402/svm requires modern Node
Supabase or equivalent PostgresThe credits/inference logs schema; see Architecture

Install the packages

pnpm add @x402/core @x402/svm @solana/kit bs58

Pin @solana/kit to v5 to match @x402/svm's peer expectation: pnpm add @solana/kit@^5

Build the facilitator instance

import bs58 from "bs58";
import { createKeyPairSignerFromBytes } from "@solana/kit";
import {
  toFacilitatorSvmSigner,
  SOLANA_DEVNET_CAIP2,
  SOLANA_MAINNET_CAIP2,
} from "@x402/svm";
import { ExactSvmScheme } from "@x402/svm/exact/facilitator";
import { x402Facilitator } from "@x402/core/facilitator";

async function buildFacilitator() {
  const secretKey = bs58.decode(process.env.NEXUS_DEPOSIT_SECRET_KEY!);
  const keypair = await createKeyPairSignerFromBytes(secretKey);
  const signer = toFacilitatorSvmSigner(keypair);
  const scheme = new ExactSvmScheme(signer);
  return new x402Facilitator().register(
    [SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2],
    scheme
  );
}

Call it from your route

const facilitator = await buildFacilitator();

const verifyResult = await facilitator.verify(paymentPayload, requirements);
if (!verifyResult.isValid) {
  return error402(verifyResult.invalidReason);
}

const settleResult = await facilitator.settle(paymentPayload, requirements);
if (!settleResult.success) {
  return error402(settleResult.errorReason);
}

// settleResult.transaction → Solana tx signature, on-chain settled
// settleResult.payer       → the agent's wallet (extracted from the tx)

Cache the facilitator instance — building it parses a keypair and is not free. In our production code that lives in apps/nexus/lib/local-facilitator.ts.

Critical operational notes

1. Fund the fee-payer wallet with SOL

The facilitator pays Solana transaction fees out of the keypair's SOL balance. Without SOL, settle silently fails at broadcast. For devnet testing, faucet.solana.com gives you 0.5 SOL at a time, enough for ~100,000 settlements.

For mainnet, fund the wallet via a real exchange withdrawal and monitor the balance — a depleted fee payer takes the endpoint down.

2. Use the CAIP-2 network identifier, not the short form

@x402/svm expects the full Solana genesis-hash CAIP-2 form (solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 for devnet). The short solana:devnet looks valid but is silently rejected one level deeper. Import the constants from @x402/svm directly to stay correct.

3. Include feePayer in the challenge's extra

The @x402/svm client refuses to build a payment payload unless paymentRequirements.extra.feePayer is set. The natural default in a consolidated topology (recipient = fee payer) is extra.feePayer = payTo.

4. Pre-create the recipient USDC ATA

If the recipient wallet has never received USDC on the target network, its associated token account doesn't exist yet — the first transaction will fail at simulation. Solve once by airdropping any amount of USDC to the recipient via faucet.circle.com.

5. Gate the facilitator behind explicit env

In production, default to fail-closed. Our code's pattern:

if (!process.env.NEXUS_FACILITATOR_LOCAL && !process.env.X402_FACILITATOR_URL) {
  throw new FacilitatorNotConfiguredError();
}

The mock facilitator (accepts any payload) should require an explicit opt-in env var like NEXUS_ALLOW_MOCK_FACILITATOR=true so it can never accidentally activate in production. We learned this the hard way today — a misconfigured prod environment ran the mock for ~10 seconds and accepted a fake payment, charging us $0.000007 to OpenRouter before we caught it.

KMS-backed signing

NEXUS_DEPOSIT_SECRET_KEY lives in lambda env — fine for devnet, unacceptable for mainnet. The Nexus reference implementation switches the signing step to AWS KMS when NEXUS_KMS_KEY_ID is set: the Ed25519 private key stays inside KMS and the lambda only ever holds AWS creds scoped to kms:Sign against a single key ARN. The code lives in apps/nexus/lib/kms-signer.ts.

1. Create the KMS key

aws kms create-key \
  --key-spec ECC_NIST_EDWARDS25519 \
  --key-usage SIGN_VERIFY \
  --region us-east-1

Note the returned KeyId (and Arn — you'll use the ARN as NEXUS_KMS_KEY_ID).

2. Derive the Solana address from the KMS public key

KMS.GetPublicKey returns an SPKI DER. For Ed25519, the leading 12-byte ASN.1 prefix is fixed and the trailing 32 bytes are the raw public key. base58-encode those 32 bytes → Solana address.

aws kms get-public-key --key-id <key-id> --region us-east-1 \
  --query PublicKey --output text | base64 -d | tail -c 32 | base58

That address is your new facilitator fee payer. Set NEXUS_DEPOSIT_ADDRESS to it.

3. Fund the derived address

4. Create an IAM user with least privilege

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
      "Resource": "<your-key-arn>"
    }
  ]
}

Generate access keys for this user. Do not attach broader KMS permissions — the runtime principal should only be able to sign with this one key.

5. Wire the env vars

NEXUS_FACILITATOR_LOCAL=true
NEXUS_KMS_KEY_ID=arn:aws:kms:us-east-1:<account-id>:key/<key-id>
NEXUS_KMS_REGION=us-east-1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
NEXUS_DEPOSIT_ADDRESS=<derived-from-step-2>
# Leave NEXUS_DEPOSIT_SECRET_KEY UNSET in production.

6. Verify the address binding at startup

On first request the facilitator emits:

{"event":"facilitator.kms.ready","address":"<derived-pubkey>"}

If it sees NEXUS_DEPOSIT_ADDRESS set and it doesn't match the derived address, it logs facilitator.kms.address_mismatch and throws — the route never serves a 402, so settlements can't accidentally credit the wrong wallet.

Mainnet readiness

KMS-backed signing is necessary but not sufficient for mainnet. Before flipping SOLANA_NETWORK=mainnet-beta you also need:

  • Deposit detection stress-tested at expected mainnet volume
  • A monitored SOL balance on the fee-payer address with a top-up plan
  • ~1 week of mainnet validation against a small budget before announcing

The mainnet flip is tracked as a separate roadmap item.

On this page