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
/consolewith a payment quote (paymentQuoteInput: destination, optional memo, network). OnAPPROVE/WARN, Fortexa stores an immutablepaymentQuoteon the audit entry. - Build unsigned tx:
POST /api/stellar/build-paymentwithauditEntryIdplus the same destination, amount, asset, memo, and network. The server verifies every field against the authorized quote before constructing XDR. 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.
The policy decision authorizes a fixed payment quote (destination, amount, asset, memo, network). POST /api/stellar/build-payment is the enforcement gate: it loads the audit entry by auditEntryId, confirms the decision is APPROVE/WARN, and rejects any request whose fields diverge from the stored quote.
| Condition | HTTP | Response |
|---|---|---|
Missing/invalid body (auditEntryId, schema) |
400 |
Invalid payment build request. + zod details |
| Unknown audit entry or non-executable decision | 403 |
No authorized payment decision found⦠/ Decision 'BLOCK' does not authorize⦠|
| Tampered destination, amount, asset, or memo | 403 |
{ error, field } naming the mismatched field |
| Valid approved request | 200 |
{ ok: true, xdr, networkPassphrase, β¦ } |
Client-side UI must pass the same paymentQuoteInput at decision time and reuse the returned auditEntry.id when building XDR. Mutating any authorized field after approval cannot produce a valid unsigned transaction.
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).
