This document describes the coding conventions, patterns, and best practices used in the Flex codebase. Follow these guidelines when contributing to ensure consistency across the project.
- Repository Structure
- Build and Development Commands
- Quick Reference
- Philosophy
- Documentation
- Git Workflow
- TypeScript Conventions
- Rust/Anchor Conventions
This is a hybrid TypeScript (bun) and Rust/Anchor project:
- programs/ - Anchor programs (Rust)
- packages/ - Shared TypeScript libraries
- apps/ - TypeScript applications
- tests/ - Integration tests
- scripts/ - Utility scripts
- skills/ - Agent skills for AI assistants
- docs/ - Design documentation
Do not create standalone TypeScript files in the repository root.
# Full build pipeline (lint, build, test)
make
# Individual commands
make build # Build all (TypeScript + Anchor)
make lint # Run format/lint checks (both languages)
make test # Run all tests
make format # Auto-format all files
make clean # Remove build artifacts
# Language-specific commands
make build-ts # Build TypeScript only
make build-anchor # Build Anchor program only
make lint-ts # Lint TypeScript only
make lint-anchor # Lint Rust onlySee DEV.md for complete development setup instructions.
- Always run
makebefore considering changes complete or committing - Use
import typefor type-only imports (TypeScript) - Use
#[account]for all program-owned data (Anchor) - Create factory functions with
create*prefix - Return
nullfrom handlers when request doesn't match - Use
{ cause }when re-throwing errors - Use the package logger, never
console(TypeScript) orprintln!(Rust) - Co-locate tests with source files
- Run
make formatbefore committing - Let compilers infer types when obvious
- Prefix unused parameters with
_
- Mix refactors/whitespace changes with functional changes
- Use
console.log(use logger) orprintln!(usemsg!) - Use default exports (TypeScript)
- Create classes unless necessary (prefer factory functions)
- Ignore validation errors (always check with
isValidationError) - Use
anytype (useunknownand narrow) - Use type assertions (
as Type) - they indicate interface problems - Use
unwrap()in production Rust code - Skip runtime validation in favor of type assertions
- Commit without running
make lint - Over-type code with explicit annotations the compiler can infer
The codebase follows these core principles:
- Composability - Components work together flexibly
- Extensibility - Easy to add new payment schemes and wallets
- Standards Agnostic - Support multiple payment standards (x402, L402, etc.)
- Pragmatic - Interface-driven design with loose coupling
Key design decisions:
- Prefer interfaces over concrete implementations
- Use plugins for payment handlers and wallet adapters
- Minimize dependencies between packages
- Enable developers to import only what they need
Code should be self-documenting. Do not add comments that describe what the code obviously does:
// Bad - obvious comments
// Base configuration type for all backends
BaseConfigArgs = { level: LogLevel };
// Good - let code speak for itself
BaseConfigArgs = { level: LogLevel };Decorative comment blocks (ASCII art dividers, section headers) add visual noise without providing meaningful information.
Do not reference external tracking artifacts in code comments. Comments like // Issue 1: ... or // Fixes JIRA-1234 are meaningless to future readers who lack the context of the original tracking document. The code and its test names should be self-explanatory without cross-referencing external sources. An exception is URLs that point at long-lived resources (e.g., RFCs, specification documents, upstream bug reports).
When comments ARE useful:
- Complex algorithms that aren't immediately obvious
- Non-obvious workarounds or edge cases
- TODO/FIXME/XXX markers for future work
- Business logic that requires explanation
// XXX - Temporary workaround until upstream fix
// TODO - Switch to newMethod when minimum version is bumped
result = await legacyMethod();When making changes to code, check whether related documentation needs updating:
- README files that reference changed functionality
- API documentation for modified interfaces
- Inline comments that describe changed behavior
- Configuration examples that no longer apply
Update documentation in the same commit as the code change, not as a separate task.
Configure git hooks before making commits: git config core.hooksPath .githooks
- Summary line: Max 72 characters, non-empty
- Blank line: Required between summary and body (if body exists)
- Body lines: Max 72 characters each
Summary lines MUST be english sentences with no abbreviations, no markup (e.g. feat, chore), and not end with any punctuation. Commits messages should not be overly verbose. DO NOT include feature/change lists in the commit body; the code already shows this.
Format:
- Write concise messages (1-2 sentences) that explain why, not what
- Do not use bullet points or feature lists in commit messages
- Focus on the purpose and context of the change
- Do not include filenames in commit messages
Good examples:
Add retry logic for failed network requests
Fix race condition in transaction verification
Document API response format
Bad examples:
feat: add retry logic
Update code (too vague)
Fix bug in server.ts (includes filename)
- Separate refactoring from feature additions (distinct commits)
- Separate formatting/whitespace fixes from logical changes
- Each commit should represent one logical unit of work
Always run the full build command (make) before declaring any task complete.
- Individual package builds do not guarantee the full tree will build
- Do not work around a failing build by running individual targets and treating their success as equivalent
- If the build fails, report the failure and identify the cause
- If the failure is pre-existing and unrelated to your changes, say so explicitly
Never silently skip a failing step or substitute a partial build.
The project uses strict TypeScript settings defined in tsconfig.base.json. Key implications:
- Strict mode enabled: All strict type-checking options are active
noUncheckedIndexedAccess: Array/object index access may returnundefined. Always check before using.exactOptionalPropertyTypes: Optional properties cannot be explicitly set toundefined.verbatimModuleSyntax: Useimport typefor type-only imports.- ESNext target: Modern JavaScript features are available; no need for polyfills.
Formatting is enforced via Prettier. See .prettierrc.json for the configuration.
Key formatting rules:
- Indentation: 2 spaces (no tabs)
- Quotes: Double quotes
"for strings - Semicolons: Required
- Trailing commas: Always (including function parameters)
Run make format to auto-format all files.
| Type | Convention | Example |
|---|---|---|
| Regular modules | Lowercase, hyphens for multi-word | token-payment.ts, server-express.ts |
| Single-word modules | Lowercase | solana.ts, common.ts, index.ts |
| Test files | {name}.test.ts |
cache.test.ts, facilitator.test.ts |
| Pattern | Use Case | Example |
|---|---|---|
camelCase |
All functions | handleMiddlewareRequest |
create* |
Factory functions | createHoldManager, createLocalWallet |
is* |
Boolean predicates | isValidationError, isKnownCluster |
get* |
Retrieval without side effects | getTokenBalance, getSupported |
handle* |
Event/request handlers | handleSettle, handleVerify |
| Pattern | Use Case | Example |
|---|---|---|
camelCase |
Regular variables | paymentRequiredResponse, recentBlockhash |
SCREAMING_SNAKE_CASE |
Constants, environment vars | MAX_PENDING_SETTLEMENTS, PAYER_KEYPAIR_PATH |
_ prefix |
Unused parameters | _ctx, _unused |
When using acronyms in camelCase or PascalCase names, preserve the acronym's capitalization based on the position:
- If the acronym starts with an uppercase letter, keep it fully capitalized
- If the acronym starts with a lowercase letter, keep it fully lowercase
Good:
// Acronyms at start of name
function getURLFromRequestInfo(input: RequestInfo | URL): string { ... }
const URLParser = { ... };
// Acronyms in middle/end of name (starts uppercase)
const requestURL = "https://example.com";
const parseHTTPHeaders = () => { ... };Bad:
// Don't mix case within acronyms when the leading character would be uppercase
function getUrlFromRequestInfo(input: RequestInfo | URL): string { ... } // Should be getURLFromRequestInfo
const requestUrl = "..."; // Should be requestURLCommon acronyms to watch: URL, HTTP, HTTPS, JSON, API, RPC, HTML, XML
Note: "ID" is an abbreviation (not an acronym), so use standard camelCase rules: userId, requestId, getId().
| Pattern | Use Case | Example |
|---|---|---|
PascalCase |
Interfaces, type aliases | FacilitatorHandler, PaymentExecer |
lowercase |
Protocol-specific types | x402PaymentRequirements, eip712Domain |
*Args / *Opts |
Function arguments | CreatePaymentHandlerOpts |
*Response |
API responses | x402SettleResponse |
*Info |
Data structures | ChainInfo, SPLTokenInfo |
*Handler |
Handler interfaces | FacilitatorHandler, PaymentHandler |
Use arktype for runtime type validation. Define the validator and TypeScript type together:
import { type } from "arktype";
// Define runtime validator
export const x402PaymentRequirements = type({
scheme: "string",
network: "string",
maxAmountRequired: "string.numeric",
resource: "string.url",
});
// Derive TypeScript type from validator
export type x402PaymentRequirements = typeof x402PaymentRequirements.infer;Create type guards using validation functions:
import { isValidationError } from "@faremeter/types";
export function isAddress(maybe: unknown): maybe is Address {
return !isValidationError(Address(maybe));
}type: Use for data structures, unions, and arktype-derived typesinterface: Use for behavioral contracts (objects with methods)
// Type for data structure
export type RequestContext = {
request: RequestInfo | URL;
};
// Interface for behavioral contract
export interface FacilitatorHandler {
getSupported?: () => Promise<x402SupportedKind>[];
getRequirements: (
req: x402PaymentRequirements[],
) => Promise<x402PaymentRequirements[]>;
handleSettle: (requirements, payment) => Promise<x402SettleResponse | null>;
}Use as const for exhaustive literal types:
const PaymentMode = {
ToSpec: "toSpec",
SettlementAccount: "settlementAccount",
} as const;
type PaymentMode = (typeof PaymentMode)[keyof typeof PaymentMode];Use import type for type-only imports (required by verbatimModuleSyntax):
import type { x402PaymentRequirements } from "@faremeter/types/x402";
import type { Hex, Account } from "viem";
// Mixed imports
import {
type Rpc,
type Transaction,
createTransactionMessage, // value import
} from "@solana/kit";Let TypeScript infer types when they are obvious:
// Good - return type is obvious from the implementation
const createHandler = async (network: string) => {
const config = { network, enabled: true };
return {
getConfig: () => config,
isEnabled: () => config.enabled,
};
};
// Unnecessary - the return type is obvious
const createHandler = async (
network: string,
): Promise<{
getConfig: () => { network: string; enabled: boolean };
isEnabled: () => boolean;
}> => { ... };When to add explicit types:
- Public API boundaries where the type serves as documentation
- When the inferred type would be too wide
- When TypeScript cannot infer the type correctly
The any type defeats TypeScript's type safety and should not be used unless absolutely required. Similarly, type assertions (as Type) are usually a sign of a problem with the interfaces being used.
Bad - Using any:
function processData(data: any) {
return data.value; // No type safety
}Good - Using unknown with validation:
function processData(data: unknown) {
const validated = MyDataType(data);
if (isValidationError(validated)) {
throw new Error(`Invalid data: ${validated.summary}`);
}
return validated.value; // Type-safe
}Use index.ts files to re-export from modules:
// packages/types/src/index.ts
// Namespaced exports for grouped functionality
export * as x402 from "./x402";
export * as client from "./client";
export * as solana from "./solana";
// Flat exports for utilities
export * from "./validation";Prefer named exports over default exports:
// Good
export function createMiddleware(args: CreateMiddlewareArgs) { ... }
export const X402_EXACT_SCHEME = "exact";
// Avoid
export default function createMiddleware(args: CreateMiddlewareArgs) { ... }Order imports by category:
- External library imports
- Internal package imports (
@faremeter/*) - Relative imports
// External libraries
import { type } from "arktype";
import { Hono } from "hono";
// Internal packages
import { isValidationError } from "@faremeter/types";
import type { FacilitatorHandler } from "@faremeter/types/facilitator";
// Relative imports
import { isValidTransaction } from "./verify";
import { logger } from "./logger";Check arktype validation errors before proceeding:
const paymentPayload = x402PaymentHeaderToPayload(paymentHeader);
if (isValidationError(paymentPayload)) {
logger.debug(`couldn't validate client payload: ${paymentPayload.summary}`);
return sendPaymentRequired();
}
// paymentPayload is now typed correctlyUse { cause } when re-throwing errors to preserve the error chain:
try {
transaction = paymentPayload.transaction;
} catch (cause) {
throw new Error("Failed to get compiled transaction message", { cause });
}Handlers should return null when a request doesn't match their criteria:
const handleVerify = async (requirements, payment) => {
if (!isMatchingRequirement(requirements)) {
return null; // Let another handler try
}
// Handle the request...
};Use async factory functions that return objects with async methods:
export function createHoldManager() {
const holds = new Map<string, Hold>();
function tryHold(params: TryHoldParams, ...): HoldResult {
// Validation and hold tracking
}
function releaseHold(escrow: Address, authorizationId: bigint): void {
// Release logic
}
// Return object with methods
return {
tryHold,
releaseHold,
// ...
};
}Use Promise.all for independent parallel operations:
const [tokenName, tokenVersion] = await Promise.all([
publicClient.readContract({ ...functionName: "name" }),
publicClient.readContract({ ...functionName: "version" }),
]);Each package follows this structure:
packages/<name>/
├── package.json # Package metadata and exports
├── tsconfig.json # Extends tsconfig.base.json
├── README.md # API documentation
└── src/
├── index.ts # Public exports (barrel file)
├── internal.ts # Internal utilities (optional)
├── common.ts # Shared logic
├── logger.ts # Package-specific logger
├── *.test.ts # Tests co-located with source
└── <feature>/ # Feature-specific subdirectories
Tests use the bun test framework. Key patterns:
- Start test files with appropriate imports
- Use
describeandtestblocks - Co-locate tests with source files (
*.test.ts)
Use the package logger, never console:
import { logger } from "./logger";
logger.info("Server started");
logger.debug("Processing request");
logger.error("Failed to connect");Key rules and their implications:
- No console:
console.logand similar are errors. Use the package logger instead. - Unused variables: Must be prefixed with
_(e.g.,_ctx,_unused).
The project uses Rust edition 2021 with strict clippy settings.
Formatting is enforced via rustfmt. See rustfmt.toml for the configuration.
Key formatting rules:
- Indentation: 4 spaces (no tabs)
- Max line width: 100 characters
Run cargo fmt or make format-anchor to auto-format Rust files.
| Type | Convention | Example |
|---|---|---|
| Modules | snake_case | escrow_account.rs, session.rs |
| Test files | snake_case | escrow_test.rs |
| Pattern | Use Case | Example |
|---|---|---|
snake_case |
Functions, variables | create_escrow, pending_count |
SCREAMING_SNAKE |
Constants | MAX_TIMEOUT_SLOTS, SEED_PREFIX |
_ prefix |
Unused parameters | _ctx, _bump |
| Pattern | Use Case | Example |
|---|---|---|
CamelCase |
Structs, enums, traits | EscrowAccount, SessionKey |
CamelCase |
Type aliases | Result<T>, ProgramResult |
Follow the same rules as TypeScript - preserve acronym capitalization:
// Good
struct PDASigner { ... }
fn get_rpc_url() -> String { ... }
const MAX_URL_LENGTH: usize = 256;
// Bad
struct PdaSigner { ... } // Should be PDASigner
fn get_rpc_Url() -> String { ... } // Should be get_rpc_urlUse Anchor's constraint system for validation:
#[derive(Accounts)]
pub struct CreateEscrow<'info> {
#[account(mut)]
pub owner: Signer<'info>,
#[account(
init,
payer = owner,
space = 8 + EscrowAccount::INIT_SPACE,
seeds = [b"escrow", owner.key().as_ref(), &index.to_le_bytes()],
bump,
)]
pub escrow: Account<'info, EscrowAccount>,
pub system_program: Program<'info, System>,
}Use #[account] for all program-owned data and #[derive(InitSpace)] for space calculation:
#[account]
#[derive(InitSpace)]
pub struct EscrowAccount {
pub owner: Pubkey,
pub facilitator: Pubkey,
pub pending_count: u64,
pub bump: u8,
}Store PDA bumps in account data and use canonical bumps:
#[account(
seeds = [b"escrow", owner.key().as_ref(), &escrow.index.to_le_bytes()],
bump = escrow.bump,
)]
pub escrow: Account<'info, EscrowAccount>,Do not use unsafe blocks unless absolutely necessary. If required, document why thoroughly.
Define comprehensive error codes starting at 6000:
#[error_code]
pub enum FlexError {
#[msg("Session key has expired")]
SessionKeyExpired = 6000,
#[msg("Session key revoked and grace period elapsed")]
SessionKeyRevoked = 6001,
#[msg("Authorization has expired")]
AuthorizationExpired = 6002,
#[msg("Ed25519 signature verification failed")]
InvalidSignature = 6003,
}Use require! for validation checks:
require!(
clock.slot < expires_at_slot,
FlexError::AuthorizationExpired
);
require!(
session_key.active,
FlexError::SessionKeyRevoked
);Add context when propagating errors:
let mint_info = fetch_mint(rpc, mint_address)
.map_err(|e| error!(FlexError::InvalidMint).with_source(e))?;Organize the main library file clearly:
use anchor_lang::prelude::*;
mod error;
mod instructions;
mod state;
pub use error::*;
pub use instructions::*;
pub use state::*;
declare_id!("...");
#[program]
pub mod flex {
use super::*;
pub fn create_escrow(ctx: Context<CreateEscrow>, ...) -> Result<()> {
instructions::create_escrow(ctx, ...)
}
}Organize CPI code in impl blocks:
impl<'info> Deposit<'info> {
pub fn transfer_to_vault(&self, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: self.source.to_account_info(),
to: self.vault.to_account_info(),
authority: self.depositor.to_account_info(),
};
let cpi_program = self.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
token::transfer(cpi_ctx, amount)
}
}Use Anchor's test framework with TypeScript tests:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Flex } from "../target/types/flex";
describe("flex", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Flex as Program<Flex>;
it("creates an escrow account", async () => {
// Test implementation
});
});Use the msg! macro for on-chain logging, never println!:
msg!("Creating escrow for owner: {}", owner.key());
msg!("Authorization: {}, Amount: {}", authorization_id, amount);For detailed security patterns, always load the anchor-security skill when writing or reviewing Anchor code. Key requirements:
- Validate all account relationships with
has_oneor constraints - Use typed accounts (
Account<'info, T>) for ownership validation - Check for duplicate mutable accounts
- Use canonical PDA bumps
- Validate program IDs for CPIs
- Properly close accounts with
closeconstraint - Enforce authorization expiry constraints
- Validate time-based constraints with slots
- Add
/// CHECK:comments forUncheckedAccount
Do not reimplement functionality that already exists in the codebase. Before writing new code:
- Search for existing implementations that could serve the same purpose
- If similar functionality exists, prefer refactoring it to meet the new requirements
- Look for unexported functions in other packages that could be promoted to a shared location
When a refactor might be necessary, prompt with specific options:
- Refactor the existing implementation
- Promote an unexported function to a shared package
- Create a new implementation
Allow for custom answers if none of the options fit.
Any code from outside the organization requires careful attribution and licensing compliance:
- License verification: Check that the license is compatible with your project
- Isolated commit: Place external code in its own commit without any modifications
- Complete attribution: Include in the commit message:
- Original source URL or reference
- Author/copyright information
- License type
- Date retrieved
If modifications to external code are needed, make them in a separate follow-up commit.
Do not modify configuration files (e.g. eslint, prettier, rustfmt, clippy) unless explicitly asked. Focus on writing working software, not changing the conventions that are being used.
Keep consistent even if we disagree; if we decide to change a style, make it an explicit decision and discussion, not a side effect of other work.
Do not use emojis in code or documentation. Act professionally.