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
67 changes: 65 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ Comprehensive reference for all backend endpoints defined in src/routes.
- Every response includes an `X-API-Version: 1` header.
- Breaking changes introduce a new major version (`/api/v2`); deprecated versions are supported for a minimum of 6 months before the announced `Sunset` date. The full policy lives in [`docs/api-versioning.md`](api-versioning.md).

## List Queries

Collection endpoints use bounded page-based pagination. `page` is 1-based and
defaults to `1`; `limit` defaults to `20` and is capped at `50`. The equivalent
SQL offset is `(page - 1) * limit`. List responses include `page`, `limit`,
`total`, `totalPages`, `hasNext`, and `hasPrevious`; an empty result has
`totalPages: 0` and both navigation flags set to `false`.

Where supported, `sortBy` is restricted to the fields documented for that
resource and `sortOrder` is `asc` or `desc` (default `desc`). The server applies
a stable ID tie-breaker. Filters are resource-specific and are applied before
both counting and fetching. Invalid page, limit, sort, or filter values return
`400` rather than being silently ignored.

This contract is used by transactions, portfolio positions, recurring deposit
plans, alert rules, webhooks, API keys, sessions, approvals, and agent decisions.

Supported list controls:

- Transactions: filter by `type`, `status`, `protocolName`, and inclusive `from`/`to` creation dates; sort by `createdAt`, `updatedAt`, or `amount`.
- Portfolio positions: filter by `status`, `protocolName`, and `assetSymbol`; sort by `openedAt`, `updatedAt`, `currentValue`, or `yieldEarned`. Portfolio summary totals remain account-wide.
- Recurring deposits: filter by `status`, `cadence`, and `assetSymbol`; sort by `createdAt`, `nextRunAt`, or `amount`.
- Alert rules: filter by `isActive`, `metric`, and `protocolName`; sort by `createdAt`, `updatedAt`, or `threshold`.
- Webhooks: filter by `isActive` and `event`; sort by `createdAt` or `updatedAt`.
- API keys: filter by `revoked`; sort by `createdAt`, `lastUsedAt`, or `expiresAt`.
- Sessions: sort by `lastSeenAt`, `createdAt`, or `expiresAt`.
- Approvals: filter by `status`; sort by `requestedAt` or `executedAt`.
- Agent decisions: filter by `outcome`, `fromProtocol`, and inclusive `from`/`to` creation dates; sort by `createdAt`, `outcome`, or `fromProtocol`.

## Authentication and Authorization

- Public endpoints: GET /health, POST /api/auth/challenge, POST /api/auth/verify, GET /api/whatsapp/webhook, POST /api/whatsapp/webhook, GET /api/vault/state, GET /api/protocols/rates, GET /api/protocols/agent/status, GET /api/agent/status
Expand Down Expand Up @@ -298,6 +327,31 @@ Notes:

## Portfolio

### GET /api/v1/liquidity-floor

- Auth: required; returns only the authenticated caller's status.
- Returns the configured USD floor, active USDC balance, amount exitable within
the 0.5% slippage target, yield-eligible amount, shortfall, estimated
restoration time, and liquidity-data availability.
- Only fresh (24-hour) snapshots with explicit zero withdrawal delay and queue
depth count as instantly liquid. Locked positions, pending transactions,
unsupported assets, and missing or stale data do not count.
- `dataAvailable: false` means the agent blocks rebalancing until liquidity data
is available. A floor above total balance is reported as a shortfall.

### PATCH /api/v1/liquidity-floor

- Auth: required; updates only the authenticated caller's floor.
- Request body: `{ "floorUsd": 500 }` sets a $500 minimum; `null` clears it;
zero disables reserve sizing.
- The floor takes precedence over goals and strategy allocation. Restoration
uses shortest known exits first and retains existing cost/payback gates; it is
a target, not an instantaneous guarantee.

The Stellar DEX collector derives conservative depth from Horizon reserves.
Blend and Luma are counted only when their API responses explicitly include
valid depth, queue, and withdrawal-delay metadata.

### GET /api/portfolio/:userId

- Auth: required (requireAuth + enforceUserAccess)
Expand Down Expand Up @@ -508,17 +562,26 @@ Response 404:
- userId: uuid string
- Query params:
- page: int >= 1, default 1
- limit: int between 1 and 50, default 5
- limit: int between 1 and 50, default 20
- type: optional transaction type
- status: optional transaction status
- protocolName: optional exact protocol match
- from, to: optional inclusive ISO-8601 bounds on createdAt
- sortBy: createdAt, updatedAt, or amount (default createdAt)
- sortOrder: asc or desc (default desc)
- Request body: none

