From 240fe7b1c8176d23f1218c4d4c926012080c8b14 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 1 Jun 2026 11:43:34 +0100 Subject: [PATCH 1/2] test: add fuzzing suite for i128 amount decoding to ensure BigInt precision consistency --- package-lock.json | 41 +++++++++++++++ tests/decoder.amount.fuzz.test.ts | 86 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/decoder.amount.fuzz.test.ts diff --git a/package-lock.json b/package-lock.json index d13e2d39..40ab9eec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/node": "^20.11.24", "@types/supertest": "^7.2.0", "@types/ws": "^8.18.1", + "fast-check": "^3.22.0", "jest": "^30.3.0", "prisma": "^5.10.0", "supertest": "^7.2.2", @@ -3437,6 +3438,46 @@ "express": ">= 4.11" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", diff --git a/tests/decoder.amount.fuzz.test.ts b/tests/decoder.amount.fuzz.test.ts new file mode 100644 index 00000000..3e3c8889 --- /dev/null +++ b/tests/decoder.amount.fuzz.test.ts @@ -0,0 +1,86 @@ +import fc from "fast-check"; +import { xdr, nativeToScVal, scValToNative } from "@stellar/stellar-sdk"; +import { parseEvent } from "../src/decoder"; +import type { RawEvent } from "../src/rpc"; +import * as fixtures from "../src/__tests__/fixtures/events.json"; + +const I128_MAX = 170141183460469231731687303715884105727n; +const I128_MIN = -170141183460469231731687303715884105728n; + +// JavaScript Number loses integer precision beyond ±2^53-1. +// Any i128 value outside this band will silently corrupt if handled as a number. +const JS_MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); // 9_007_199_254_740_991n +const JS_MIN_SAFE = -BigInt(Number.MAX_SAFE_INTEGER); // -9_007_199_254_740_991n + +const aliceScVal = xdr.ScVal.fromXDR(fixtures.transfer.topic[1], "base64"); +const bobScVal = xdr.ScVal.fromXDR(fixtures.transfer.topic[2], "base64"); + +const baseEvent = { + ledger: 1, + ledgerClosedAt: "2024-01-01T00:00:00Z", + contractId: fixtures.contractId, + txHash: "fuzz000000000000000000000000000000000000000000000000000000000001", + id: "0000000000000000001-00001", + type: "contract", +} as const; + +function makeTransferEvent(amountScVal: xdr.ScVal): RawEvent { + return { + ...baseEvent, + topic: [xdr.ScVal.scvSymbol("transfer"), aliceScVal, bobScVal], + value: amountScVal, + }; +} + +// Generator: full i128 range, with 2× weight on the unsafe-for-Number band so +// precision bugs surface quickly without excluding the in-range sub-domain. +const arbI128 = fc.oneof( + { arbitrary: fc.bigInt({ min: JS_MAX_SAFE + 1n, max: I128_MAX }), weight: 2 }, + { arbitrary: fc.bigInt({ min: I128_MIN, max: JS_MIN_SAFE - 1n }), weight: 2 }, + { arbitrary: fc.bigInt({ min: I128_MIN, max: I128_MAX }), weight: 1 }, +); + +describe("decoder amount fuzz – BigInt-only precision", () => { + it("stellar-sdk scValToNative returns bigint (not number) for i128 ScVals", () => { + // Verify the SDK invariant that decodeI128 relies on. If this ever starts + // returning a number, every amount outside the safe-integer range silently + // loses precision before we even reach the decoder. + const sentinels = [ + 0n, + 1n, + -1n, + JS_MAX_SAFE, + JS_MAX_SAFE + 1n, + JS_MIN_SAFE, + JS_MIN_SAFE - 1n, + I128_MAX, + I128_MIN, + ]; + for (const v of sentinels) { + const native = scValToNative(nativeToScVal(v, { type: "i128" })); + expect(typeof native).toBe("bigint"); + } + }); + + it("10 000 i128 inputs decode with no precision loss", () => { + fc.assert( + fc.property(arbI128, (original) => { + const amountScVal = nativeToScVal(original, { type: "i128" }); + const raw = makeTransferEvent(amountScVal); + const result = parseEvent(raw); + + // Decoder must recognise the transfer event + expect(result).not.toBeNull(); + + // Decoder encodes amount as a decimal string for storage + expect(typeof result!.amount).toBe("string"); + + // No precision loss: BigInt round-trip must be exact. + // If the decoder had fallen through to the `number` path, values + // outside ±2^53-1 would stringify with rounding, and this would fail. + expect(BigInt(result!.amount)).toBe(original); + }), + { numRuns: 10_000 }, + ); + }); +}); From 730958b69ed92c749d74749ca2471e0f274f5081 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 1 Jun 2026 12:19:50 +0100 Subject: [PATCH 2/2] feat: add scheduled and manual k6 load testing workflow for transfers --- .github/workflows/load.yml | 57 +++++++++++++++++++++++++ tests/load/transfers.k6.js | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 .github/workflows/load.yml create mode 100644 tests/load/transfers.k6.js diff --git a/.github/workflows/load.yml b/.github/workflows/load.yml new file mode 100644 index 00000000..a61b3c76 --- /dev/null +++ b/.github/workflows/load.yml @@ -0,0 +1,57 @@ +name: Load test + +on: + # Weekly scheduled run against staging — Mondays at 06:00 UTC. + schedule: + - cron: "0 6 * * 1" + # Allow manual runs with optional overrides. + workflow_dispatch: + inputs: + base_url: + description: "Staging base URL to test against (overrides STAGING_BASE_URL)" + required: false + default: "" + rps: + description: "Target requests per second" + required: false + default: "1000" + duration: + description: "Test duration (e.g. 2m, 30s)" + required: false + default: "2m" + +jobs: + load: + name: k6 load test (transfers) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Install k6 + uses: grafana/setup-k6-action@v1 + + - name: Resolve target URL + id: target + env: + INPUT_URL: ${{ github.event.inputs.base_url }} + STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }} + run: | + URL="${INPUT_URL:-$STAGING_BASE_URL}" + if [[ -z "$URL" ]]; then + echo "::error::No staging URL configured. Set the STAGING_BASE_URL secret or pass base_url via workflow_dispatch." + exit 1 + fi + echo "base_url=${URL}" >> "$GITHUB_OUTPUT" + + - name: Run k6 load test + uses: grafana/run-k6-action@v1 + with: + path: tests/load/transfers.k6.js + env: + BASE_URL: ${{ steps.target.outputs.base_url }} + # Optional: a staging account with realistic transfer volume. + ADDRESS: ${{ secrets.LOAD_TEST_ADDRESS }} + RPS: ${{ github.event.inputs.rps || '1000' }} + DURATION: ${{ github.event.inputs.duration || '2m' }} diff --git a/tests/load/transfers.k6.js b/tests/load/transfers.k6.js new file mode 100644 index 00000000..a3fd0c9d --- /dev/null +++ b/tests/load/transfers.k6.js @@ -0,0 +1,86 @@ +// k6 load test for the headline transfers endpoint. +// +// Goal (SLO): sustain 1,000 RPS with p95 latency < 100 ms against a staging +// deployment. +// +// Run locally: +// k6 run tests/load/transfers.k6.js +// +// Override the target and load shape via env vars: +// k6 run \ +// -e BASE_URL=https://wraith-staging.example.com \ +// -e ADDRESS=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF \ +// -e RPS=1000 \ +// -e DURATION=2m \ +// tests/load/transfers.k6.js +// +// A lighter smoke run (handy on a laptop with no staging target): +// k6 run -e RPS=20 -e DURATION=10s -e BASE_URL=http://localhost:3000 \ +// tests/load/transfers.k6.js + +import http from "k6/http"; +import { check } from "k6"; +import { Rate } from "k6/metrics"; + +// ── Configuration (all overridable via -e) ─────────────────────────────────── +const BASE_URL = (__ENV.BASE_URL || "http://localhost:3000").replace(/\/$/, ""); + +// A representative address to query. Defaults to a well-known testnet account so +// the script runs without extra config; override with -e ADDRESS=... to point +// at an account with realistic transfer volume on staging. +const ADDRESS = + __ENV.ADDRESS || + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +const RPS = parseInt(__ENV.RPS || "1000", 10); +const DURATION = __ENV.DURATION || "2m"; +const PAGE_LIMIT = parseInt(__ENV.LIMIT || "50", 10); + +// Pre-allocate enough VUs to drive the target rate even if individual requests +// approach the latency budget. Allow k6 to scale up under contention. +const PRE_ALLOCATED_VUS = parseInt(__ENV.PRE_VUS || "200", 10); +const MAX_VUS = parseInt(__ENV.MAX_VUS || "1000", 10); + +// ── Custom metrics ──────────────────────────────────────────────────────────── +const errorRate = new Rate("errors"); + +// ── Options ───────────────────────────────────────────────────────────────── +export const options = { + scenarios: { + transfers_constant_rps: { + executor: "constant-arrival-rate", + rate: RPS, + timeUnit: "1s", + duration: DURATION, + preAllocatedVUs: PRE_ALLOCATED_VUS, + maxVUs: MAX_VUS, + }, + }, + // Hard SLO gate: the run fails (non-zero exit) if these are breached. + thresholds: { + http_req_duration: ["p(95)<100"], + http_req_failed: ["rate<0.01"], + errors: ["rate<0.01"], + }, +}; + +// ── Test body ───────────────────────────────────────────────────────────────── +export default function () { + const url = `${BASE_URL}/transfers/address/${ADDRESS}?limit=${PAGE_LIMIT}`; + const res = http.get(url, { + tags: { name: "GET /transfers/address/:address" }, + }); + + const ok = check(res, { + "status is 200": (r) => r.status === 200, + "body has transfers array": (r) => { + try { + return Array.isArray(r.json("transfers")); + } catch (_e) { + return false; + } + }, + }); + + errorRate.add(!ok); +}