From 70327cf0650a18c5976ab6deb1d47da68145b469 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:17:48 -0400 Subject: [PATCH 1/5] feat: add Soroban score tier gatekeeper precheck --- Docs/integrations/score-gatekeeper.md | 69 ++++++++++++++++++ .../src/controllers/contracts.controller.ts | 70 +++++++++++++++++++ Server/src/middleware/schemas.ts | 4 ++ Server/src/routes/user.routes.ts | 8 +++ .../score-gatekeeper.service.test.ts | 37 ++++++++++ Server/src/services/soroban.service.ts | 29 ++++++++ 6 files changed, 217 insertions(+) create mode 100644 Docs/integrations/score-gatekeeper.md create mode 100644 Server/src/services/__tests__/score-gatekeeper.service.test.ts diff --git a/Docs/integrations/score-gatekeeper.md b/Docs/integrations/score-gatekeeper.md new file mode 100644 index 0000000..3c0b291 --- /dev/null +++ b/Docs/integrations/score-gatekeeper.md @@ -0,0 +1,69 @@ +# Score Gatekeeper precheck + +Issue #28 uses the server-side precheck path until the score-registry deployment tracked by #16 is available. The gate reads the existing Soroban score record and compares its canonical tier code; it does not recalculate credit scores or submit a lending transaction. + +## Endpoint + +```http +GET /api/user/{wallet}/tier?minTier=B +``` + +`minTier` accepts `A`, `B`, or `C` and defaults to `B`. The tier ordering is the existing score-registry encoding: + +| Code | Tier | +| ---: | --- | +| 0 | REJECTED | +| 1 | C | +| 2 | B | +| 3 | A | + +A wallet at or above the requested tier receives HTTP 200 with `allowed: true`. + +A wallet below the requested tier receives HTTP 403 with `allowed: false` and a clear minimum-tier error. Unknown contract tier values normalize to `REJECTED` and fail closed. + +If the score registry is not configured, the RPC read fails, or no attested score is available, the endpoint returns HTTP 503. Protocol consumers should treat that result as not eligible rather than falling back to an off-chain score. + +## Example + +```bash +curl "http://localhost:3000/api/user/G.../tier?minTier=B" +``` + +Allowed response: + +```json +{ + "success": true, + "data": { + "walletAddress": "G...", + "score": 610, + "allowed": true, + "tier": "B", + "tierCode": 2, + "minimumTier": "B", + "minimumTierCode": 2, + "updatedAt": 1791267000, + "validUntil": 1791871800, + "source": "soroban" + } +} +``` + +Below-threshold response uses the same data shape with HTTP 403 and `success: false`. + +## Mock score-registry + +For CI or local integration work, deploy the test-only contract in `Contracts/mock-score-registry/` and point the server test process at that contract with the existing `withMockContractId(id)` helper. The endpoint calls `readOnChainScore`, so it follows the same effective contract ID path as other score-registry reads. + +The mock is test-only. Do not deploy it to mainnet. + +## Lending integration + +A lending adapter can call the precheck immediately before constructing or submitting a gated transaction: + +1. request the wallet tier with the protocol's minimum; +2. continue only on HTTP 200 with `allowed: true`; +3. stop on HTTP 403; +4. fail closed on HTTP 503 or other read errors. + +This keeps scoring authority in the existing score registry and avoids duplicating ZCore scoring logic in each protocol. diff --git a/Server/src/controllers/contracts.controller.ts b/Server/src/controllers/contracts.controller.ts index 484d246..b37f1ce 100644 --- a/Server/src/controllers/contracts.controller.ts +++ b/Server/src/controllers/contracts.controller.ts @@ -2,9 +2,11 @@ import { NextFunction, Request, Response } from "express"; import { prisma } from "../config/database"; import { attestScoreOnChain, + evaluateTierGate, getContractConfig, readOnChainScore, tierCodeToLabel, + type GateTier, } from "../services/soroban.service"; import { verifyWalletSignature } from "../services/auth-challenge.service"; @@ -71,6 +73,74 @@ export const getOnChainScore = async ( } }; +/** + * @swagger + * /api/user/{wallet}/tier: + * get: + * tags: [Contracts] + * summary: Check whether an attested wallet meets a minimum protocol tier + * parameters: + * - in: path + * name: wallet + * required: true + * schema: + * type: string + * - in: query + * name: minTier + * required: false + * schema: + * type: string + * enum: [A, B, C] + * default: B + * responses: + * 200: + * description: Wallet meets the requested minimum tier + * 403: + * description: Wallet is below the requested minimum tier + * 503: + * description: On-chain score is unavailable; gate fails closed + */ +export const getTierGate = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { wallet } = req.params; + const minimumTier = (req.query.minTier ?? "B") as GateTier; + const record = await readOnChainScore(wallet); + + if (!record) { + return res.status(503).json({ + success: false, + error: "On-chain tier unavailable; gate fails closed", + }); + } + + const decision = evaluateTierGate(record, minimumTier); + const data = { + walletAddress: wallet, + score: record.score, + ...decision, + updatedAt: record.updatedAt, + validUntil: record.validUntil ?? null, + source: "soroban", + }; + + if (!decision.allowed) { + return res.status(403).json({ + success: false, + error: `Minimum tier ${minimumTier} required`, + data, + }); + } + + return res.status(200).json({ success: true, data }); + } catch (error) { + return next(error); + } +}; + /** * @swagger * /api/user/{wallet}/attest: diff --git a/Server/src/middleware/schemas.ts b/Server/src/middleware/schemas.ts index 5dd2aa4..9b6b250 100644 --- a/Server/src/middleware/schemas.ts +++ b/Server/src/middleware/schemas.ts @@ -64,6 +64,10 @@ export const ScoreHistoryQuerySchema = PaginationQuerySchema.extend({ to: z.string().datetime().optional(), }); +export const TierGateQuerySchema = z.object({ + minTier: z.enum(["A", "B", "C"]).default("B"), +}); + export const CreditEventSchema = z.object({ apiKey: z.string().min(10, "Invalid API key"), eventType: z.enum([ diff --git a/Server/src/routes/user.routes.ts b/Server/src/routes/user.routes.ts index a5f4231..e5380ac 100644 --- a/Server/src/routes/user.routes.ts +++ b/Server/src/routes/user.routes.ts @@ -10,6 +10,7 @@ import { import { attestScore, getOnChainScore, + getTierGate, } from "../controllers/contracts.controller"; import { validate, validateParams, validateQuery } from "../middleware/validation.middleware"; import { validateLenderKey } from "../middleware/lender-auth.middleware"; @@ -24,6 +25,7 @@ import { WalletParamSchema, PaginationQuerySchema, ScoreHistoryQuerySchema, + TierGateQuerySchema, } from "../middleware/schemas"; const router = Router(); @@ -37,6 +39,12 @@ const lenderScoreRateLimit = createRateLimiter({ router.post("/request", validate(ScoringRequestSchema), requestScoring); router.get("/:wallet/on-chain", validateParams(WalletParamSchema), getOnChainScore); +router.get( + "/:wallet/tier", + validateParams(WalletParamSchema), + validateQuery(TierGateQuerySchema), + getTierGate +); router.post( "/:wallet/attest", validateParams(WalletParamSchema), diff --git a/Server/src/services/__tests__/score-gatekeeper.service.test.ts b/Server/src/services/__tests__/score-gatekeeper.service.test.ts new file mode 100644 index 0000000..f65102e --- /dev/null +++ b/Server/src/services/__tests__/score-gatekeeper.service.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { evaluateTierGate } from "../soroban.service"; + +describe("score gatekeeper", () => { + it("allows an attested tier at or above the requested minimum", () => { + expect( + evaluateTierGate({ score: 610, tier: 2, updatedAt: 1_000 }, "B") + ).toEqual({ + allowed: true, + tier: "B", + tierCode: 2, + minimumTier: "B", + minimumTierCode: 2, + }); + + expect( + evaluateTierGate({ score: 790, tier: 3, updatedAt: 1_000 }, "B").allowed + ).toBe(true); + }); + + it("rejects lower or malformed on-chain tiers", () => { + const below = evaluateTierGate( + { score: 420, tier: 1, updatedAt: 1_000 }, + "B" + ); + expect(below.allowed).toBe(false); + expect(below.tier).toBe("C"); + + const malformed = evaluateTierGate( + { score: 850, tier: 99, updatedAt: 1_000 }, + "C" + ); + expect(malformed.allowed).toBe(false); + expect(malformed.tier).toBe("REJECTED"); + expect(malformed.tierCode).toBe(0); + }); +}); diff --git a/Server/src/services/soroban.service.ts b/Server/src/services/soroban.service.ts index cda4f1c..74fcd4b 100644 --- a/Server/src/services/soroban.service.ts +++ b/Server/src/services/soroban.service.ts @@ -23,6 +23,35 @@ export function getInterfaceVersion(): number { return ZCORE_INTERFACE_VERSION; } +export type GateTier = "A" | "B" | "C"; + +export interface TierGateDecision { + allowed: boolean; + tier: string; + tierCode: number; + minimumTier: GateTier; + minimumTierCode: number; +} + +export function evaluateTierGate( + record: OnChainScoreRecord, + minimumTier: GateTier +): TierGateDecision { + const tier = tierCodeToLabel(record.tier); + // Normalize unknown contract output through the canonical mapping so a + // malformed tier fails closed as REJECTED instead of accidentally passing. + const tierCode = tierLabelToCode(tier); + const minimumTierCode = tierLabelToCode(minimumTier); + + return { + allowed: tierCode >= minimumTierCode, + tier, + tierCode, + minimumTier, + minimumTierCode, + }; +} + async function getNetworkPassphrase(): Promise { const { Networks } = await import("@stellar/stellar-sdk"); return process.env.STELLAR_NETWORK === "mainnet" From b0a3a8e1f694505c76bc43fb1e217cce5716b7e2 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:32:29 -0400 Subject: [PATCH 2/5] fix(gatekeeper): reject stale score attestations --- Server/src/services/soroban.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Server/src/services/soroban.service.ts b/Server/src/services/soroban.service.ts index 74fcd4b..37c8567 100644 --- a/Server/src/services/soroban.service.ts +++ b/Server/src/services/soroban.service.ts @@ -33,6 +33,22 @@ export interface TierGateDecision { minimumTierCode: number; } +export function isTierGateRecordFresh( + record: OnChainScoreRecord, + nowEpochSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + if (!Number.isFinite(record.updatedAt) || record.updatedAt <= 0) { + return false; + } + + const validUntil = record.validUntil; + if (validUntil === undefined || validUntil === 0) { + return true; + } + + return Number.isFinite(validUntil) && validUntil >= nowEpochSeconds; +} + export function evaluateTierGate( record: OnChainScoreRecord, minimumTier: GateTier From a61d30da96e6931c97016036c8a6e8fff560de64 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:32:32 -0400 Subject: [PATCH 3/5] fix(gatekeeper): fail closed on stale tiers --- Server/src/controllers/contracts.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Server/src/controllers/contracts.controller.ts b/Server/src/controllers/contracts.controller.ts index b37f1ce..3f78d53 100644 --- a/Server/src/controllers/contracts.controller.ts +++ b/Server/src/controllers/contracts.controller.ts @@ -4,6 +4,7 @@ import { attestScoreOnChain, evaluateTierGate, getContractConfig, + isTierGateRecordFresh, readOnChainScore, tierCodeToLabel, type GateTier, @@ -110,10 +111,10 @@ export const getTierGate = async ( const minimumTier = (req.query.minTier ?? "B") as GateTier; const record = await readOnChainScore(wallet); - if (!record) { + if (!record || !isTierGateRecordFresh(record)) { return res.status(503).json({ success: false, - error: "On-chain tier unavailable; gate fails closed", + error: "On-chain tier unavailable or expired; gate fails closed", }); } From abea3acd18ce0872d5d733a506b3e4d2ca3b1d95 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:32:34 -0400 Subject: [PATCH 4/5] test(gatekeeper): cover freshness and mock contract --- .../score-gatekeeper.service.test.ts | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/Server/src/services/__tests__/score-gatekeeper.service.test.ts b/Server/src/services/__tests__/score-gatekeeper.service.test.ts index f65102e..20a580f 100644 --- a/Server/src/services/__tests__/score-gatekeeper.service.test.ts +++ b/Server/src/services/__tests__/score-gatekeeper.service.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { evaluateTierGate } from "../soroban.service"; +import { afterEach, describe, expect, it } from "vitest"; +import { + evaluateTierGate, + getContractConfig, + isTierGateRecordFresh, +} from "../soroban.service"; +import { + clearMockContractId, + withMockContractId, +} from "../../test-helpers/mock-soroban"; + +afterEach(() => { + clearMockContractId(); +}); describe("score gatekeeper", () => { it("allows an attested tier at or above the requested minimum", () => { @@ -34,4 +46,36 @@ describe("score gatekeeper", () => { expect(malformed.tier).toBe("REJECTED"); expect(malformed.tierCode).toBe(0); }); + + it("fails closed for unattested or expired score records", () => { + expect( + isTierGateRecordFresh({ score: 790, tier: 3, updatedAt: 0 }, 1_000) + ).toBe(false); + + expect( + isTierGateRecordFresh( + { score: 790, tier: 3, updatedAt: 900, validUntil: 999 }, + 1_000 + ) + ).toBe(false); + + expect( + isTierGateRecordFresh( + { score: 790, tier: 3, updatedAt: 900, validUntil: 1_000 }, + 1_000 + ) + ).toBe(true); + + expect( + isTierGateRecordFresh( + { score: 790, tier: 3, updatedAt: 900, validUntil: 0 }, + 1_000 + ) + ).toBe(true); + }); + + it("resolves the score-registry contract through the mock override", () => { + withMockContractId("MOCK_SCORE_REGISTRY"); + expect(getContractConfig()?.contractId).toBe("MOCK_SCORE_REGISTRY"); + }); }); From a4a2a115d3394a5f00b244c54047ab7786543aa9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 03:35:52 -0400 Subject: [PATCH 5/5] fix(gatekeeper): normalize Soroban u64 timestamps --- .../score-gatekeeper.service.test.ts | 10 ++++++ Server/src/services/soroban.service.ts | 32 ++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Server/src/services/__tests__/score-gatekeeper.service.test.ts b/Server/src/services/__tests__/score-gatekeeper.service.test.ts index 20a580f..a503fcd 100644 --- a/Server/src/services/__tests__/score-gatekeeper.service.test.ts +++ b/Server/src/services/__tests__/score-gatekeeper.service.test.ts @@ -3,6 +3,7 @@ import { evaluateTierGate, getContractConfig, isTierGateRecordFresh, + sorobanU64ToSafeNumber, } from "../soroban.service"; import { clearMockContractId, @@ -74,6 +75,15 @@ describe("score gatekeeper", () => { ).toBe(true); }); + it("normalizes Soroban u64 timestamps before freshness checks", () => { + expect(sorobanU64ToSafeNumber(1_791_272_062n)).toBe(1_791_272_062); + expect(sorobanU64ToSafeNumber(0n)).toBe(0); + expect(sorobanU64ToSafeNumber(-1n)).toBeNull(); + expect( + sorobanU64ToSafeNumber(BigInt(Number.MAX_SAFE_INTEGER) + 1n) + ).toBeNull(); + }); + it("resolves the score-registry contract through the mock override", () => { withMockContractId("MOCK_SCORE_REGISTRY"); expect(getContractConfig()?.contractId).toBe("MOCK_SCORE_REGISTRY"); diff --git a/Server/src/services/soroban.service.ts b/Server/src/services/soroban.service.ts index 37c8567..2989fc0 100644 --- a/Server/src/services/soroban.service.ts +++ b/Server/src/services/soroban.service.ts @@ -13,6 +13,20 @@ export interface OnChainScoreRecord { validUntil?: number; } +export function sorobanU64ToSafeNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } + + if (typeof value === "bigint") { + return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : null; + } + + return null; +} + export function tierToCode(tier: string): number { return tierLabelToCode(tier); } @@ -140,15 +154,25 @@ export async function readOnChainScore( const native = scValToNative(result) as { score: number; tier: number; - updated_at: number; - valid_until?: number; + updated_at: number | bigint; + valid_until?: number | bigint; }; + const updatedAt = sorobanU64ToSafeNumber(native.updated_at); + const validUntil = + native.valid_until === undefined + ? undefined + : sorobanU64ToSafeNumber(native.valid_until); + if (updatedAt === null || validUntil === null) { + console.error("Soroban score returned an unsafe u64 timestamp"); + return null; + } + return { score: native.score, tier: native.tier, - updatedAt: native.updated_at, - validUntil: native.valid_until, + updatedAt, + validUntil, }; } catch (error) { console.error("Failed to read on-chain score:", error);