Seven bugs found by auditing the billing and PSS paths. 1. Reservations were never released on ack/delivery timeout. Both failure paths in gateway/context.ts marked the GatewayJob terminal *before* calling releaseReservation, and releaseReservation bailed out on exactly those statuses — so every failed job permanently inflated the tenant's reservedXbzzWei and shrank their available balance. Replace the status check with GatewayJob.reservationReleasedAt as an explicit compare-and-set marker, which makes the release exactly-once regardless of ordering, and give settleXbzz the same guard so a job that was released locally and claimed on-chain anyway can't decrement the reservation twice. The migration returns the xBZZ already stuck on existing accounts. 2. job_ack / job_deliver were only checked for a valid signature, never for *whose* signature. Anyone who learned a routing id could sign their own job_deliver, pointing at a chunk encrypted to the gateway's (published) PSS key, and hand the tenant an arbitrary completion. Drop any envelope whose `from` isn't the provider the job was posted to. 3. BigInt(decimal.toString()) throws on any wei value >= 1e21 (1000 xBZZ), because decimal.js switches to exponential notation there. That took out GET /accounts/me and, worse, wedged the claim watcher's settle loop. Route every Decimal→string conversion through toFixed(). 4. A cancelJob failure at the ACK deadline left the job with no timer at all, so the chat request hung forever holding a semaphore slot and a reservation. Fall through to the delivery deadline instead. 5. `?limit=abc` reached Prisma as `take: NaN` (500). Clamp and fall back. 6. A fractional max_tokens made BigInt() throw; a negative one budgeted a negative reservation. Validate at the schema and defend in the estimator. 7. model.<entity>.delete() announced the deletion on the queue before performing it, so a failed delete still told consumers the row was gone. Verified against a real Postgres: the reservation lifecycle (release-after- terminal, settle-after-release, concurrent release/settle, idempotent settle) and the migration's repair of leaked reservations on pre-fix data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mfkh741kNCZeBdk4xyM9qR
231 lines
7.0 KiB
Plaintext
231 lines
7.0 KiB
Plaintext
// Hosted T4T Gateway — Prisma schema.
|
|
//
|
|
// One operator wallet handles all on-chain transactions. Tenants are off-chain
|
|
// ledger entries denominated in xBZZ wei (Decimal(78, 0) holds any uint256).
|
|
// USD is never in the billing path — only in `Topup` rows for Phase 2 Stripe.
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
// Connection URL + seed config live in prisma.config.ts (Prisma 7).
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Enums
|
|
// -----------------------------------------------------------------------------
|
|
|
|
enum UserRole {
|
|
USER
|
|
ADMIN
|
|
}
|
|
|
|
enum AuthProvider {
|
|
google
|
|
email
|
|
}
|
|
|
|
enum AccountStatus {
|
|
active
|
|
disabled
|
|
}
|
|
|
|
enum LedgerKind {
|
|
welcomeGrant
|
|
settle
|
|
refund
|
|
topup
|
|
adjustment
|
|
}
|
|
|
|
enum GatewayJobStatus {
|
|
posted
|
|
acked
|
|
delivered
|
|
claimed
|
|
cancelled
|
|
timed_out
|
|
failed
|
|
}
|
|
|
|
enum TopupStatus {
|
|
requires_payment
|
|
processing
|
|
succeeded
|
|
credited
|
|
failed
|
|
refunded
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// User + auth
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
email String @unique
|
|
emailVerified Boolean @default(false)
|
|
// Single-use email verification token (NULL once consumed). For Phase 1 we
|
|
// surface this in the signup response in dev/test mode; Phase 1.5 wires up
|
|
// a real outbound mailer and stops echoing the token back.
|
|
verificationToken String? @unique
|
|
authProvider AuthProvider
|
|
googleSub String? @unique
|
|
passwordHash String?
|
|
role UserRole @default(USER)
|
|
|
|
// Phase 2 — Stripe handles billing address + sanctions screening
|
|
stripeCustomerId String? @unique
|
|
country String? // ISO 3166-1 alpha-2, mirrored from Stripe
|
|
|
|
account Account?
|
|
apiKeys ApiKey[]
|
|
gatewayJobs GatewayJob[]
|
|
topups Topup[]
|
|
|
|
@@index([authProvider, googleSub])
|
|
}
|
|
|
|
model ApiKey {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
key String @unique @default(uuid())
|
|
name String @default("")
|
|
lastUsedAt DateTime?
|
|
scopeMaxXbzzPerDay Decimal? @db.Decimal(78, 0)
|
|
modelAllowlist String[] @default([])
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
|
|
@@index([userId])
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Account + ledger
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model Account {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
status AccountStatus @default(active)
|
|
balanceXbzzWei Decimal @default(0) @db.Decimal(78, 0)
|
|
reservedXbzzWei Decimal @default(0) @db.Decimal(78, 0)
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
userId String @unique
|
|
|
|
ledgerEntries LedgerEntry[]
|
|
}
|
|
|
|
model LedgerEntry {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
kind LedgerKind
|
|
deltaXbzzWei Decimal @db.Decimal(78, 0)
|
|
idempotencyKey String @unique
|
|
note String?
|
|
|
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
|
accountId String
|
|
|
|
gatewayJob GatewayJob? @relation(fields: [gatewayJobId], references: [id], onDelete: SetNull)
|
|
gatewayJobId String?
|
|
|
|
topup Topup? @relation(fields: [topupId], references: [id], onDelete: SetNull)
|
|
topupId String?
|
|
|
|
@@index([accountId, createdAt(sort: Desc)])
|
|
@@index([kind])
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Gateway jobs (per-chat-completion, settled on JobClaimed)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model GatewayJob {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Routing
|
|
routingId String @unique // local routing id (keccak of request hash etc.)
|
|
onChainJobId String? @unique // bytes32 from JobEscrow.JobPosted
|
|
requestHash String? // Swarm chunk ref of encrypted request
|
|
responseHash String? // Swarm chunk ref of encrypted response
|
|
|
|
// Counterparties / model
|
|
provider String // 0x address selected by the gateway
|
|
modelId String
|
|
|
|
// Billing (xBZZ-denominated; markup baked in)
|
|
estimatedMaxXbzzWei Decimal @db.Decimal(78, 0)
|
|
actualXbzzWei Decimal? @db.Decimal(78, 0)
|
|
|
|
// Lifecycle
|
|
status GatewayJobStatus @default(posted)
|
|
postedAt DateTime @default(now())
|
|
ackedAt DateTime?
|
|
deliveredAt DateTime?
|
|
claimedAt DateTime?
|
|
ackDeadline DateTime?
|
|
deliveryDeadline DateTime?
|
|
|
|
// Set the first time this job's off-chain reservation is released — by
|
|
// `settleXbzz` on JobClaimed, or by `releaseReservation` on cancel / timeout
|
|
// / dispatch failure. It is the compare-and-set marker that makes the
|
|
// release exactly-once: `status` cannot serve that role because a job can be
|
|
// marked terminal and released by different code paths in either order.
|
|
reservationReleasedAt DateTime?
|
|
|
|
// Token accounting (from JobClaimed actualPayment)
|
|
promptTokens Int?
|
|
completionTokens Int?
|
|
|
|
// Optional payload retention (off by default; T4T_PERSIST_PAYLOADS-style)
|
|
promptRedacted String?
|
|
responseRedacted String?
|
|
errorMessage String?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
|
|
ledgerEntries LedgerEntry[]
|
|
|
|
@@index([userId, createdAt(sort: Desc)])
|
|
@@index([status])
|
|
@@index([onChainJobId])
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Stripe top-ups (Phase 2)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model Topup {
|
|
id String @id @default(uuid())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
status TopupStatus @default(requires_payment)
|
|
|
|
stripePaymentIntent String @unique
|
|
amountUsdCents Int
|
|
|
|
creditedXbzzWei Decimal? @db.Decimal(78, 0)
|
|
usdPerXbzzAtCredit Decimal? @db.Decimal(18, 8)
|
|
|
|
error String?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
|
|
ledgerEntries LedgerEntry[]
|
|
|
|
@@index([userId, createdAt(sort: Desc)])
|
|
@@index([status])
|
|
}
|