Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ coverage/
skills-lock.json
CLAUDE.md
plan.md
tasks/
tasks/
fix.md
2 changes: 1 addition & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1 +1 @@
npm test -- --passWithNoTests
npm test -- --passWithNoTests --runInBand --forceExit
32 changes: 32 additions & 0 deletions deploy/monitoring/grafana/dashboards/latency.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@
"type": "timeseries",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 6 },
"targets": [{ "expr": "histogram_quantile(0.95, rate(analytics_request_duration_seconds_bucket[5m]))", "legendFormat": "{{endpoint}}" }]
},
{
"id": 6,
"title": "Fee Oracle Recommended Base Fee (stroops)",
"type": "timeseries",
"gridPos": { "x": 0, "y": 18, "w": 12, "h": 6 },
"targets": [{ "expr": "fee_oracle_recommended_base_fee", "legendFormat": "recommended" }, { "expr": "fee_oracle_aggressive_base_fee", "legendFormat": "aggressive" }]
},
{
"id": 7,
"title": "Ledger Capacity Usage",
"type": "timeseries",
"gridPos": { "x": 12, "y": 18, "w": 12, "h": 6 },
"targets": [{ "expr": "fee_oracle_ledger_capacity_usage", "legendFormat": "capacity" }, { "expr": "fee_oracle_congestion_level", "legendFormat": "congestion" }]
},
{
"id": 8,
"title": "Fee Oracle Staleness (s)",
"type": "timeseries",
"gridPos": { "x": 0, "y": 24, "w": 12, "h": 6 },
"targets": [{ "expr": "fee_oracle_staleness_seconds", "legendFormat": "staleness" }]
},
{
"id": 9,
"title": "Outbox Deferrals & Fee Events",
"type": "timeseries",
"gridPos": { "x": 12, "y": 24, "w": 12, "h": 6 },
"targets": [
{ "expr": "rate(outbox_low_deferred_total[5m])", "legendFormat": "low_deferred" },
{ "expr": "rate(outbox_aggressive_fee_used_total[5m])", "legendFormat": "aggressive" },
{ "expr": "rate(outbox_max_fee_hit_total[5m])", "legendFormat": "max_fee_hit {{priority}}" }
]
}
]
}
27 changes: 27 additions & 0 deletions deploy/monitoring/prometheus/alert-rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,33 @@ groups:
summary: "High failure rate detected"
description: "Failure rate is {{ $value }}/min (> 10)"

- alert: SponsorLowXlm
expr: sponsor_available_xlm < 10
for: 5m
labels:
severity: critical
annotations:
summary: "Sponsor account low on XLM"
description: "Sponsor {{ $labels.sponsorAccount }} has {{ $value }} XLM available (< 10) — top up immediately"

- alert: FeeOracleStale
expr: fee_oracle_staleness_seconds > 60
for: 2m
labels:
severity: warning
annotations:
summary: "Fee oracle stale"
description: "Fee oracle has not updated for {{ $value }}s (> 60s)"

- alert: SevereCongestion
expr: fee_oracle_congestion_level == 3
for: 5m
labels:
severity: warning
annotations:
summary: "Severe network congestion"
description: "Congestion level severe for 5 minutes — LOW ops deferring"

