@routexcc/core
v1.0.4
Published
Multi-chain settlement cost router engine for the x402 payment protocol
Maintainers
Readme
Routex
Multi-chain settlement cost router for the x402 payment protocol. Selects the cheapest or fastest blockchain for each AI agent micropayment — non-custodial, stateless, and BigInt-safe.
Built for developers integrating x402 payments into AI agents, wallets, and autonomous services.
Why Routex?
When an AI agent receives a 402 Payment Required response, it needs to pay — but which chain? Gas fees fluctuate, finality times vary, and balances are spread across networks. Routex evaluates all options in real time and picks the optimal chain automatically.
Real cost impact: A $0.10 USDC payment costs ~$0.001 on Base but ~$0.01 on Polygon — 10x difference. At 10,000 payments/day, that's $90/day saved by routing to the cheapest chain. Fees shift constantly; Routex checks every 30 seconds and picks the winner for each payment.
- Pay less by routing to the cheapest chain at the moment of payment
- Pay faster by routing to the chain with the lowest finality time
- Never touch private keys — Routex is fully non-custodial
- Drop in as x402 middleware with a single line of code
Install
# Core router (required)
npm install @routexcc/core
# Chain adapters (install the ones you need)
npm install @routexcc/chain-base
npm install @routexcc/chain-stellar
npm install @routexcc/chain-solana
npm install @routexcc/chain-polygon
# x402 middleware (optional)
npm install @routexcc/x402Quickstart
import { createRouter, LocalFeeOracle } from '@routexcc/core';
import { createBaseAdapter } from '@routexcc/chain-base';
import { createSolanaAdapter } from '@routexcc/chain-solana';
// 1. Configure chain adapters
const adapters = new Map([
['base', createBaseAdapter(viemClient)],
['solana', createSolanaAdapter(solanaConnection)],
]);
// 2. Create fee oracle
const feeOracle = new LocalFeeOracle({
adapters,
pollIntervalMs: 30_000,
maxFeeAgeMs: 60_000,
});
// 3. Create router
const router = createRouter({
adapters,
feeOracle,
strategy: 'cheapest',
maxFeeAgeMs: 60_000,
});
// 4. Route a payment
const result = await router.route(paymentRequirement, signer);
console.log(result.chainId); // 'base' or 'solana' — whichever is cheaper
console.log(result.payload); // Signed payload ready for the facilitatorx402 Middleware
Intercept 402 Payment Required responses and route payments automatically:
import { routexMiddleware } from '@routexcc/x402';
const middleware = routexMiddleware({
routeConfig: { adapters, feeOracle, strategy: 'cheapest', maxFeeAgeMs: 60_000 },
signer: mySigner,
onRouteSelected: (result) => console.log(`Paying via ${result.chainId}`),
onRouteFailed: (error) => console.error('Routing failed:', error),
});
// When you get a 402 response:
const parsed = middleware.parseResponse(402, responseBody);
if (parsed) {
const { payload, chainId } = await middleware.handlePaymentRequired(parsed);
// payload is ready to submit to the facilitator
}Routing Strategies
| Strategy | Optimizes For | Best When |
|---|---|---|
| 'cheapest' | Lowest fee in USD | Cost-sensitive workloads, high volume |
| 'fastest' | Lowest finality time | Time-critical payments, real-time agents |
| 'balanced' | 60% cost / 40% speed | General-purpose, default choice |
| custom | Your scoring function | Domain-specific requirements |
Custom Strategy
const router = createRouter({
adapters,
feeOracle,
strategy: {
type: 'custom',
scorer: (options) => {
// Score and sort candidates however you want
return [...options].sort((a, b) => {
// Example: prefer Base, then sort by fee
if (a.chainId === 'base' && b.chainId !== 'base') return -1;
if (b.chainId === 'base' && a.chainId !== 'base') return 1;
return Number(a.fee.feeUsd - b.fee.feeUsd);
});
},
},
maxFeeAgeMs: 60_000,
});Configuration
interface RouteConfig {
adapters: Map<ChainId, ChainAdapter>; // Chain adapters to use
feeOracle: FeeOracle; // Fee estimation source
strategy: Strategy; // 'cheapest' | 'fastest' | 'balanced' | CustomStrategy
maxFeeAgeMs: number; // Reject fees older than this (ms)
maxFeeUsd?: bigint; // Max fee in USD (6-decimal bigint)
maxFinalityMs?: number; // Max acceptable finality time (ms)
excludeChains?: ChainId[]; // Chains to skip
}Fee Oracle Options
const feeOracle = new LocalFeeOracle({
adapters, // Required — chain adapters to poll
pollIntervalMs: 30_000, // How often to refresh fees (default: 30s)
maxFeeAgeMs: 60_000, // When fees become stale (required)
fallbackAdapters: fallbacks, // Optional — for cross-validation
maxDivergencePercent: 50, // Flag low confidence if primary/fallback diverge
});Constraints
const router = createRouter({
adapters,
feeOracle,
strategy: 'cheapest',
maxFeeAgeMs: 60_000,
maxFeeUsd: 500000n, // Max $0.50 fee (6 decimals)
maxFinalityMs: 5000, // Max 5 seconds to finality
excludeChains: ['polygon'], // Skip Polygon
});Supported Chains
| Chain | Adapter Package | Finality | Typical Fee (USDC transfer) | Notes |
|---|---|---|---|---|
| Base (L2) | @routexcc/chain-base | ~2s | $0.0005–$0.005 | EVM, EIP-712 signing |
| Polygon | @routexcc/chain-polygon | ~2s | $0.005–$0.05 | EVM, shared base with Base |
| Stellar | @routexcc/chain-stellar | ~5s | $0.00001–$0.0001 | Stellar consensus |
| Solana | @routexcc/chain-solana | ~400ms | $0.0005–$0.005 | SPL token transfers |
All adapters implement the ChainAdapter interface:
interface ChainAdapter {
chainId: ChainId;
getBalance(address: string, token: string): Promise<TokenBalance>;
estimateFee(payment: AcceptedPayment): Promise<FeeEstimate>;
buildPaymentPayload(payment: AcceptedPayment, signer: Signer): Promise<PaymentPayload>;
getFinality(): number;
}How Routing Works
Routex uses a five-step pipeline for every route() call:
PaymentRequirement
|
1. Parse Extract candidates from accepted chains
|
2. Filter Remove ineligible candidates:
| - Chain excluded or no adapter
| - Fee stale, too high, or unavailable
| - Insufficient balance
| - Finality too slow
|
3. Score Apply routing strategy to remaining candidates
|
4. Select Pick highest-scoring candidate, build payload
|
5. Verify Enforce security invariants (recipient, amount, chain ID)
|
RouteResult { chainId, payload, fee, candidates }If no candidates survive filtering, Routex throws a RouteExhaustedError with per-candidate rejection reasons — your code can then fall back to a direct payment.
Error Handling
Routex uses typed errors with full context — never silent failures.
import {
RouteExhaustedError,
StaleFeesError,
InsufficientBalanceError,
PaymentConstructionError,
} from '@routexcc/core';
try {
const result = await router.route(requirement, signer);
} catch (error) {
if (error instanceof RouteExhaustedError) {
// No chain eligible — check error.rejections for details
console.log(error.rejections);
// Fall back to direct payment on a specific chain
}
if (error instanceof InsufficientBalanceError) {
console.log(error.chainId, error.required, error.available);
}
if (error instanceof StaleFeesError) {
console.log(error.chainId, error.ageMs, error.maxAgeMs);
}
}Security Model
Routex is designed around ten security invariants enforced by code and automated tests:
| # | Invariant |
|---|---|
| INV-1 | No Routex function receives, stores, or returns a private key |
| INV-2 | Payment recipient matches the 402 response payTo address |
| INV-3 | Payment amount matches the 402 required amount (BigInt) |
| INV-4 | Chain ID in payload matches the selected adapter's chain |
| INV-5 | No eligible route throws RouteExhaustedError (never silently drops) |
| INV-6 | Fee estimates older than maxFeeAgeMs are rejected |
| INV-7 | All token arithmetic uses bigint — no floating-point |
| INV-8 | Error messages never contain credentials or RPC keys |
| INV-9 | Each route() call is stateless and independent |
| INV-10 | Router failure always allows caller to fall back to direct payment |
Non-Custodial Design
Routex interacts with your wallet through a Signer interface:
interface Signer {
sign(data: Uint8Array): Promise<Uint8Array>;
signTypedData?(domain: object, types: object, value: object): Promise<string>;
}Routex calls .sign() or .signTypedData() to construct payment payloads. It never accesses .privateKey, .secretKey, or .mnemonic. If you grep the entire codebase for these strings, you'll find zero results in source files.
Packages
| Package | Description | npm |
|---|---|---|
| @routexcc/core | Router engine, strategies, fee oracle, types | |
|
@routexcc/chain-base | Base (L2) chain adapter | |
|
@routexcc/chain-stellar | Stellar chain adapter | |
|
@routexcc/chain-solana | Solana chain adapter | |
|
@routexcc/chain-polygon | Polygon chain adapter | |
|
@routexcc/x402 | x402 middleware wrapper | |
|
@routexcc/cloud | Cloud SDK (fee oracle, telemetry, batching) | coming soon |
Routex Cloud (coming soon)
Routex v1 works entirely local — zero cloud dependency. Routex Cloud adds an optional hosted layer:
- Cloud Fee Oracle — Real-time fee streaming via WebSocket. No per-agent RPC polling. Automatic fallback to local oracle if unreachable.
- Batch Settlement — Accumulate micropayment intents, settle in single on-chain transactions. Up to 99% cost reduction.
- Fleet Analytics — Dashboard with cost savings, chain distribution, and per-agent breakdowns.
All v1 code continues to work unchanged. Cloud features activate by setting a cloudApiKey in your config.
API Reference
createRouter(config: RouteConfig): Router
Creates a new router instance.
const router = createRouter({
adapters,
feeOracle,
strategy: 'cheapest',
maxFeeAgeMs: 60_000,
});Router.route(req: PaymentRequirement, signer: Signer): Promise<RouteResult>
Evaluates all eligible chains and returns the best route with a signed payment payload.
const result = await router.route(paymentRequirement, signer);
// result.chainId — selected chain
// result.payload — signed PaymentPayload ready for submission
// result.fee — FeeEstimate for the selected chain
// result.candidates — all evaluated RouteOptions with scoresLocalFeeOracle
Polls chain adapters for fee estimates. Caches results in memory with stale detection.
const oracle = new LocalFeeOracle({ adapters, pollIntervalMs: 30_000, maxFeeAgeMs: 60_000 });
oracle.start(); // Begin polling
const fee = await oracle.getFee('base');
const allFees = await oracle.getAllFees();
oracle.stop(); // Stop pollingBalanceManager
Queries token balances across all configured chains in parallel.
const balances = new BalanceManager({ adapters, cacheTtlMs: 15_000 });
const balanceMap = await balances.getBalances(walletAddress, tokenAddress);
// Map<ChainId, bigint>Key Types
type ChainId = 'base' | 'stellar' | 'solana' | 'polygon';
interface PaymentRequirement {
acceptedChains: AcceptedPayment[];
}
interface AcceptedPayment {
chainId: ChainId;
payTo: string;
amount: bigint;
token: string;
}
interface RouteResult {
chainId: ChainId;
payload: PaymentPayload;
fee: FeeEstimate;
candidates: readonly RouteOption[];
}
interface FeeEstimate {
feeAmount: bigint;
feeUsd: bigint; // 6-decimal fixed point
finalityMs: number;
confidence: 'high' | 'medium' | 'low';
}
interface RouteOption {
chainId: ChainId;
fee: FeeEstimate;
score: number;
eligible: boolean;
rejectionReason?: string;
}Requirements
- Node.js >= 20
- TypeScript >= 5.7 (strict mode)
- Dual CJS/ESM — works with both
require()andimport
Documentation
- Getting Started — Step-by-step with Base Sepolia testnet
- Configuration — Every
RouteConfigoption explained - Strategies — How each routing strategy scores candidates
- Chain Adapters — Per-chain setup and RPC requirements
- Security — Fund safety guarantees and threat model
- API Reference — All exported interfaces and functions
Contributing
git clone https://github.com/routexcc/routex.git
cd routex
pnpm install
pnpm build
pnpm testWe use changesets for versioning. Run pnpm changeset to create a changeset before submitting a PR.
