Skip to content
Open
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
69 changes: 69 additions & 0 deletions Docs/integrations/score-gatekeeper.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions Server/src/controllers/contracts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { NextFunction, Request, Response } from "express";
import { prisma } from "../config/database";
import {
attestScoreOnChain,
evaluateTierGate,
getContractConfig,
isTierGateRecordFresh,
readOnChainScore,
tierCodeToLabel,
type GateTier,
} from "../services/soroban.service";
import { verifyWalletSignature } from "../services/auth-challenge.service";

Expand Down Expand Up @@ -71,6 +74,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 || !isTierGateRecordFresh(record)) {
return res.status(503).json({
success: false,
error: "On-chain tier unavailable or expired; 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:
Expand Down
4 changes: 4 additions & 0 deletions Server/src/middleware/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
8 changes: 8 additions & 0 deletions Server/src/routes/user.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ import {
WalletParamSchema,
PaginationQuerySchema,
ScoreHistoryQuerySchema,
TierGateQuerySchema,
} from "../middleware/schemas";

const router = Router();
Expand All @@ -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),
Expand Down
91 changes: 91 additions & 0 deletions Server/src/services/__tests__/score-gatekeeper.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import {
evaluateTierGate,
getContractConfig,
isTierGateRecordFresh,
sorobanU64ToSafeNumber,
} 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", () => {
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);
});

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("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");
});
});
77 changes: 73 additions & 4 deletions Server/src/services/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -23,6 +37,51 @@ 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 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
): 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<string> {
const { Networks } = await import("@stellar/stellar-sdk");
return process.env.STELLAR_NETWORK === "mainnet"
Expand Down Expand Up @@ -95,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);
Expand Down