- name: neurowealth_warning
interval: 30s
rules:
Expand Down
109 changes: 109 additions & 0 deletions docs/AGENT_DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Explainable Rebalancing — Decision Rationale Ledger (#343)

Every automated money movement now leaves a structured, durable `RebalanceDecision` row that explains *why it happened and why then*, so a user, an auditor, or a support engineer can reconstruct the decision after the fact.

## Model — `RebalanceDecision`

One row per `protocol:strategy:followId` batch evaluation per tick, whether or not a rebalance fired:

```
RebalanceDecision {
id, correlationId, batchKey,
fromProtocol, toProtocol, // toProtocol null when the decision was "hold"
outcome REBALANCED | HELD | BLOCKED
blockedReason risk_ceiling | below_min_improvement | cost_exceeds_gain | no_candidates | null
strategyName, strategyIsFollowed, followedStrategyId
thresholds { minimumImprovement, maxGasPercent } // snapshot at decision time
currentApy, chosenApy, rawImprovement, estCostPercent, netImprovement // Decimal(12,6)
candidates [{ protocol, apy, riskScore, eligible, rejectionReason }]
rationale String? // server-templated, never free-form user text
affectedUserIds String[] // every user whose position was in the batch
affectedPositions Int
outboxOpId String? // links the decision to the durable outbox op when one was enqueued
heldSince, lastEvaluatedAt // window for consecutive identical HELD collapsing
createdAt
}
```

`candidates` is the full ranked protocol list. Each non-winner carries `rejectionReason`:

- `lower_apy` / `lower_target_weight` — eligible but lost on yield/weight,
- `over_risk_ceiling` — score present but below ceiling,
- `risk_score_unknown` — fail-closed when absent from the risk-score map.

Thresholds are snapshotted into the row so a later config change never rewrites what the agent actually used.

## Persistence — best-effort, never blocks a rebalance

`src/agent/rebalanceDecision.ts#persistRebalanceDecision` is the single writer. Failures are `logger.error` + `alertingService.emit` (`agent:decision-persist:<batchKey>:<outcome>`) and return `null`; the rebalance itself never rolls back. Decisions are backfillable from the correlation-scoped logs (same pattern as tax-lot creation in `src/stellar/events.ts`).

### Consecutive-HELD collapsing

A batch that holds every tick would otherwise write 24 rows/day/batch. Consecutive identical `HELD` decisions for the same `batchKey` — same `candidates` ranking and same `thresholds` (canonicalized via `audit/chain#canonicalizeAuditPayload`) — collapse into one row: the existing row's `lastEvaluatedAt` is bumped and `affectedUserIds` is union-merged; a change in inputs starts a new row (`heldSince` marks the window start).

## Audit-ledger feed (#315)

Each **new** decision row's canonical payload is hashed via `audit/chain#auditPayloadHashFor` and inserted into `audit_payload_hashes` (`tableName=rebalance_decisions`, `kind=REBALANCE_DECISION`). The row hash is the per-decision contribution to the hash-chained audit ledger; the ledger's chain verification (`audit/chain#verifyAuditChain`) therefore covers every decision.

## Real-time — `agent.decision_recorded`

On every new decision row, `agent.decision_recorded` is published to each affected user's stream (`publishUserEvent` with `EVENT_TYPE_TOPIC['agent.decision_recorded'] = 'agent'`):

```
{ decisionId, outcome, fromProtocol, toProtocol, blockedReason, createdAt }
```

Allowlisted in `utils/api-formatters#USER_EVENT_PAYLOAD_ALLOWLIST` so only those keys reach the client. The `agent.rebalanced` event is still emitted per-batch as before; `agent.decision_recorded` arrives right after it so a dashboard can deep-link to `/api/v1/agent/decisions/:id` without polling.

## API

### User — owner-scoped via `affectedUserIds`

```
GET /api/v1/agent/decisions
?outcome=REBALANCED|HELD|BLOCKED
&fromProtocol=Blend
&from=2026-08-10T00:00:00Z
&to=2026-08-11T00:00:00Z
&page=1&limit=10
```

Paginated `findMany` where `affectedUserIds has <callerId>`, ordered `createdAt desc`. Filterable by `outcome`, `fromProtocol`, and a `createdAt` date range. Response is projected per-user: `affectedUserIds` is stripped (no sibling userIds leak) and `affectedPositions` is batch-level (not per-user size); `candidates` and apy/threshold fields are batch-level.

```
GET /api/v1/agent/decisions/:id
```

The same `affectedUserIds has <callerId>` guard, but returns the full trace (ranked `candidates`, thresholds, the `rationale`, and — when the decision linked an `outboxOpId` — the current outbox `status` so the UI can show “decided to rebalance → on-chain submission failed”).

#### Per-user projection

- No field from another user's position is echoed: only protocol names, public APYs, public risk scores, and the batch decision.
- Followed-strategy privacy: `followedStrategyId` (the publisher's `PublishedStrategy.id`) is shown to the follower; the publisher's userId and other followers are never exposed.
- Outbox op later fails: `RebalanceDecision.outcome` stays `REBALANCED`; the API joins `outboxOpId` to `outbox_ops.status` so the UI can explain the discrepancy.

### Admin — unrestricted, audit-logged

```
GET /api/v1/admin/agent/decisions
?outcome=&fromProtocol=&from=&to=&correlationId=&batchKey=&page=&limit=
— requires `read` scope (admin-scoped), written to `AdminAuditLog` as `LIST_AGENT_DECISIONS`.
```

Returns every row (no `affectedUserIds` filter) with `affectedUserIds + affectedPositions` included for support/audit. No user projection, no stripping.

## Strategy plumbing

`src/agent/strategies.ts#rankCandidates` builds `RankedCandidate[]` from the ordered protocol list the strategy actually evaluated. Each strategy decision now carries `candidates` and — for non-rebalances — `blockedReason` so the ledger doesn't need to string-match `reasoning`:

- `risk_ceiling` — ceiling excluded every candidate,
- `below_min_improvement` — net improvement below the effective threshold,
- `cost_exceeds_gain` — payback gate rejected the move,
- `no_candidates` — no protocols available / no APY,
- (internal) `exposure_unplaceable` — caps summed below 100% (if seen, surfaced as `no_candidates` in the strategy path).

`src/agent/router.ts#compareProtocols` now returns `ProtocolComparison.trace` (candidates + `cost.breakdown` + thresholds + APYs) instead of only logging it. The strategy engine returns `candidates` alongside its `reasoning`. `src/agent/router.ts#executeRebalanceIfNeeded` persists a decision for every outcome (REBALANCED/HELD/BLOCKED), best-effort, and links `outboxOpId` when it enqueues.

## Backfill

Historical rebalances are not backfilled; only prospective decisions are recorded (#343 is forward-only).
31 changes: 31 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,8 @@ Response 201:
"assetSymbol": "USDC",
"protocolName": "Blend"
},
"estFee": 100,
"estConfirmationSeconds": 8,
"whatsappReply": "..."
}

Expand Down Expand Up @@ -697,6 +699,8 @@ Response 201:
"assetSymbol": "USDC",
"protocolName": "Blend"
},
"estFee": 500,
"estConfirmationSeconds": 4,
"whatsappReply": "..."
}

