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
8 changes: 8 additions & 0 deletions backend/docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ Restrict it at the ingress/firewall level — it must not be publicly reachable.

`error_type` values: `client_error`, `unavailable`, `unknown`.

### Quote simulation cache

| Metric | Type | Labels | Description |
|---|---|---|---|
| `quote_simulation_cache_requests_total` | Counter | `result` | `hit` = Redis served; `miss` = computed via RPC; `bypass` = `Cache-Control: no-cache` |

See [quote-simulation-cache.md](./quote-simulation-cache.md) for TTL and invalidation.

### Cardinality notes

- `route` is normalised: numeric path segments → `:id`, UUIDs → `:uuid`,
Expand Down
42 changes: 42 additions & 0 deletions backend/docs/quote-simulation-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Quote simulation Redis cache

## Purpose

`POST /api/quote/generate-premium` can simulate `generate_premium` on Soroban for every request, which adds latency and consumes RPC rate limits. A short-TTL Redis cache stores **successful on-chain simulation results** keyed by a deterministic hash of normalized quote inputs.

## Key design

- **Cache key**: `quote:sim:v1:` + SHA-256 of `CONTRACT_ID`, `STELLAR_NETWORK_PASSPHRASE`, and canonical JSON of sorted fields: `age`, `coverage_tier`, `policy_type`, `region`, `risk_score`, `source_account` (empty string if omitted).
- **Stored value**: JSON of `{ premiumStroops, premiumXlm, minResourceFee, source: "simulation", inputs }` — same shape as the API response body (excluding redundant `inputs` merge).
- **Not cached**:
- Responses with `source: "local_fallback"` (contract simulation error path inside `SorobanService`).
- Any thrown error (e.g. `ACCOUNT_NOT_FOUND`, `WRONG_NETWORK`) — transient failures must not be pinned in Redis.
- Requests **without** `source_account` (local-only path; no RPC reduction target).

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `QUOTE_SIMULATION_CACHE_ENABLED` | `true` | Set to `false` or `0` to disable read/write. |
| `QUOTE_SIMULATION_CACHE_TTL_SECONDS` | `30` | Redis TTL per entry (1–600 seconds in validation). |

## TTL tradeoffs

- **Too long**: Clients may see **stale premiums** after on-chain multiplier table updates until TTL expires or cache is invalidated.
- **Too short**: **More RPC traffic** and less benefit; tune against Soroban quotas and p95 quote latency.

Operational mitigation: the indexer clears all `quote:sim:v1:*` keys when it observes contract event `niffyins:tbl_upd` (multiplier table update). Short TTL remains a safety bound.

## Bypass

Send header `Cache-Control: no-cache` (exact directive `no-cache` among comma-separated values). The handler skips Redis get/set and records metric `quote_simulation_cache_requests_total{result="bypass"}`.

## Metrics

Prometheus counter **`quote_simulation_cache_requests_total`** with label **`result`**:

- `hit` — served from Redis
- `miss` — cache empty/disabled, performed simulation
- `bypass` — `no-cache` request

Use hit/(hit+miss) on dashboards for cache effectiveness (exclude bypass from denominator if desired).
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { validationSchema } from './config/env.validation';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { CacheModule } from './cache/cache.module';
import { RedisService } from './cache/redis.service';
import { RedisThrottlerStorage } from './common/guards/throttler-redis.storage';
import { RpcModule } from './rpc/rpc.module';
import { IndexerModule } from './indexer/indexer.module';
import { IpfsModule } from './ipfs/ipfs.module';
Expand Down
4 changes: 2 additions & 2 deletions backend/src/common/guards/throttler-redis.storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import { ThrottlerStorageRecord } from '@nestjs/throttler';
import { RedisService } from '../cache/redis.service';
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
import { RedisService } from '../../cache/redis.service';

/**
* Redis-backed storage for @nestjs/throttler.
Expand Down
10 changes: 10 additions & 0 deletions backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export const validationSchema = Joi.object({
CACHE_TTL_SECONDS: Joi.number()
.default(60)
.description("Cache TTL in seconds"),
QUOTE_SIMULATION_CACHE_ENABLED: Joi.string()
.valid("true", "false", "1", "0")
.default("true")
.description("Redis cache for successful Soroban quote simulations"),
QUOTE_SIMULATION_CACHE_TTL_SECONDS: Joi.number()
.integer()
.min(1)
.max(600)
.default(30)
.description("TTL for quote simulation cache entries (seconds)"),
// CAPTCHA (Turnstile or hCaptcha)
CAPTCHA_PROVIDER: Joi.string()
.valid("turnstile", "hcaptcha")
Expand Down
1 change: 1 addition & 0 deletions backend/src/indexer/indexer.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { IndexerWorker } from './indexer.worker';
import { ReindexWorkerService } from './reindex.worker';
import { PrismaModule } from '../prisma/prisma.module';
import { RpcModule } from '../rpc/rpc.module';
import { QuoteModule } from '../quote/quote.module';

@Module({
imports: [PrismaModule, RpcModule, ConfigModule],
Expand Down
4 changes: 4 additions & 0 deletions backend/src/indexer/indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ export class IndexerService {

await this.advanceCursorInTx(tx, network, event.ledger);
});

if (parsed?.key === 'niffyins:tbl_upd') {
await this.quoteSimulationCache.invalidateAll();
}
}

private async handlePolicyInitiated(tx: IndexerTx, data: EventPayload, event: SorobanEvent) {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/metrics/metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export class MetricsService implements OnModuleInit {
readonly rpcCallDuration: client.Histogram<string>;
readonly rpcCallTotal: client.Counter<string>;
readonly rpcErrorTotal: client.Counter<string>;
/** result: hit | miss | bypass — quote simulation Redis cache */
readonly quoteSimulationCacheTotal: client.Counter<string>;

