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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ INVOICE_TIMELOCK_CONTRACT_ID=
INVOICE_MARKET_CONTRACT_ID=
INVOICE_MARKET_ABI=

# Portfolio health score contract (get_health_score). Unset returns 503 from
# GET /investors/:wallet/health-score.
PORTFOLIO_HEALTH_CONTRACT_ID=

# Multi-tier circuit breaker (#726): per-dependency health tracking and automatic recovery probes
# Global defaults
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
Expand Down
132 changes: 132 additions & 0 deletions docs/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,99 @@ GET /invoices/compare?ids=cm123,cm456

---

### GET /invoices/:invoice_id/early-incentive

Early funding incentive slot status for an invoice. `slots_filled` counts indexed
`EarlySlotFilled` events and is capped at `slots_total`. Configuration comes from
the most recent event. Responses are cached for 30 seconds.

- **Auth:** None
- **Response:** `200 OK`; `404` when the invoice does not exist. An invoice with
no incentive configured returns `is_active: false`.

```json
{
"success": true,
"data": {
"invoice_id": "cm123",
"slots_total": 10,
"slots_filled": 4,
"slots_remaining": 6,
"bonus_bps": 250,
"is_active": true
}
}
```

---

## Issuer Endpoints

### GET /issuers/:wallet/verification

Verification badge status for an issuer. `is_verified` is `false` for revoked,
expired, or never-verified issuers, and `tier` is `null` in those cases.
`days_remaining` is `null` for a grant with no expiry. Responses are cached for 60
seconds.

- **Auth:** None
- **Response:** `200 OK`; `400` for an invalid Stellar wallet

```json
{
"success": true,
"data": {
"wallet": "GISSUER...",
"is_verified": true,
"tier": "GOLD",
"expiry_timestamp": "2027-01-01T00:00:00.000Z",
"days_remaining": 71
}
}
```

### GET /issuers/verified

Paginated list of currently verified issuers.

- **Auth:** None
- **Query:** `page` (default `1`), `limit` (default `20`, max `100`)
- **Response:** `200 OK` with `meta` pagination

---

## Investor Endpoints

### GET /investors/:wallet/health-score

Portfolio health score for a wallet, read from the health-score contract's
`get_health_score` and cached for 5 minutes. `score` and `factors` are `null` for
a wallet with no portfolio. Requires `PORTFOLIO_HEALTH_CONTRACT_ID`; without it the
endpoint returns `503`.

- **Auth:** None
- **Response:** `200 OK`; `400` for an invalid wallet; `503` when the contract is
not configured or the read fails

```json
{
"success": true,
"data": {
"wallet": "GINVESTOR...",
"score": 742,
"factors": {
"concentration_score": 80,
"maturity_distribution_score": 70,
"issuer_diversity_score": 65,
"default_exposure_score": 90
},
"computed_at": "2026-10-21T10:00:00.000Z"
}
}
```

---

## Admin Endpoints

### POST /admin/proposals
Expand Down Expand Up @@ -611,6 +704,45 @@ window, with alert and auto-suspension state.

---

### GET /admin/treasury

Platform treasury balance for the admin dashboard. `total_balance` is gross fee
inflow minus allocations. `per_source_breakdown` is gross inflow per source;
allocations are not attributed to a source. Amounts are strings in stroops.
Cached for 60 seconds.

- **Auth:** Admin JWT (`adminGuard`)
- **Response:** `200 OK`

```json
{
"success": true,
"data": {
"total_balance": "1200000000",
"per_source_breakdown": [{ "source": "trading", "balance": "1500000000" }],
"last_updated": "2026-10-21T10:00:00.000Z"
}
}
```

### GET /admin/treasury/allocations

Paginated `FundsAllocated` history (`destination`, `amount`, `purpose`, `tx_hash`,
`ledger`, `occurred_at`).

- **Auth:** Admin JWT
- **Query:** `page` (default `1`), `limit` (default `20`, max `100`)

### GET /admin/treasury/inflows

Paginated `FeeReceived` history (`source`, `amount`, `tx_hash`, `ledger`,
`occurred_at`).

- **Auth:** Admin JWT
- **Query:** `page` (default `1`), `limit` (default `20`, max `100`)

---

## Keys Endpoints

### GET /keys/:keyId/price
Expand Down
23 changes: 23 additions & 0 deletions prisma/schema/early-incentive.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// prisma/schema/early-incentive.prisma
// Invoice early funding incentive slot tracking (#1180).

/// Append-only log of EarlySlotFilled events. Slots filled is a count of these
/// rows; the incentive configuration (total slots, bonus) is taken from the
/// most recent event for the invoice. The unique chain position makes replays
/// no-ops.
model InvoiceEarlySlotEvent {
id String @id @default(cuid())
invoiceId String
slotsTotal Int
bonusBps Int
investor String?
txHash String
eventIndex Int
ledger Int
occurredAt DateTime
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([invoiceId, ledger(sort: Desc), eventIndex(sort: Desc)])
@@map("invoice_early_slot_events")
}
34 changes: 34 additions & 0 deletions prisma/schema/issuer-verification.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// prisma/schema/issuer-verification.prisma
// Issuer verification badge state fed by VerificationGranted / VerificationRevoked (#1179).