Expand Down Expand Up @@ -780,3 +784,30 @@ Response 404:
- deposit.ts: POST /api/deposit
- withdraw.ts: POST /api/withdraw
- vault.ts: GET /api/vault/state, GET /api/vault/balance
- network.ts: GET /api/v1/network/conditions

---

## Network

### GET /api/v1/network/conditions

- Auth: none (public, rate-limited)
- Description: Current fee oracle snapshot and per-priority ETA bands.
- Request params: none

Response 200:
{
"recommendedBaseFee": 100,
"aggressiveBaseFee": 500,
"congestionLevel": "low",
"ledgerCapacityUsage": 0.3,
"sampledAt": "2026-08-30T00:00:00.000Z",
"ttlMs": 30000,
"stale": false,
"etaBands": {
"LOW": { "minSeconds": 10, "maxSeconds": 30 },
"NORMAL": { "minSeconds": 5, "maxSeconds": 15 },
"CRITICAL": { "minSeconds": 2, "maxSeconds": 8 }
}
}
14 changes: 13 additions & 1 deletion docs/NON_CUSTODIAL_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,19 @@ If the non-custodial migration causes issues:
| Phase 3 (deprecation) | If `custodial_wallets` still exist, re-enable custodial endpoints. If table is dropped, restore from backup |
| Phase 4 (cleanup) | Re-add `WALLET_ENCRYPTION_KEY` and `custodial_wallets` model if needed (full schema revert) |

## 7. Migration Checklist
## 7. Reserve Sponsorship

Every custodial Stellar account needs a base reserve (1 XLM) plus 0.5 XLM per trustline. Without sponsorship a stablecoin-only user cannot hold assets.

* **Create:** `buildSponsoredCreateAccount({newAccountId, sponsorKeypair})` wraps `CreateAccount(0)` between `BeginSponsoringFutureReserves`/`EndSponsoringFutureReserves` so the sponsor (from `STELLAR_SPONSOR_KEYS` pool, hash-tracked via `keys/registry.ts`) pays the reserve. `createCustodialWallet` enqueues this via `OutboxOpKind.ACCOUNT_PROVISION` behind `SPONSORED_RESERVES_ENABLED` (default on).
* **Trustline:** `buildSponsoredTrustline({accountId, asset:` USDC `, sponsor})` sandwiches `ChangeTrust` similarly; only for classic `G...` USDC (Soroban `C...` needs no trustline). Sponsored before first deposit of a new asset.
* **Ledger:** `ReserveSponsorship` (one row per sponsored entry, `xlmReserved` 1 / 0.5, `status ACTIVE|REVOKED|RECLAIMED`, `ledgerKey` for revoke) tracks outstanding liability (`SUM WHERE ACTIVE` → gauge `reserve_sponsorship_outstanding_xlm`).
* **Multi-sponsor:** `STELLAR_SPONSOR_KEYS` comma-separated, `pickSponsor()` selects most available XLM above `SPONSOR_MIN_XLM_FLOOR` (default 10 XLM); if none, `503 sponsor_capacity_exhausted` + critical alert, never silently under-reserves.
* **Revoke:** `buildRevokeSponsorship` leaf-first (trustlines before account) for close; reconciliation job walks on-chain sponsor fields and joins pending outbox before alerting on drift.