constructor() {
this.registry = new client.Registry();
Expand Down Expand Up @@ -100,6 +102,13 @@ export class MetricsService implements OnModuleInit {
labelNames: ['rpc_method', 'error_type'],
registers: [this.registry],
});

this.quoteSimulationCacheTotal = new client.Counter({
name: 'quote_simulation_cache_requests_total',
help: 'Quote simulation cache lookups (hit/miss/bypass)',
labelNames: ['result'],
registers: [this.registry],
});
}

onModuleInit() {
Expand Down Expand Up @@ -154,6 +163,10 @@ export class MetricsService implements OnModuleInit {
}
}

recordQuoteSimulationCache(result: 'hit' | 'miss' | 'bypass') {
this.quoteSimulationCacheTotal.inc({ result });
}

async getMetrics(): Promise<string> {
return this.registry.metrics();
}
Expand Down
55 changes: 55 additions & 0 deletions backend/src/quote/quote-simulation-cache-key.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
buildNormalizedQuoteCanonicalJson,
buildQuoteSimulationCacheKeyHash,
} from './quote-simulation-cache-key.util';
import {
CoverageTierEnum,
PolicyTypeEnum,
RegionTierEnum,
} from './dto/generate-premium.dto';

describe('quote-simulation-cache-key.util', () => {
it('sorts keys for stable canonical JSON', () => {
const a = buildNormalizedQuoteCanonicalJson({
policy_type: PolicyTypeEnum.Auto,
region: RegionTierEnum.Medium,
coverage_tier: CoverageTierEnum.Standard,
age: 40,
risk_score: 3,
source_account:
'GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I',
});
expect(a).toBe(
'{"age":40,"coverage_tier":"Standard","policy_type":"Auto","region":"Medium","risk_score":3,"source_account":"GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I"}',
);
});

it('same logical inputs produce same hash', () => {
const dto = {
policy_type: PolicyTypeEnum.Property,
region: RegionTierEnum.High,
coverage_tier: CoverageTierEnum.Premium,
age: 22,
risk_score: 8,
source_account: 'GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I',
};
const h1 = buildQuoteSimulationCacheKeyHash(dto, 'C1', 'net-a');
const h2 = buildQuoteSimulationCacheKeyHash(dto, 'C1', 'net-a');
expect(h1).toBe(h2);
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});

it('different contract id changes hash', () => {
const dto = {
policy_type: PolicyTypeEnum.Health,
region: RegionTierEnum.Low,
coverage_tier: CoverageTierEnum.Basic,
age: 50,
risk_score: 2,
source_account: '',
};
const h1 = buildQuoteSimulationCacheKeyHash(dto, 'CAAA', 'net');
const h2 = buildQuoteSimulationCacheKeyHash(dto, 'CBBB', 'net');
expect(h1).not.toBe(h2);
});
});
39 changes: 39 additions & 0 deletions backend/src/quote/quote-simulation-cache-key.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createHash } from 'crypto';
import type { GeneratePremiumDto } from './dto/generate-premium.dto';

/**
* Canonical JSON for cache keys: fixed key order (sorted) and normalized scalars.
* Changing this function invalidates all existing quote cache entries.
*/
export function buildNormalizedQuoteCanonicalJson(dto: GeneratePremiumDto): string {
const normalized: Record<string, string | number> = {
age: dto.age,
coverage_tier: dto.coverage_tier,
policy_type: dto.policy_type,
region: dto.region,
risk_score: dto.risk_score,
source_account: dto.source_account ?? '',
};
const keys = Object.keys(normalized).sort() as (keyof typeof normalized)[];
const sorted: Record<string, string | number> = {};
for (const k of keys) {
sorted[k] = normalized[k];
}
return JSON.stringify(sorted);
}

/** SHA-256 hex digest scoped by contract + network so environments do not collide. */
export function buildQuoteSimulationCacheKeyHash(
dto: GeneratePremiumDto,
contractId: string,
networkPassphrase: string,
): string {
const canonical = buildNormalizedQuoteCanonicalJson(dto);
return createHash('sha256')
.update(contractId)
.update('\0')
.update(networkPassphrase)
.update('\0')
.update(canonical)
.digest('hex');
}
Loading
Loading