/// Append-only log of verification events. The unique chain position makes
/// replays no-ops, so IssuerVerification only changes for newly seen events.
model IssuerVerificationEvent {
id String @id @default(cuid())
wallet String
eventType String
tier String?
expiresAt DateTime?
txHash String
eventIndex Int
ledger Int
occurredAt DateTime
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([wallet, occurredAt(sort: Desc)])
@@map("issuer_verification_events")
}

/// Current verification status per issuer wallet, derived from the event log.
model IssuerVerification {
wallet String @id
isVerified Boolean @default(false)
tier String?
expiresAt DateTime?
lastLedger Int
updatedAt DateTime @updatedAt

@@index([isVerified, expiresAt])
@@map("issuer_verification")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
-- CreateTable
CREATE TABLE "treasury_balance" (
"source" TEXT NOT NULL,
"balance" DECIMAL(40,0) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "treasury_balance_pkey" PRIMARY KEY ("source")
);

-- CreateTable
CREATE TABLE "treasury_inflows" (
"id" TEXT NOT NULL,
"source" TEXT NOT NULL,
"amount" DECIMAL(40,0) NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"ledger" INTEGER NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "treasury_inflows_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "treasury_allocations" (
"id" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"amount" DECIMAL(40,0) NOT NULL,
"purpose" TEXT NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"ledger" INTEGER NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "treasury_allocations_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "invoice_early_slot_events" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"slotsTotal" INTEGER NOT NULL,
"bonusBps" INTEGER NOT NULL,
"investor" TEXT,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"ledger" INTEGER NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "invoice_early_slot_events_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "issuer_verification_events" (
"id" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"tier" TEXT,
"expiresAt" TIMESTAMP(3),
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"ledger" INTEGER NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "issuer_verification_events_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "issuer_verification" (
"wallet" TEXT NOT NULL,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"tier" TEXT,
"expiresAt" TIMESTAMP(3),
"lastLedger" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "issuer_verification_pkey" PRIMARY KEY ("wallet")
);

-- CreateIndex
CREATE UNIQUE INDEX "treasury_inflows_txHash_eventIndex_key" ON "treasury_inflows"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "treasury_inflows_source_occurredAt_idx" ON "treasury_inflows"("source", "occurredAt" DESC);

-- CreateIndex
CREATE INDEX "treasury_inflows_occurredAt_idx" ON "treasury_inflows"("occurredAt" DESC);

-- CreateIndex
CREATE UNIQUE INDEX "treasury_allocations_txHash_eventIndex_key" ON "treasury_allocations"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "treasury_allocations_occurredAt_idx" ON "treasury_allocations"("occurredAt" DESC);

-- CreateIndex
CREATE UNIQUE INDEX "invoice_early_slot_events_txHash_eventIndex_key" ON "invoice_early_slot_events"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "invoice_early_slot_events_invoiceId_ledger_eventIndex_idx" ON "invoice_early_slot_events"("invoiceId", "ledger" DESC, "eventIndex" DESC);

-- CreateIndex
CREATE UNIQUE INDEX "issuer_verification_events_txHash_eventIndex_key" ON "issuer_verification_events"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "issuer_verification_events_wallet_occurredAt_idx" ON "issuer_verification_events"("wallet", "occurredAt" DESC);

-- CreateIndex
CREATE INDEX "issuer_verification_isVerified_expiresAt_idx" ON "issuer_verification"("isVerified", "expiresAt");
48 changes: 48 additions & 0 deletions prisma/schema/treasury.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// prisma/schema/treasury.prisma
// Platform treasury read models fed by FeeReceived / FundsAllocated (#1181).
// Amounts are stored in the contract's smallest unit (stroops).

/// Gross fee inflow per source. A balance only moves when its matching
/// TreasuryInflow row is newly inserted, so replayed events cannot double-count.
model TreasuryBalance {
source String @id
balance Decimal @db.Decimal(40, 0)
updatedAt DateTime @updatedAt

@@map("treasury_balance")
}

/// Append-only log of FeeReceived events. The unique chain position is the
/// idempotency guard for replays.
model TreasuryInflow {
id String @id @default(cuid())
source String
amount Decimal @db.Decimal(40, 0)
txHash String
eventIndex Int
ledger Int
occurredAt DateTime
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([source, occurredAt(sort: Desc)])
@@index([occurredAt(sort: Desc)])
@@map("treasury_inflows")
}

/// Append-only log of FundsAllocated events (treasury outflows).
model TreasuryAllocation {
id String @id @default(cuid())
destination String
amount Decimal @db.Decimal(40, 0)
purpose String
txHash String
eventIndex Int
ledger Int
occurredAt DateTime
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([occurredAt(sort: Desc)])
@@map("treasury_allocations")
}
4 changes: 4 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ export const envSchema = z
INVOICE_FRAUD_CONTRACT_ID: optionalNonEmptyString,
INVOICE_TIMELOCK_CONTRACT_ID: optionalNonEmptyString,

// Portfolio health score contract (get_health_score). Unset fails the
// /investors/:wallet/health-score read closed with 503.
PORTFOLIO_HEALTH_CONTRACT_ID: optionalNonEmptyString,

// Social metadata sync (Pinata gateway used to resolve avatar CIDs)
PINATA_GATEWAY_URL: z
.string()
Expand Down
Loading
Loading