Policy-Controlled Payment Firewall for Autonomous Agent Actions on Stellar
Fortexa is a policy-controlled payment firewall for autonomous agent actions on Stellar. It sits between agent intent and economic execution, applies governance/risk checks, and keeps an auditable decision trail.
This document reflects the current implementation in this repository.
Agentic systems can now trigger real payments. That creates a new risk layer: high-speed model decisions can become high-impact economic actions.
Fortexa adds a control plane between intent and money movement:
- Policy checks before execution
- Risk scoring on suspicious behavior
- Human-approval gate for sensitive cases
- Wallet-native signed XDR flow
- Auditable evidence trail for every decision
In short: Fortexa is the safety layer for agentic payments.
If you only read one section, read this:
- Login with wallet on
/login. - Evaluate action in
/console. - Receive decision:
BLOCK/REQUIRE_APPROVAL/WARN/APPROVE. - For allowed flows, build unsigned XDR → sign in wallet → submit signed XDR.
- Verify outcome with Explorer link and inspect evidence in
/activityand/ops.
Fortexa currently runs with a strict wallet-bound model:
- User logs in with wallet (
/login). - Session is created with role (
operator/viewer). - Session wallet is bound as execution source.
- Actions are evaluated by policy + security engine.
- Approved/warned decisions can proceed to signed-XDR payment flow.
- Decision/audit evidence is stored and visible in
/activityand/ops.
Fortexa uses a challenge-signature login flow:
- Client requests a one-time login challenge via
POST /api/auth/challengewith the wallet public key (G...). - The server returns a short-lived challenge message bound to that wallet.
- The wallet signs the challenge message (SEP-53 / Freighter
signMessage). - Client posts
publicKey,challengeId, andsignaturetoPOST /api/auth/login. - The server verifies the signature, enforces one-time challenge use + expiry, then issues
fortexa_session.
Role is still resolved via allowlists:
FORTEXA_OPERATOR_WALLETSFORTEXA_VIEWER_WALLETS
If both allowlists are empty, current behavior falls back to operator role for any valid wallet (recommended only for local/dev).
Session cookie: fortexa_session (HMAC-signed).
Challenge TTL: FORTEXA_AUTH_CHALLENGE_TTL_SECONDS (default 300).
operator: full decision/policy/payment flowviewer: read-only experience on sensitive execution paths
- Rate limiting
- Brute-force lockout (
FORTEXA_AUTH_MAX_ATTEMPTS,FORTEXA_AUTH_LOCK_MINUTES)
Note: MFA is removed from current implementation.
Fortexa currently does not perform server-side signing or private-key custody.
- Session is wallet-bound at login.
- Execution source wallet is derived from session identity.
- Manual arbitrary wallet assignment in UI is removed.
/api/stellar/balanceauto-syncs missing wallet mapping from session when possible.
- Policy engine:
src/lib/policy/engine.ts - Security analyzer:
src/lib/security/analyzer.ts - Decision engine:
src/lib/decision/engine.ts
Decision outcomes:
BLOCKREQUIRE_APPROVALWARNAPPROVE
Human Approve & Re-run applies only when prior result is REQUIRE_APPROVAL.
Before committing a policy change, operators can dry-run the unsaved draft from the Policy editor (Run simulation). The draft is evaluated against the seeded demo scenarios — and, optionally, a small recent-audit sample — and the result shows each action's current → proposed decision so risky edits surface before they go live.
Simulation is strictly read-only: it never saves the policy and never consumes usage. Saving still happens only through POST /api/policy. See src/lib/decision/simulate.ts and POST /api/policy/simulate.
- Evaluate action in
/console. - Build unsigned tx:
POST /api/stellar/build-payment. Submit Signed XDRorchestrates signing/submission path:- if signed input is already present → submit directly
- if unsigned input is present → wallet signing is triggered first, then submit
- Submit signed tx:
POST /api/stellar/submit-signed. - Explorer URL is returned and shown as clickable link.
Idempotent retries: POST /api/stellar/submit-signed accepts an optional idempotency key, supplied either as an Idempotency-Key request header or an idempotencyKey body field (the header wins if both are present). Results are stored per authenticated user + key + signed-XDR hash. Replaying the same key with the same signed XDR returns the original result (200, with header Idempotency-Replayed: true) without resubmitting to Horizon. Reusing the same key with a different signed XDR returns 409 Conflict. Omitting the key preserves the original submit-on-every-request behavior. Keys must be 8–255 characters.
Additional behavior:
- XDR build timeout configured to 180 seconds.
- Submit errors include Horizon result codes when available.
- Decisions are appended to audit store at evaluation time.
/activityreads entries by authenticated session user id.- Export endpoint supports
mineandallscopes in JSON/CSV.
Every new audit entry is linked into a tamper-evident SHA-256 hash chain:
| Field | Description |
|---|---|
previousHash |
entryHash of the immediately preceding entry, or 0000…0000 (64 zeroes) for the first hashed entry. |
entryHash |
SHA-256 of the entry's canonical fields (id, timestamp, action, decision, explanation, triggeredPolicies, riskFindings, stellarTxHash, previousHash). Object keys are sorted before hashing so DB-stored and file-stored entries produce identical digests. |
Both fields are included in JSON exports. CSV exports add entryHash and previousHash columns.
Verification helper: verifyHashChain(entries) in src/lib/audit/hash-chain.ts — returns { valid: true } for an untouched log and { valid: false, reason } when it detects a modified, deleted, or reordered entry.
Entries written before this feature was introduced carry no hash fields and are treated as legacy entries; they do not break verification of newer hashed entries.
- Node.js 20+
- npm 10+
npm install
cp .env.example .env.local
npm run devOpen: http://localhost:3000
Reference (.env.example):
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
DATABASE_URL=
DATABASE_SSL=false
FORTEXA_STORE_DIR=
FORTEXA_SHARED_STATE_PATH=
REDIS_URL=
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
FORTEXA_AUTH_SECRET=
FORTEXA_OPERATOR_WALLETS=
FORTEXA_VIEWER_WALLETS=
FORTEXA_AUTH_MAX_ATTEMPTS=5
FORTEXA_AUTH_LOCK_MINUTES=10
NEXT_PUBLIC_STELLAR_DESTINATION=
# Optional external blocklist URL for dynamic threat-intel
# Accepts JSON array of domains or plain-text (one domain per line, # comments ignored)
# Cached in-memory for 5 minutes; feed failures fall back silently
FORTEXA_BLOCKLIST_URL=npm run dev
npm run build
npm run start
npm run lint
npm run test
npm run test:watch
npm run demo:scenarios
npm run db:migratePOST /api/auth/challengePOST /api/auth/loginPOST /api/auth/logoutGET /api/auth/sessionPOST /api/auth/refresh
GET /api/policyPOST /api/policy(operator)POST /api/policy/simulate(operator) — read-only pre-save simulationGET /api/policy/history(operator)POST /api/policy/rollback(operator)
POST /api/decision(operator)POST /api/agent/plan(operator, Groq-backed)
GET /api/auditGET /api/audit/export?format=json|csv&scope=mine|all&from=<ISO8601>&to=<ISO8601>&decision=APPROVE|WARN|REQUIRE_APPROVAL|BLOCK&domain=<string>&actionId=<string>- Filters:
from/to(ISO 8601 date),decision,domain,actionId— all optional - Scope:
mine(own entries) orall(operator only) - Examples:
GET /api/audit/export?format=csv&scope=mine&from=2025-06-01T00:00:00Z&to=2025-06-30T23:59:59ZGET /api/audit/export?format=json&scope=all&decision=BLOCK&domain=malicious.example.comGET /api/audit/export?format=json&scope=mine&actionId=evt_abc123
- Filters:
GET /api/healthGET /api/metrics(?format=prometheus)
GET /api/stellar/balancePOST /api/stellar/setup(session-wallet bootstrap/sync helper; not manual wallet linking)POST /api/stellar/build-paymentPOST /api/stellar/submit-signed(supportsIdempotency-Keyheader/body for safe UI retries)POST /api/stellar/pay(legacy disabled)POST /api/stellar/fund(removed behavior, returns410)
/→ Overview dashboard/login→ Wallet-only authentication (Connect Wallet)/wallet→ Session wallet status and balance/console→ Decisioning + payment execution console/policies→ Policy editor, history, rollback/scenarios→ Scenario gallery/activity→ Audit trail timeline/ops→ Operations/telemetry dashboard
- Health endpoint:
GET /api/health— returnsblocklistobject withconfigured,lastRefreshAt,domainCount,lastError - Metrics endpoint:
GET /api/metrics+ Prometheus format /opsdashboard shows:- service health
- total requests
- error rate
- signed tx count
- blocklist feed health (configured, domain count, last refresh, errors)
- top routes + rolling trend
Ops dashboard initial load is optimized so core telemetry renders first; slow TX-count fetch no longer blocks first paint.
See docs/observability.md for the Prometheus scrape config, sample PromQL (request rate, error rate, p95 latency), and an example alert rule.
Stores include:
audit-storepolicy-storeuser-wallet-storesubmit-idempotency-store
If DATABASE_URL is available and healthy, Postgres is used.
Otherwise Fortexa falls back to local JSON files:
- local/dev default:
.fortexa/*.json - Vercel default:
/tmp/fortexa/*.json
Optional overrides:
FORTEXA_STORE_DIRto set file-store directory explicitlyFORTEXA_SHARED_STATE_PATHfor shared lockout/rate-limit state file path- use an absolute path on Vercel (example:
/tmp/fortexa/shared-security-state.json)
- use an absolute path on Vercel (example:
REDIS_URLfor multi-instance deployments (e.g. Vercel)- uses a Redis-backed adapter with automatic, transparent fallback to the file store if Redis is unreachable or unconfigured.
- Migrations:
src/lib/storage/migrations.ts - Runner:
src/lib/storage/db.ts - Tracking table:
fortexa_schema_migrations - Manual run:
npm run db:migrate
- Framework: Next.js App Router (
next@16) - Language: TypeScript
- UI: Tailwind CSS + custom UI primitives
- Validation:
zod - Charts:
recharts - Stellar:
@stellar/stellar-sdk, optional@stellar/freighter-api - Database:
pg(optional Postgres, file fallback enabled) - Tests: Vitest
- Shared security state supports Redis distributed locking, but defaults to file-based for local development.
- Risk scoring remains heuristic-heavy (no external threat-intel integration).
- Stellar workflow is testnet-oriented.
- Server-side signing remains intentionally disabled.
- Full end-to-end automated coverage for the complete decision-to-payment lifecycle is still limited.
Fortexa is intentionally optimized for hackathon clarity and wallet-native control, not full production deployment.
Common Stellar Horizon failures during the signed payment flow:
tx_bad_seq: The transaction sequence number is incorrect. Wait for pending transactions to clear or refresh your wallet state.tx_insufficient_fee: The provided fee is below the current network minimum. Increase the base fee.op_no_destination: The destination account does not exist on the network. Verify the destination address.op_underfunded: Your source wallet lacks the XLM necessary to complete the payment and satisfy the network base reserve.
- Add stronger risk intelligence + anomaly detection.
- Expand end-to-end payment verification and automated lifecycle tests.
MIT (see package.json).