Example request:
GET /api/transactions/550e8400-e29b-41d4-a716-446655440002?page=2&limit=10
GET /api/transactions/550e8400-e29b-41d4-a716-446655440002?page=2&limit=10&status=CONFIRMED&sortBy=amount&sortOrder=desc

Response 200:
{
"page": 2,
"limit": 10,
"total": 20,
"totalPages": 2,
"hasNext": false,
"hasPrevious": true,
"transactions": [
{
"id": "tx-id-1",
Expand Down
50 changes: 50 additions & 0 deletions docs/LIQUIDITY_FLOOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Agent Liquidity Floor

`User.liquidityFloor` is a minimum USD-equivalent amount the agent attempts to
keep in positions that can be exited immediately within the configured 0.5%
slippage target. It is evaluated before active goals and strategy allocation;
those controls operate only on the balance above the floor.

## Data Contract

Liquidity snapshots are valid for 24 hours. A position counts toward the floor
only when all of the following hold:

- Its asset is USDC.
- Its protocol has a fresh liquidity snapshot with a valid depth curve.
- The snapshot reports zero withdrawal delay and zero queue depth.
- The position is not locked and has no pending transaction.
- The whole position is exitable within the 0.5% slippage target.

Unknown assets, missing or stale snapshots, invalid depth curves, pending
transactions, and locked positions fail closed. The agent blocks rebalancing
while required liquidity data is unavailable rather than assuming funds are
liquid.

The current collector derives conservative 50-basis-point depth from Horizon's
USDC pool reserves for Stellar DEX. Blend and Luma snapshots are written only
when their API responses explicitly provide withdrawal delay, queue depth, and
depth data. `ProtocolLiquiditySnapshot.withdrawalDelayHours` must be set by the
trusted collector; null means unknown, not zero.

## Agent Behavior

When the floor is already met, the agent allocates no more than total balance
minus the floor and never uses fully liquid positions as rebalance sources.
When there is a shortfall, it considers unlocked positions with known exit times
in shortest-exit order and routes only to protocols with verified immediate
liquidity. Existing strategy risk and rebalance cost/payback gates still apply.
Pending transactions prevent duplicate attempts.

If the floor exceeds the total balance, the agent keeps the full balance out of
yield and reports the shortfall. A configured floor is a restoration target, not
an instantaneous on-chain guarantee; delayed withdrawals and cost gates can
extend restoration time.

## API

- `GET /api/v1/liquidity-floor` returns the authenticated user's configured
floor, current balances, shortfall, estimated restoration time, and data
availability.
- `PATCH /api/v1/liquidity-floor` accepts `{ "floorUsd": <non-negative number> }`.
Send `null` to clear the floor.
58 changes: 45 additions & 13 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ components:
window. Rows/responses with this flag set are excluded from leaderboard
rankings. Never silently serves a truncated window under the requested label.

PaginationMeta:
type: object
required: [page, limit, total, totalPages, hasNext, hasPrevious]
properties:
page:
type: integer
minimum: 1
limit:
type: integer
minimum: 1
maximum: 50
total:
type: integer
minimum: 0
totalPages:
type: integer
minimum: 0
hasNext:
type: boolean
hasPrevious:
type: boolean

NullableDecimal:
type: number
format: double
Expand Down Expand Up @@ -3037,25 +3059,35 @@ paths:
type: integer
minimum: 1
maximum: 50
default: 10
default: 20
- name: sortBy
in: query
required: false
schema:
type: string
enum: [createdAt, outcome, fromProtocol]
default: createdAt
- name: sortOrder
in: query
required: false
schema:
type: string
enum: [asc, desc]
default: desc
responses:
'200':
description: Paginated decision listing (user-projected).
content:
application/json:
schema:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
decisions:
type: array
items:
$ref: '#/components/schemas/RebalanceDecision'
allOf:
- $ref: '#/components/schemas/PaginationMeta'
- type: object
properties:
decisions:
type: array
items:
$ref: '#/components/schemas/RebalanceDecision'
'401':
$ref: '#/components/responses/Unauthorized'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE "users"
ADD COLUMN "liquidityFloor" DECIMAL(36, 18);

ALTER TABLE "positions"
ADD COLUMN "liquidityLocked" BOOLEAN NOT NULL DEFAULT false;

ALTER TABLE "protocol_liquidity_snapshots"
ADD COLUMN "withdrawalDelayHours" INTEGER;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE "protocol_liquidity_snapshots"
DROP COLUMN "withdrawalDelayHours";

ALTER TABLE "positions"
DROP COLUMN "liquidityLocked";

ALTER TABLE "users"
DROP COLUMN "liquidityFloor";
27 changes: 27 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,15 @@ model User {
// wallet auth and never link a number. Used as the WhatsApp delivery
// destination for alert rules (#289); alerts on the WHATSAPP/BOTH channel are
// skipped (logged, not errored) for users without a number on file.
phone String? @unique
riskTolerance Int @default(5)
rebalanceStrategy String? // 'MAX_YIELD' | 'TARGET_ALLOCATION' | null (defaults to MAX_YIELD)
strategyConfig Json? // e.g. { "targetAllocations": { "Blend": 50, "Stellar DEX": 30, "Luma": 20 } }
liquidityFloor Decimal? @db.Decimal(36, 18)
taxAwareRebalancing Boolean @default(false) // #550 - opt-in wash-sale-aware rebalancing
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
phone String? @unique
riskTolerance Int @default(5)
rebalanceStrategy String? // 'MAX_YIELD' | 'TARGET_ALLOCATION' | null (defaults to MAX_YIELD)
Expand Down Expand Up @@ -609,6 +618,7 @@ model Position {
depositedAmount Decimal @db.Decimal(36, 18)
currentValue Decimal @db.Decimal(36, 18)
yieldEarned Decimal @default(0) @db.Decimal(36, 18)
liquidityLocked Boolean @default(false)
status PositionStatus @default(ACTIVE)
openedAt DateTime @default(now())
closedAt DateTime?
Expand Down Expand Up @@ -2417,6 +2427,23 @@ model TreasurySweepPolicy {
@@map("treasury_sweep_policies")
}

// --- Issue #350: Liquidity Risk & Time-to-Exit Estimation ---
model ProtocolLiquiditySnapshot {
id String @id @default(uuid())
protocolName String
assetSymbol String
poolTvl Decimal @db.Decimal(36, 18)
availableLiquidity Decimal @db.Decimal(36, 18)
dailyVolume Decimal @db.Decimal(36, 18)
withdrawalQueueDepth Int?
withdrawalDelayHours Int?
depthCurve Json // [{ sizeUsd, priceImpactBps }]
fetchedAt DateTime @default(now())

@@unique([protocolName, assetSymbol, fetchedAt])
@@index([protocolName, assetSymbol])
@@index([fetchedAt])
@@map("protocol_liquidity_snapshots")
// ─────────────────────────────────────────────────────────────────────────────
// Treasury accounts (#jobs/treasurySweep.ts, #outbox/executors.ts)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
19 changes: 17 additions & 2 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,13 @@ async function rebalanceCheckJob(): Promise<void> {
for (const pos of positions) {
const { config, follow } = effectiveByUser.get(pos.userId)!
const hasGoal = goalsByUser.has(pos.userId)
const liquidityFloorConfigured =
(pos.user as any).liquidityFloor !== null &&
(pos.user as any).liquidityFloor !== undefined
const floorUserKey = liquidityFloorConfigured ? pos.userId : 'shared'
const key = `${pos.protocolName}:${config.strategyName || 'DEFAULT'}:${
config.riskCeiling ?? 'none'
}:${follow?.followId ?? 'none'}:${hasGoal ? 'goal' : 'nogoal'}`
}:${follow?.followId ?? 'none'}:${hasGoal ? 'goal' : 'nogoal'}:${floorUserKey}`
if (!byProtocolAndStrategy.has(key)) {
byProtocolAndStrategy.set(key, {
protocol: pos.protocolName,
Expand Down Expand Up @@ -344,7 +348,10 @@ async function rebalanceCheckJob(): Promise<void> {
// null — the common case for a new user, and exactly who this feature
// targets — would have had their followed config silently ignored.
const hasStrategyContext =
Boolean(lead.config.strategyName) || Boolean(lead.follow)
Boolean(lead.config.strategyName) ||
Boolean(lead.follow) ||
(protocolPositions[0].user as any).liquidityFloor !== null &&
(protocolPositions[0].user as any).liquidityFloor !== undefined

const userStrategyPreferences = hasStrategyContext
? protocolPositions.map((p: PositionWithUser) => {
Expand All @@ -365,6 +372,14 @@ async function rebalanceCheckJob(): Promise<void> {
// them.
exposureCaps: config.exposureCaps,
defaultMaxFraction: config.defaultMaxFraction,
...((p.user as any).liquidityFloor === null ||
(p.user as any).liquidityFloor === undefined
? {}
: {
liquidityFloorUsd: Number(
(p.user as any).liquidityFloor
),
}),
followedStrategyId: follow?.followedStrategyId ?? undefined,
}
})
Expand Down
Loading