See `docs/RUNBOOK.md` #8 and `docs/OUTBOX.md` Fee oracle section for operational details.

## 8. Migration Checklist

- [ ] Phase 1: `POST /api/vault/submit-transaction` endpoint implemented with XDR validation
- [ ] Phase 1: Idempotency check via `txHash` uniqueness
Expand Down
59 changes: 59 additions & 0 deletions docs/OUTBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ fully `AdminAuditLog`-audited (`src/routes/admin.ts`):
| `outbox_op_latency_seconds` | Histogram | `kind` — creation to confirmation |
| `outbox_fee_bump_total` | Counter | `kind` |
| `outbox_stuck_submitted` | Gauge | — ops `SUBMITTED` past the timeout; the "lost in flight" alarm |
| `fee_oracle_recommended_base_fee` | Gauge | — current p70 |
| `fee_oracle_aggressive_base_fee` | Gauge | — current p95 |
| `fee_oracle_ledger_capacity_usage` | Gauge | — 0..1 |
| `fee_oracle_congestion_level` | Gauge | — 0=low,1=elevated,2=high,3=severe |
| `fee_oracle_staleness_seconds` | Gauge | — seconds since last good sample |
| `fee_oracle_clamp_total` | Counter | `bound` (`min`\|`max`) |
| `outbox_low_deferred_total` | Counter | — LOW deferred due to high congestion |
| `outbox_aggressive_fee_used_total` | Counter | — CRITICAL used aggressive fee |
| `outbox_max_fee_hit_total` | Counter | `priority` |

## Configuration (`src/config/env.ts` → `config.outbox`)

Expand All @@ -199,9 +208,59 @@ fully `AdminAuditLog`-audited (`src/routes/admin.ts`):
| `OUTBOX_SUBMITTED_TIMEOUT_MS` | `90000` | How long `SUBMITTED` may sit unconfirmed before fee-bump escalation |
| `OUTBOX_FEE_BUMP_MULTIPLIER` | `2` | Fee multiplier per bump (compounds) |
| `OUTBOX_FEE_BUMP_MAX_ATTEMPTS` | `3` | Fee-bump cap before a stuck op is escalated to `FAILED` |
| `OUTBOX_MAX_ABS_FEE` | `100000` | Absolute fee cap in stroops |
| `OUTBOX_LOW_DEFER_MS` | `15000` | LOW defer interval during high congestion |
| `OUTBOX_LOW_MAX_DEFER_MS` | `300000` | Max total defer for a LOW op |
| `OUTBOX_GLOBAL_MAX_IN_FLIGHT` | `10` | Global in-flight cap |
| `OUTBOX_PER_ACCOUNT_MAX_IN_FLIGHT` | `1` | Per-signer in-flight cap (serial per account) |
| `OUTBOX_BATCH_SIZE` | `20` | Ops claimed per sweep |
| `FEE_ORACLE_POLL_MS` | `10000` | Fee oracle poll cadence |
| `FEE_ORACLE_TTL_MS` | `30000` | Snapshot TTL |
| `FEE_ORACLE_MIN` | `100` | Min base fee (stroops) |
| `FEE_ORACLE_MAX` | `50000` | Max base fee (stroops) |
| `FEE_ORACLE_DEFAULT_BASE_FEE` | `100` | Fallback base fee |

## Fee oracle & congestion policy (#342)

The outbox no longer sets fees blindly and bumps only after failure.

### Fee oracle (`src/stellar/feeOracle.ts`)

A small service that polls `server.getFeeStats()` and `server.getLatestLedger()` every `FEE_ORACLE_POLL_MS` (default 10s) via the resilient RPC client, keeps a short in-memory history (and a Redis-mirrored snapshot at `fee-oracle:snapshot`), and publishes:

```ts
FeeSnapshot {
recommendedBaseFee: number // p70 inclusion fee, floored 100 stroops
aggressiveBaseFee: number // p95, for CRITICAL ops
congestionLevel: 'low'|'elevated'|'high'|'severe'
ledgerCapacityUsage: number // 0..1
sampledAt: string
ttlMs: number
}
```

