Skip to Content
Architecture

Architecture

Overview

txfence is structured around a single function — runPipeline — that takes an action plus a policy and returns a discriminated ExecutionResult. Everything else in the SDK is either a typed input (Policy, Action, AdapterMap) or a pluggable provider (CapLockProvider, ApprovalProvider, ReceiptStore, AuditLog, TelemetryProvider, NotificationProvider, EventStore, ProvenanceChain) injected as optional arguments.

createAgent is a thin wrapper that closes over those providers, maintains in-flight/shutdown state, and exposes submit, dryRun, executeIntent, shutdown, health, and isShuttingDown methods.

txfence pipeline architecture — agent action flows through policy evaluation, temporal rules, simulation, staleness check, cap lock, approval, and execution stages inside runPipeline before a signed transaction is broadcast

Pipeline stages

runPipeline walks eight stages, short-circuiting on the first blocker:

1. Policy evaluation

The flat Policy object is evaluated against the action via evaluate(policy, action, simulationResult?). Six checks run in order: chains allowlist, allowedContracts, maxSpendPerTx, slippage declared (swaps only), simulation-required (when set), and gasBufferMultiplier. The first failing check produces a PolicyRejectionReason and the pipeline returns { status: 'policy_rejected', ... }.

If a policyNode (composite AND/OR tree) is configured, evaluateNode() is used instead — same six checks, run per leaf, with tree-aware aggregation.

2. Temporal rules

If the policy has temporalRules AND an EventStore is wired in, evaluateTemporalRules() runs against the recent event window. Six predicate kinds — simulation_failure_rate, contract_call_frequency, success_drought, spend_velocity, consecutive_failures, approval_flood — produce one of three consequences: require_approval, reject, or flag_for_review.

3. Simulation

The chain adapter’s simulate(action, chainId, rpcUrl, options?) is called. The result includes gas, coverageLevel (basic for eth_call, deep for Tenderly), wouldRevert, revertReason, and caveats. A timestamp (simulatedAt) is recorded for the next stage.

4. Staleness check

If policy.simulationStalenessMs is set, the pipeline computes Date.now() - simulatedAt and returns { status: 'simulation_stale', stalenessMs } if it exceeds the threshold. The caller is expected to re-simulate before retrying.

5. Cap lock acquire

If a CapLockProvider is configured, provider.acquire(capId, amount, token) is called. Returns { granted: true, lockId } or { granted: false, reason: 'absolute_cap_exceeded' | 'rolling_window_exceeded' }. The pipeline holds the lockId for the rest of the run.

6. Human approval

If the action’s value ≥ humanApprovalThreshold, ApprovalProvider.request() dispatches the approval (typically a webhook POST with HMAC signature), then provider.poll(token) is called every 50 ms until decision or humanApprovalTimeoutMs expires. Cancel-on-timeout is the hard default — timeout returns { status: 'approval_timeout' }, never falls through to execute.

7. Execution

The user-supplied executor callback is invoked with (action, chainId, rpcUrl, evaluation, simulation) and is responsible for signing and broadcasting. The chain-specific helpers executeEvmAction / executeSolanaAction / executeCosmosAction are the standard implementations. On success the executor returns a SuccessReceipt.

8. Cap lock commit / release

On success, provider.commit(capId, lockId, amount) records the spend. On any failure (policy reject, simulation failure, approval timeout, execution throw), provider.release(capId, lockId, amount) returns the reservation. This is what makes the cap lock two-phase.

Throughout, every stage emits spans through the optional TelemetryProvider, fires events via the NotificationProvider, writes to the AuditLog, and appends to the ProvenanceChain — all errors in those providers are swallowed so observability failures never crash the pipeline.

Pluggable providers

Every long-lived dependency is an interface, not a concrete implementation. The default createMemoryX() factories are good for single-process dev; production typically swaps them out for Redis, PostgreSQL, or your own infrastructure.

InterfaceMemory implProduction impl
CapLockProvidercreateMemoryCapLockProvider@txfence/redis
ReceiptStorecreateMemoryReceiptStore@txfence/storage-pg, @txfence/storage-sqlite
AuditLogcreateMemoryAuditLogcreateFileAuditLog (NDJSON), or your own
ApprovalProvidercreateMemoryApprovalProvidercreateWebhookApprovalProvider
ProvenanceChaincreateMemoryProvenanceChaincreateFileProvenanceChain
EventStorecreateMemoryEventStore(Redis impl planned)
TelemetryProvidernoopTelemetryWrap any OpenTelemetry tracer
NotificationProviderconsole / compositecreateWebhookNotificationProvider

Contract test suites (receiptStoreContract, capLockProviderContract, auditLogContract, approvalProviderContract) are exported from @txfence/core/contracts so any new implementation can verify substitutability.

Chain adapters

The ChainAdapter interface is minimal — { simulate(action, chainId, rpcUrl, options?) }. Execution lives outside the adapter, in the user-supplied executor callback, so signing keys never cross the pipeline boundary.

type ChainAdapter = { simulate: ( action: Action, chainId: ChainId, rpcUrl: string, options?: SimulateOptions, ) => Promise<SimulationResult>; };

createMultiChainAdapter(adapters) wraps an AdapterMap ({ ethereum: { simulate }, arbitrum: { simulate }, … }) into a single adapter that dispatches by action.chain — useful when a downstream API expects one adapter.

You can implement a custom adapter for any chain or simulator (e.g., a local Anvil fork for tests).

Agent state

createAgent adds a small state machine around the pipeline:

  • An inFlight counter, incremented on submit() entry and decremented in finally
  • A shuttingDown flag, flipped by shutdown() to refuse new submissions
  • A startedAt timestamp for health().uptime
  • A reference to capLockConfigs for shutdown() to inspect outstanding locks

shutdown(timeoutMs) sets shuttingDown = true, polls every 50 ms until inFlight === 0 or the deadline expires, then inspects cap locks (when capLockProvider.inspect is implemented) and returns { completed, abandoned, capLocksReleased }.

Deployment topologies

In-process (default). The agent runs in the same Node.js process as your trading/treasury logic. Zero network latency. Use createMemoryCapLockProvider and friends.

Multi-process, single host. Multiple Node.js processes coordinate via Redis. Swap the cap-lock provider for createRedisCapLockProvider (atomic Lua scripts), and the receipt store for @txfence/storage-pg or @txfence/storage-sqlite.

Multi-host. Same as multi-process, plus the audit log goes to a write-once backend (S3 with object lock, or createFileProvenanceChain on shared NFS) and the monitor (@txfence/monitor) runs as its own process watching block streams.

AI-assistant frontend. The MCP server (@txfence/mcp) exposes txfence_simulate, txfence_check_policy, txfence_submit, txfence_execute_intent, txfence_diff_policies, txfence_replay_audit_log, and seven more as MCP tools. AI assistants get a typed interface to the same pipeline.

Last updated on