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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,23 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s
- Husky `commit-msg` hook — runs commitlint on every local commit (Closes #137)
- CI job `commitlint` — validates commit messages on every push/PR in GitHub Actions
(Closes #137)
- `src/common/amount.ts` — shared, unit-tested base-units ↔ decimal conversion plus
the protocol fee (0.05 %) and quote-variance helpers; `IntentsController.quote()`
and `fill()` now use it instead of duplicated inline `BigInt`/decimal math
(Closes #272)
- `PATCH /api/v1/solvers/:address` (`UpdateSolverDto`, `buildUpdateSolverMessage`) —
signature-verified partial update of a solver's mutable profile fields
(`name`, `supportedChains`, `supportedTokens`, `avgFillTime`); immutable fields
are stripped by the DTO whitelist (Closes #273)
- Typed Swagger response documentation for every `SorobanController` and
`TokensController` route, including the account route's 400/429 responses
(Closes #271)

### Fixed
- `IntentsService.create()` idempotency-key handling is now race-safe — concurrent
requests carrying the same key synchronously claim an in-flight slot before any
`await`, so exactly one intent is created and the losers replay its result
(Closes #274)
- `TokensModule` was missing `exports: [TokensService]` — `IntentsController`
could not inject `TokensService` outside the Jest test environment
- `IntentsModule` was missing `exports: [IntentsGateway]` — `StatsService`
Expand Down
36 changes: 36 additions & 0 deletions docs/solver-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ register:<solverAddress>

---

## 1a. Updating Your Solver Profile

Once registered, a solver operator can edit their **mutable** profile fields as
their operation scales — for example adding a newly-supported chain or fixing a
typo in the display name — without re-registering.

### HTTP Request
`PATCH /api/v1/solvers/:address`

- **Authentication**: same proof-of-control convention as registration. Sign the
UTF-8 bytes of the message `update-solver:G...` (the `:address` path segment)
with the solver's Stellar secret key and send the base64 signature in the body.
- **Editable fields**: `name`, `supportedChains`, `supportedTokens`, `avgFillTime`
(all optional — send only what changes). Arrays are replaced wholesale.
- **Immutable fields** (`address`, `bondAmount`, `fillsCompleted`, `fillsFailed`,
`totalVolume`, `registeredAt`, `isActive`) are silently stripped by the
request validator; sending them is a no-op, not an error. Bond changes are an
on-chain concern — see section 2.

**Payload (`UpdateSolverDto`)**:
```json
{
"name": "Alpha-Liquidity-Solver",
"supportedChains": ["stellar", "ethereum", "polygon", "arbitrum", "base"],
"supportedTokens": ["USDC", "XLM", "ETH", "WBTC"],
"avgFillTime": 38,
"signature": "base64EncodedSignatureString=="
}
```

**Responses**: `200 OK` with the updated solver record; `400` for an invalid
body (e.g. an unsupported chain or a missing signature); `401` for a bad or
mismatched signature; `404` when `:address` is not a registered solver.

---

## 2. Bond Posting & On-Chain Enforcement

### Posting a Bond
Expand Down
162 changes: 162 additions & 0 deletions src/common/amount.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
BPS_DENOMINATOR,
PROTOCOL_FEE_BPS,
VARIANCE_SCALE,
assertValidDecimals,
applyVarianceScale,
calculateProtocolFee,
parseBaseUnits,
toBaseUnits,
toDecimalNumber,
varianceScaleFromPerfScore,
} from "./amount";

describe("common/amount", () => {
describe("constants", () => {
it("encodes the documented 0.05% protocol fee", () => {
expect(PROTOCOL_FEE_BPS).toBe(5n);
expect(BPS_DENOMINATOR).toBe(10_000n);
expect(VARIANCE_SCALE).toBe(1_000n);
});
});

describe("assertValidDecimals", () => {
it.each([0, 6, 7, 18, 36])("accepts %s", (d) => {
expect(() => assertValidDecimals(d)).not.toThrow();
});

it.each([-1, 1.5, 37, NaN, Infinity])("rejects %s", (d) => {
expect(() => assertValidDecimals(d)).toThrow(RangeError);
});
});

describe("parseBaseUnits", () => {
it("passes through a non-negative bigint", () => {
expect(parseBaseUnits(42n)).toBe(42n);
});

it("parses a digit string, tolerating surrounding whitespace", () => {
expect(parseBaseUnits(" 1000000 ")).toBe(1_000_000n);
});

it("parses amounts far beyond Number.MAX_SAFE_INTEGER without loss", () => {
const huge = "123456789012345678901234567890";
expect(parseBaseUnits(huge)).toBe(BigInt(huge));
});

it.each(["-1", "1.5", "0x10", "", "abc", "1e3"])("rejects %j", (v) => {
expect(() => parseBaseUnits(v)).toThrow(RangeError);
});

it("rejects a negative bigint", () => {
expect(() => parseBaseUnits(-1n)).toThrow(RangeError);
});
});

describe("toDecimalNumber", () => {
it("scales by the given decimals", () => {
expect(toDecimalNumber("1000000", 6)).toBe(1);
expect(toDecimalNumber("1500000", 6)).toBe(1.5);
expect(toDecimalNumber("1", 7)).toBe(0.0000001);
});

it("handles zero decimals as an identity", () => {
expect(toDecimalNumber("123", 0)).toBe(123);
});

it("handles a zero amount for any decimals", () => {
for (let d = 0; d <= 18; d++) {
expect(toDecimalNumber("0", d)).toBe(0);
}
});

it("accepts a bigint input", () => {
expect(toDecimalNumber(2_500_000n, 6)).toBe(2.5);
});

it("stays precise for very large amounts where Number division would drift", () => {
// 10^30 base units at 18 decimals = 10^12 whole units, exactly representable.
const baseUnits = "1" + "0".repeat(30);
expect(toDecimalNumber(baseUnits, 18)).toBe(1e12);
});

it("is the inverse of toBaseUnits for representable values", () => {
for (const [amount, decimals] of [
[1, 6],
[1234.56, 2],
[0.0000001, 7],
[999999.999999, 6],
] as const) {
expect(toDecimalNumber(toBaseUnits(amount, decimals), decimals)).toBeCloseTo(amount, decimals);
}
});

it("rejects invalid decimals", () => {
expect(() => toDecimalNumber("1", -1)).toThrow(RangeError);
});
});

describe("toBaseUnits", () => {
it("scales up by the given decimals", () => {
expect(toBaseUnits(1, 6)).toBe("1000000");
expect(toBaseUnits(1.5, 6)).toBe("1500000");
expect(toBaseUnits(0, 18)).toBe("0");
});

it("truncates sub-unit precision rather than rounding", () => {
expect(toBaseUnits(1.2345678, 6)).toBe("1234567");
});

it("rejects negative or non-finite amounts", () => {
expect(() => toBaseUnits(-1, 6)).toThrow(RangeError);
expect(() => toBaseUnits(Infinity, 6)).toThrow(RangeError);
});
});

describe("calculateProtocolFee", () => {
it("takes 0.05% of the destination amount, floored", () => {
expect(calculateProtocolFee("1000000")).toBe(500n); // 0.05% of 1_000_000
expect(calculateProtocolFee(0n)).toBe(0n);
expect(calculateProtocolFee("19999")).toBe(9n); // 9.9995 -> 9
});

it("matches the inline formula for a large bigint amount", () => {
const dst = 987654321987654321987654321n;
expect(calculateProtocolFee(dst)).toBe((dst * 5n) / 10_000n);
});
});

describe("varianceScaleFromPerfScore", () => {
it("applies no haircut for a perfect score", () => {
expect(varianceScaleFromPerfScore(1)).toBe(1000);
});

it("applies the maximum 0.8% haircut for a zero score", () => {
expect(varianceScaleFromPerfScore(0)).toBe(992);
});

it("clamps out-of-range scores", () => {
expect(varianceScaleFromPerfScore(5)).toBe(1000);
expect(varianceScaleFromPerfScore(-5)).toBe(992);
});

it("reproduces the original inline computation", () => {
for (const perfScore of [0.1, 0.25, 0.5, 0.73, 0.9]) {
const expected = Math.round(1000 * (1 - (1 - perfScore) * 0.008));
expect(varianceScaleFromPerfScore(perfScore)).toBe(expected);
}
});
});

describe("applyVarianceScale", () => {
it("scales the source amount by scale / VARIANCE_SCALE in bigint", () => {
expect(applyVarianceScale("1000000", 1000)).toBe(1_000_000n);
expect(applyVarianceScale("1000000", 992)).toBe(992_000n);
});

it("matches the inline formula for a large bigint amount", () => {
const src = 123456789012345678901234567890n;
expect(applyVarianceScale(src, 997)).toBe((src * 997n) / 1000n);
});
});
});
160 changes: 160 additions & 0 deletions src/common/amount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Shared helpers for base-unit ↔ decimal amount conversion and the protocol's
* fee / quote-variance arithmetic.
*
* Why this module exists
* ----------------------
* `IntentsController.quote()` and `fill()` each grew their own inline copy of
* `BigInt` arithmetic and `Number(x) / Math.pow(10, decimals)` scaling.
* `CHANGELOG.md` already records one *"Precision loss in quote calculation for
* large bigint amounts"* bug — exactly the class of defect that duplicated,
* ad-hoc bigint-and-decimals math tends to reintroduce. Consolidating the
* conversions here gives every caller a single, unit-tested source of truth.
*
* Precision contract
* ------------------
* All arithmetic is performed in `BigInt`. The **only** place a value is
* converted to `Number` is the final display-scaling step in
* {@link toDecimalNumber}, and even there the integer and fractional parts are
* split as strings first so large amounts do not lose precision before scaling.
*/

/** Basis-points denominator: 1 bp = 0.01 %, so 10 000 bp = 100 %. */
export const BPS_DENOMINATOR = 10_000n;

/** Protocol fee, expressed in basis points: 0.05 % = 5 bp. */
export const PROTOCOL_FEE_BPS = 5n;

/**
* Fixed-point scale used when weighting a quote's destination amount by a
* solver's performance variance (see {@link varianceScaleFromPerfScore}).
*/
export const VARIANCE_SCALE = 1_000n;

/**
* Largest `decimals` value we accept. Well beyond any real token (18 is the
* practical maximum) while still guarding against absurd input.
*/
const MAX_DECIMALS = 36;

/**
* Validate that `decimals` is a non-negative integer within a sane range.
*
* @throws {RangeError} when `decimals` is not an integer in `[0, 36]`.
*/
export function assertValidDecimals(decimals: number): void {
if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {
throw new RangeError(
`decimals must be an integer in [0, ${MAX_DECIMALS}], received ${decimals}`,
);
}
}

/**
* Parse a non-negative integer base-unit amount into a `BigInt`.
*
* Accepts either a `BigInt` (returned as-is after a sign check) or a decimal
* string of ASCII digits. Leading/trailing whitespace is tolerated.
*
* @throws {RangeError} when the value is not a non-negative integer.
*/
export function parseBaseUnits(value: string | bigint): bigint {
if (typeof value === "bigint") {
if (value < 0n) {
throw new RangeError(`base-unit amount must be non-negative, received ${value}`);
}
return value;
}
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
throw new RangeError(`invalid base-unit amount: ${JSON.stringify(value)}`);
}
return BigInt(trimmed);
}

/**
* Convert a base-unit integer amount to a human-scaled decimal `Number`.
*
* Equivalent to `Number(baseUnits) / 10 ** decimals` but precision-safe for
* amounts above `Number.MAX_SAFE_INTEGER`: the whole and fractional parts are
* assembled as a decimal string and parsed once, so the only rounding is the
* unavoidable `string → Number` step.
*
* @param baseUnits Non-negative integer amount in the token's smallest unit.
* @param decimals Number of decimal places the token uses (e.g. `6` for USDC).
*/
export function toDecimalNumber(baseUnits: string | bigint, decimals: number): number {
assertValidDecimals(decimals);
const units = parseBaseUnits(baseUnits);
if (decimals === 0) {
return Number(units);
}
const divisor = 10n ** BigInt(decimals);
const whole = units / divisor;
const fraction = (units % divisor).toString().padStart(decimals, "0").replace(/0+$/, "");
return Number(fraction ? `${whole}.${fraction}` : whole.toString());
}

/**
* Convert a human-scaled decimal `Number` back to a base-unit integer string.
*
* The inverse of {@link toDecimalNumber}. Any precision in `amount` beyond
* `decimals` places is truncated (not rounded), matching how on-chain token
* transfers treat sub-unit dust.
*
* @throws {RangeError} when `amount` is negative or not finite.
*/
export function toBaseUnits(amount: number, decimals: number): string {
assertValidDecimals(decimals);
if (!Number.isFinite(amount) || amount < 0) {
throw new RangeError(`amount must be a non-negative finite number, received ${amount}`);
}
// `toFixed` expands any exponential notation and gives us a fixed-point string
// with one extra digit, which we then truncate to `decimals` places.
const fixed = amount.toFixed(decimals + 1);
const [whole, fractionRaw = ""] = fixed.split(".");
const fraction = fractionRaw.slice(0, decimals).padEnd(decimals, "0");
const combined = `${whole}${fraction}`.replace(/^0+(?=\d)/, "");
return BigInt(combined).toString();
}

/**
* Calculate the protocol fee for a destination amount.
*
* `fee = dstAmount * PROTOCOL_FEE_BPS / BPS_DENOMINATOR` (0.05 %), floored to
* an integer number of base units via `BigInt` division.
*
* @param dstAmount Destination amount in base units.
* @returns The fee in base units.
*/
export function calculateProtocolFee(dstAmount: string | bigint): bigint {
return (parseBaseUnits(dstAmount) * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR;
}

/**
* Derive the integer variance scale (out of {@link VARIANCE_SCALE}) applied to
* a quote's destination amount, given a solver's `[0, 1]` performance score.
*
* A perfect score (`1`) yields no haircut (`VARIANCE_SCALE`); a zero score
* yields the maximum 0.8 % haircut. `perfScore` is clamped to `[0, 1]`.
*/
export function varianceScaleFromPerfScore(perfScore: number): number {
const clamped = Math.min(Math.max(perfScore, 0), 1);
const variancePct = (1 - clamped) * 0.008;
return Math.round(Number(VARIANCE_SCALE) * (1 - variancePct));
}

/**
* Apply an integer variance scale (see {@link varianceScaleFromPerfScore}) to a
* source amount, entirely in `BigInt`:
* `dstAmount = srcAmount * varianceScale / VARIANCE_SCALE`.
*
* @param srcAmount Source amount in base units.
* @param varianceScale Integer scale, typically `0 … VARIANCE_SCALE`.
*/
export function applyVarianceScale(
srcAmount: string | bigint,
varianceScale: number,
): bigint {
return (parseBaseUnits(srcAmount) * BigInt(varianceScale)) / VARIANCE_SCALE;
}
Loading