`congestionLevel` is derived from `ledgerCapacityUsage` and the `min→p95` spread, with hysteresis and a 30s dwell so a single noisy sample does not flap. Recommended/aggressive fees are clamped to `[FEE_ORACLE_MIN, FEE_ORACLE_MAX]` (`100`–`50000` stroops) and floored at `100`. On poll failure the snapshot goes stale; consumers fall back to `FEE_ORACLE_DEFAULT_BASE_FEE` (100) and staleness is a metric + `fee-oracle:stale` alert. `ttlMs` (default 30s) is evaluated on the consumer clock with 1s grace. Redis absence is logged, not fatal — in-memory still serves.

### Dispatcher integration

`submitClaimedOp` reads the current snapshot before the first attempt:

* `NORMAL` (deposits): `recommendedBaseFee`
* `LOW` (rebalances): `recommendedBaseFee`, but if `congestionLevel >= high` the op is **deferred** — left `PENDING` with `nextAttemptAt = now + OUTBOX_LOW_DEFER_MS` (default 15s), bounded by `OUTBOX_LOW_MAX_DEFER_MS` (default 5m) after which it dispatches regardless (a rebalance is never cancelled, only delayed). Counter `outbox_low_deferred_total`.
* `CRITICAL` (withdrawals): `aggressiveBaseFee` when `congestionLevel >= elevated`, otherwise `recommendedBaseFee`. Never waits. Counter `outbox_aggressive_fee_used_total`.

Per-attempt `computeFeeMultiplier` still compounds on top (`feeBumpMultiplier ** min(attempts-1, feeBumpMaxAttempts)`), but the product `baseFee * multiplier` is now capped by an **absolute** `OUTBOX_MAX_ABS_FEE` (default 100000 stroops) so a high oracle base cannot compound without bound. Hitting the cap on a `CRITICAL` op still submits at the cap and emits a critical `outbox:max-fee-hit` alert (`outbox_max_fee_hit_total{priority}`).

`reconcileStuckSubmitted` uses the *current* oracle base for its fee-bump resubmission, not a multiple of the original.

### API surface

* `GET /api/v1/network/conditions` (public, rate-limited) → current `FeeSnapshot` + per-priority `etaBands` derived from `outbox_op_latency_seconds` percentiles.
* `POST /api/v1/deposit` and `POST /api/v1/withdraw` responses now include `estFee` (stroops) and `estConfirmationSeconds` from the oracle.

### Telemetry

Gauges `fee_oracle_recommended_base_fee`, `fee_oracle_aggressive_base_fee`, `fee_oracle_ledger_capacity_usage`, `fee_oracle_congestion_level` (0..3), `fee_oracle_staleness_seconds`; counters `fee_oracle_clamp_total{bound}`, `outbox_low_deferred_total`, `outbox_aggressive_fee_used_total`, `outbox_max_fee_hit_total{priority}`. Grafana `latency` dashboard panel + alerts `oracle stale > N s`, `severe > M min`.

## Out of scope

Expand Down
19 changes: 19 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,22 @@ psql "$DATABASE_URL" -c "
ORDER BY ledger DESC LIMIT 10;
"
```

## 8. Sponsor Account Top-Up

Sponsored reserves move the XLM cost from user to sponsor accounts (`STELLAR_SPONSOR_KEYS`). Monitor `GET /api/v1/admin/reserves` (admin-scoped, audit-logged) for `outstandingXlm` and `perSponsor[].availableXlm`. Alert `SponsorLowXlm` fires when any sponsor `< 10 XLM` for 5m.

**Top-up:**
```bash
# Check
curl -H "Authorization: Bearer $ADMIN_API_TOKEN" http://localhost:3001/api/v1/admin/reserves | jq

# Fund sponsor from treasury/ops hot wallet via Stellar Laboratory or
stellar account fund --destination <sponsorPublicKey> --amount 100 --network public

# Verify
curl -s http://localhost:3001/metrics | grep sponsor_available_xlm
psql "$DATABASE_URL" -c "SELECT \"sponsorAccount\", count(*), sum(\"xlmReserved\") FROM reserve_sponsorships WHERE status='ACTIVE' GROUP BY \"sponsorAccount\";"
```

No auto top-up — operational runbook only. Reconciliation job (`reserveReconciliation` hourly) flags drift where on-chain sponsor ≠ ledger.
Loading
Loading