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
| Requirement | Why |
|---|---|
| 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 network | Fee payer pays Solana fees (~0.000005 SOL per tx) |
| Node.js 20+ | @x402/svm requires modern Node |
| Supabase or equivalent Postgres | The credits/inference logs schema; see Architecture |
Install the packages
pnpm add @x402/core @x402/svm @solana/kit bs58Pin
@solana/kitto 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-1Note 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 | base58That address is your new facilitator fee payer. Set
NEXUS_DEPOSIT_ADDRESS to it.
3. Fund the derived address
- SOL via faucet.solana.com — ~0.5 SOL covers ~100k settlements at devnet rates.
- USDC via faucet.circle.com to pre-create the associated token account.
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.