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
20 changes: 20 additions & 0 deletions docs/solver-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ Solvers must maintain a collateral bond in the Soroban `SolverRegistryContract`
- **Minimum Bond Requirement**: Solvers cannot accept high-value intents without adequate active collateral.
- **On-Chain Settlement**: Bond balances are recorded on-chain via the Soroban contract and synchronized with the backend registry.

### Bond and Exposure Checks on Acceptance
Every accept request verifies the solver's bond amount and active status against
the registry contract through a read-only Soroban simulation. A successful
result is cached for at most 30 seconds and registry bond/activity events
invalidate the cache. If the registry RPC, contract, or response is unavailable,
the API fails closed with `503`; the projected `bondAmount` in the backend
solver record is not used as a substitute.

The backend values the intent's source amount in USD using integer arithmetic
and enforces this ceiling before the state transition:

> accepted exposure in USD + new intent value in USD ≤ on-chain bond value in USD × `maxExposureRatio`

`maxExposureRatio` is the current governance parameter. Stellar bond amounts are
interpreted in 7-decimal XLM base units and valued using the configured XLM USD
price. An intent price snapshot older than five minutes, a missing/non-positive
price, or malformed amount fails closed with `503`. An intent exceeding the
remaining capacity is rejected with `403` and error code `INSUFFICIENT_BOND`.
Bond top-ups remain an on-chain operation and are outside this API flow.

### Reputation & Bond Reconciliation
A solver's active reputation score is continuously computed using completion rates and account age:
$$\text{ReputationScore} = \text{SuccessRate} \times e^{-\frac{\text{AgeInDays}}{180}}$$
Expand Down
72 changes: 72 additions & 0 deletions src/intents/intent-exposure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ServiceUnavailableException } from "@nestjs/common";
import { Intent } from "./intents.types";

/** Price snapshots older than this are not safe for collateral decisions. */
export const MAX_EXPOSURE_PRICE_AGE_SECONDS = 300;
const USD_SCALE = 1_000_000n;

/** Convert a decimal USD price to six-decimal fixed point. */
function priceToMicros(priceUsd: number): bigint {
if (!Number.isFinite(priceUsd) || priceUsd <= 0) {
throw new ServiceUnavailableException({
code: "STALE_PRICE",
error: "A valid USD price is unavailable for exposure calculation",
message: "A valid USD price is unavailable for exposure calculation",
});
}
return BigInt(Math.round(priceUsd * Number(USD_SCALE)));
}

/** Convert arbitrary token base units to micro-USD using fixed-point math. */
export function baseUnitsToUsdMicros(amount: bigint, decimals: number, priceUsd: number): bigint {
if (!Number.isInteger(decimals) || decimals < 0 || amount < 0n) {
throw new ServiceUnavailableException({
code: "INVALID_EXPOSURE_AMOUNT",
error: "Amount cannot be valued safely",
message: "Amount cannot be valued safely",
});
}
return (amount * priceToMicros(priceUsd)) / (10n ** BigInt(decimals));
}

/**
* Value an intent's source-token amount in micro-USD using its price snapshot.
* `createdAt` bounds snapshot age conservatively because token snapshots do
* not currently carry an independent oracle timestamp.
*/
export function intentExposureUsdMicros(intent: Intent, nowSeconds = Math.floor(Date.now() / 1000)): bigint {
const token = intent.srcToken;
if (
!token ||
!Number.isInteger(token.decimals) ||
token.decimals < 0 ||
nowSeconds - intent.createdAt > MAX_EXPOSURE_PRICE_AGE_SECONDS ||
intent.createdAt > nowSeconds
) {
throw new ServiceUnavailableException({
code: "STALE_PRICE",
error: "A fresh USD price is unavailable for this intent",
message: "A fresh USD price is unavailable for this intent",
});
}

let amount: bigint;
try {
amount = BigInt(intent.srcAmount);
} catch {
throw new ServiceUnavailableException({
code: "INVALID_EXPOSURE_AMOUNT",
error: "Intent amount cannot be valued safely",
message: "Intent amount cannot be valued safely",
});
}
if (amount < 0n) {
throw new ServiceUnavailableException({
code: "INVALID_EXPOSURE_AMOUNT",
error: "Intent amount cannot be valued safely",
message: "Intent amount cannot be valued safely",
});
}

return baseUnitsToUsdMicros(amount, token.decimals, token.priceUSD ?? 0);
}
Loading
Loading