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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,10 @@ HEALTH_READY_SUCCESS_THRESHOLD=2
HEALTH_EVENT_LOOP_MAX_LAG_MS=1000
# Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL).
SOROBAN_RPC_HEALTH_URLS=
# Fee rules and integrator referrals (issue #438). JSON arrays; empty uses the built-in 5 bps default.
FEE_RULES_JSON=[]
FEE_REFERRALS_JSON=[]

# Low-frequency scan that catches deadline jobs the queue did not run (issue #437).
SAFETY_SWEEP_INTERVAL_MS=300000

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s
## [Unreleased]

### Added
- Protocol fee engine with pair > chain > default rules, volume tiers, integrator referral share, and a double-entry fee ledger. Quotes include the fee split and rule version. Treasury stats read the ledger once it has postings (Closes #438).
- Deadline jobs `expire-intent` and `fill-window-expired` replace the 30s sweeper poll. A safety sweep (`SAFETY_SWEEP_INTERVAL_MS`, default 5 min) catches lost jobs and increments `vortex_sweeper_safety_caught_total` (Closes #437).
- `scripts/generate-client.ts` — generates a typed TypeScript API client from the live
OpenAPI spec using `openapi-typescript` v7; output committed to `src/generated/`
Expand Down
26 changes: 26 additions & 0 deletions docs/adr/0006-fee-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ADR 0006: Protocol fee engine and ledger

- **Status**: Accepted
- **Date**: 2026-09-29
- **Technical Story**: #438 — configurable fees, tiers, referral share, double-entry ledger

## Context

`feeAmount` was a flat 5 bps truncated division at fill time, and again inside quoting. There was no rule version, no integrator share, and no ledger that could be reconciled.

## Decision

`src/fees/` quotes a fee from versioned rules. Precedence is specific pair, then source chain, then the built-in default (5 bps, the previous rate). A rule may set basis points, a min and max in base units, and volume tiers selected by trade size (or a caller-supplied cumulative volume).

The charged fee is `ceil(amount * bps / 10_000)`, then clamped. Ceil is at most one base unit above truncating division. Caps may move the fee further; that difference is the cap. Integrator share is a floor of the fee, so the remainder stays with the treasury. An unknown referral code quotes no integrator share.

A fill posts the quote of the fill amount: debit the user, credit the treasury, and credit the integrator when the share is non-zero. The ledger refuses a batch unless the sum of debits equals the sum of credits. The same function produces the quote and the realized fee, so they match when the amount, chains, tokens, and referral match.

`GET` treasury stats keep the intent `feeAmount` totals until the ledger has postings, then report the ledger totals. On-chain fee collection is unchanged. Rules and referrals are `FEE_RULES_JSON` and `FEE_REFERRALS_JSON`.

The durable table is `fee_ledger`. The running service posts to an in-memory ledger with the same shape (the same pattern as the other default `memory` adapters).

## Consequences

- Quote responses gain `treasuryFee`, `integratorFee`, `feeRuleVersion`, and `referralCode`.
- Fills of amounts that do not divide evenly by the bps denominator cost one extra base unit versus the old truncated fee.
63 changes: 63 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/** @type {import('jest').Config} */

// The single definition of the coverage gate. It is enforced on the *merged*
// shard report by scripts/ci/coverage-merge.mjs, not by individual shard runs
// (issue #486), which pass --coverageThreshold '{}' because a shard only ever
// executes part of the suite.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: "src",
testRegex: ".*\\.spec\\.ts$",
collectCoverageFrom: ["**/*.(t|j)s"],
coverageDirectory: "../coverage",
// text-summary keeps local runs readable, lcov feeds editors, and json is what
// coverage-merge.mjs consumes.
coverageReporters: ["text-summary", "lcov", "json"],
// Run both the main NestJS unit suite and the scripts suite under one command.
projects: [
// ── Main NestJS unit suite ──────────────────────────────────────────────
{
displayName: "src",
preset: "ts-jest",
testEnvironment: "node",
rootDir: "src",
testRegex: ".*\\.spec\\.ts$",
// Exclude the scripts sub-suite so tests aren't picked up twice.
testPathIgnorePatterns: ["/scripts/"],
collectCoverageFrom: ["**/*.(t|j)s"],
moduleNameMapper: {
"^@nestjs/schedule$": "<rootDir>/../test/__mocks__/nestjs-schedule.ts",
},
},

// ── Scripts suite (ledger-utils, etc.) ─────────────────────────────────
// Tests live in src/scripts/ but import from scripts/ (outside src/).
// A dedicated tsconfig with broader rootDir handles the path.
{
displayName: "scripts",
testEnvironment: "node",
rootDir: ".",
testMatch: ["<rootDir>/src/scripts/**/*.spec.ts"],
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: "./tsconfig.scripts.json",
},
],
},
},
],

// Coverage is collected from the project-level collectCoverageFrom above.
coverageDirectory: "coverage",
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
17 changes: 17 additions & 0 deletions prisma/migrations/20260929120000_fee_ledger/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Fee ledger (issue #438). Application code rejects a batch unless debits equal credits.
CREATE TABLE "fee_ledger" (
"id" TEXT NOT NULL,
"intent_id" TEXT NOT NULL,
"rule_id" TEXT NOT NULL,
"rule_version" INTEGER NOT NULL,
"side" TEXT NOT NULL,
"account" TEXT NOT NULL,
"account_id" TEXT NOT NULL,
"amount" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,

CONSTRAINT "fee_ledger_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "fee_ledger_intent_id_idx" ON "fee_ledger"("intent_id");
CREATE INDEX "fee_ledger_account_created_at_idx" ON "fee_ledger"("account", "created_at");
Loading
Loading