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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ All routes are namespaced under `/api`.
| GET | `/api/vaults/:id/projection` | Yield projection (`?amount=&days=`) |
| GET | `/api/analytics` | Aggregate TVL and average APY |
| GET | `/api/analytics/tvl-history` | Mock protocol TVL series (`?days=`) |
| GET | `/api/vaults/:id/deposit-preview` | Canonical deposit/share quote (`?amount=`) |
| POST | `/api/positions/deposit` | Deposit assets into a vault |
| POST | `/api/positions/withdraw` | Redeem shares from a vault |
| GET | `/api/positions?user=` | List positions, optionally filtered by user |
Expand Down Expand Up @@ -133,6 +134,13 @@ yield engine grows `totalAssets` over time based on the vault APY while shares
stay constant, so every position appreciates automatically. Accrual is applied
lazily whenever a vault or position is read.

## Amount and rounding policy

Asset and share amounts use six decimal places (`0.000001`) and round to
nearest at that precision. Inputs above `1e12` or with smaller units are
rejected. Deposit previews and execution share the same conversion helper and
return the policy metadata so clients can explain boundary results.

## Project structure

```
Expand Down
176 changes: 176 additions & 0 deletions docs/PRECISION_POLICY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# YieldVault amount and fee policy

This document defines the canonical arithmetic used by deposit previews,
deposits, withdrawals, fee quotes, and API responses. The policy is kept in
`src/utils/math.js` and is deliberately shared by every state-changing path.

## Supported units

| Rule | Value | Behavior |
| --- | --- | --- |
| Decimal places | 6 | Values are represented to six fractional digits. |
| Minimum unit | `0.000001` | Smaller values are rejected. |
| Maximum amount | `1e12` | Larger values are rejected before mutation. |
| Rounding | nearest | Only supported values are normalized. |
| Empty-vault price | `1` | First deposits mint one share per asset. |

An amount is valid only when it is finite, non-negative, and within the maximum.
Mutation inputs such as a deposit amount or withdrawal share count must also be
strictly positive. Read-side totals may be zero when a vault is empty.

## Why a shared helper matters

Preview and execution must agree at boundary values. If a preview rounds one
way and execution rounds another way, clients can display a share count that is
not executable or a withdrawal amount that silently loses value. The quote
helpers return both the result and the policy metadata so this agreement is
visible to clients and testable without inspecting implementation details.

The execution flow is:

1. Validate the user amount as a finite supported value.
2. Synchronize the vault's accrued assets.
3. Calculate shares or assets through the canonical conversion helper.
4. Submit the mocked invocation with the normalized input.
5. Mutate totals using the same normalized result.
6. Return the conversion and policy metadata.

No provider call occurs before steps one through three succeed. This prevents
unsupported precision from creating an external transaction that cannot be
represented in local state.

## Conversion equations

For a non-empty vault:

```text
shares = round(assets * totalShares / totalAssets)
assets = round(shares * totalAssets / totalShares)
pricePerShare = round(totalAssets / totalShares)
```

The first deposit into an empty vault uses a one-to-one ratio. A vault with no
shares returns zero for a share-to-asset conversion because no ownership claim
exists. These rules avoid division by zero and make empty-state behavior
explicit for clients.

## Boundary behavior

- Zero is valid for stored totals and read-side calculations.
- Zero is invalid for a deposit or withdrawal request.
- Negative values are always rejected.
- Non-finite values are rejected, including `NaN`, `Infinity`, and strings.
- Values with more than six fractional digits are rejected, not truncated.
- Amounts above `1e12` are rejected before a transaction is submitted.
- A conversion result is rounded to the same six-decimal precision.

Rejecting extra precision is intentional. Silent truncation can understate a
deposit, overstate a withdrawal, or cause preview and execution to disagree.
Applications that need finer units must first make a versioned protocol change.

## Fee policy

Fees are supplied in basis points. The denominator is 10,000 and the maximum
fee is 10,000 basis points (100%). A fee above that maximum is an input error;
it is not clamped. Management fees are prorated by days and performance fees
apply only to positive profit. Losses do not create a performance fee or a
negative net profit.

Fee helpers use the same six-decimal rounder as conversion helpers. This keeps
fee quotes stable at common boundary values and avoids floating-point dust in
the API.

## API behavior

`GET /api/vaults/:id/deposit-preview?amount=` returns a quote without changing
state. Deposit and withdrawal responses include a `conversion` object. The
object contains the normalized input, result, price per share, and:

```json
{
"decimalPlaces": 6,
"minimumUnit": 0.000001,
"rounding": "nearest"
}
```

Clients should show the normalized values returned by the server rather than
recomputing with local floating-point rules. Unsupported values receive the
normal `400` error contract before the mock transaction is created.

## Compatibility

Existing clients sending supported amounts continue to receive the same share
and asset values. The new preview route and `conversion` response field are
additive. Clients that ignore unknown fields remain compatible. A future change
to decimal places, rounding, or maximum amount must be versioned and documented
because it can change value calculations.

## Testing and operations

The policy suite covers empty vaults, one-to-one conversions, yield ratios,
round trips, smallest units, maximums, unsupported precision, fee caps, and
serialization. Operators should record the policy metadata with any financial
reconciliation so a later policy version can be distinguished from historical
calculations.

If a rollout needs to be reversed, revert the additive preview and metadata
changes together with the helper policy change. Stored values use the existing
six-decimal representation, so no data migration is required for rollback.

## Review checklist

Reviewers should confirm the following for every amount-bearing change:

- Inputs are normalized before any external invocation.
- Empty-vault and zero-share behavior is explicit.
- The same helper is used by preview and execution.
- Conversion results are rounded exactly once at the boundary.
- Fees cannot exceed the basis-point cap.
- Negative, non-finite, and over-maximum values fail closed.
- Tests include the minimum unit and the maximum amount.
- Tests compare preview output with persisted execution state.
- API documentation names the precision and rounding mode.
- Rollback does not require rewriting stored positions.

These checks are especially important for changes to share accounting because a
small arithmetic difference can compound across many deposits and withdrawals.
The policy metadata is therefore part of the review surface, not merely a UI
hint.

## Example reconciliation record

An operator comparing a preview with execution can retain the following
fields:

```json
{
"vaultId": "vault_example",
"input": 125.5,
"quotedShares": 100.4,
"executedShares": 100.4,
"pricePerShare": 1.25,
"decimalPlaces": 6,
"rounding": "nearest"
}
```

If the quoted and executed values differ for identical vault totals, the
operation should be treated as a correctness incident and investigated with
the transaction and request identifiers. The server-side quote helper is the
source of truth for the comparison.

The same reconciliation applies to partial withdrawals: compare normalized
shares, returned assets, and the post-operation vault totals. Never infer the
result from a client-side floating-point calculation.

For incident review, record the vault totals before the operation, the policy
metadata returned by the quote, the normalized request, the transaction hash,
and the totals after execution. This makes a mismatch reproducible and keeps
the remediation focused on a specific policy boundary.

This record is sufficient for reconciliation without exposing wallet secrets or
internal implementation details.

The reconciliation record should be retained alongside the normal transaction
receipt and is safe to share with maintainers during review.
9 changes: 9 additions & 0 deletions src/controllers/vaultController.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ function getVaultProjection(req, res) {
res.json({ projection });
}

function getDepositPreview(req, res) {
const preview = positionService.previewDeposit({
vaultId: req.params.id,
amount: Number(req.query.amount),
});
res.json({ preview });
}

module.exports = {
listVaults,
getTopVaults,
Expand All @@ -57,4 +65,5 @@ module.exports = {
getVaultApyHistory,
getVaultStats,
getVaultProjection,
getDepositPreview,
};
3 changes: 3 additions & 0 deletions src/routes/vaultRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ router.get('/', asyncHandler(vaultController.listVaults));
// Registered before /:id so the literal path is not treated as an id.
router.get('/top', asyncHandler(vaultController.getTopVaults));

// GET /api/vaults/:id/deposit-preview?amount= - canonical share quote
router.get('/:id/deposit-preview', asyncHandler(vaultController.getDepositPreview));

// GET /api/vaults/:id - vault detail
router.get('/:id', asyncHandler(vaultController.getVault));

Expand Down
38 changes: 31 additions & 7 deletions src/services/positionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ const store = require('../store');
const { badRequest, notFound } = require('../utils/errors');
const { newPositionId } = require('../utils/ids');
const {
assetsToShares,
sharesToAssets,
quoteAssetsToShares,
quoteSharesToAssets,
round,
} = require('../utils/math');
const vaultService = require('./vaultService');
Expand Down Expand Up @@ -42,7 +42,14 @@ function serialize(position) {

function deposit({ user, vaultId, amount }) {
const vault = vaultService.getVaultRecord(vaultId);
const shares = assetsToShares(amount, vault.totalAssets, vault.totalShares);
let conversion;
try {
conversion = quoteAssetsToShares(amount, vault.totalAssets, vault.totalShares);
} catch (error) {
throw badRequest(error.message);
}
const shares = conversion.shares;
amount = conversion.assets;

const tx = stellarService.submitInvocation('deposit', { user, vaultId, amount });
store.transactions.set(tx.txHash, { ...tx, user, vaultId, amount });
Expand Down Expand Up @@ -73,7 +80,7 @@ function deposit({ user, vaultId, amount }) {
store.positions.set(position.id, position);
}

return { position: serialize(position), tx };
return { position: serialize(position), tx, conversion };
}

function withdraw({ user, vaultId, shares }) {
Expand All @@ -92,7 +99,14 @@ function withdraw({ user, vaultId, shares }) {
});
}

const assets = sharesToAssets(shares, vault.totalAssets, vault.totalShares);
let conversion;
try {
conversion = quoteSharesToAssets(shares, vault.totalAssets, vault.totalShares);
} catch (error) {
throw badRequest(error.message);
}
shares = conversion.shares;
const assets = conversion.assets;
const tx = stellarService.submitInvocation('withdraw', { user, vaultId, shares });
store.transactions.set(tx.txHash, { ...tx, user, vaultId, shares, assets });

Expand All @@ -110,10 +124,19 @@ function withdraw({ user, vaultId, shares }) {

if (position.shares <= 0) {
store.positions.delete(position.id);
return { withdrawnAssets: assets, tx, position: null };
return { withdrawnAssets: assets, tx, position: null, conversion };
}

return { withdrawnAssets: assets, tx, position: serialize(position) };
return { withdrawnAssets: assets, tx, position: serialize(position), conversion };
}

function previewDeposit({ vaultId, amount }) {
const vault = vaultService.getVaultRecord(vaultId);
try {
return { vaultId, ...quoteAssetsToShares(amount, vault.totalAssets, vault.totalShares) };
} catch (error) {
throw badRequest(error.message);
}
}

function getPosition(id) {
Expand Down Expand Up @@ -165,6 +188,7 @@ function getUserSummary(user) {
module.exports = {
serialize,
deposit,
previewDeposit,
withdraw,
getPosition,
listPositions,
Expand Down
5 changes: 5 additions & 0 deletions src/utils/fees.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { round } = require('./math');
*/

const BPS_DENOMINATOR = 10000;
const MAX_FEE_BPS = BPS_DENOMINATOR;

/**
* Convert a basis-point value into a decimal rate (e.g. 50 bps -> 0.005).
Expand All @@ -17,6 +18,9 @@ function bpsToRate(bps) {
if (!Number.isFinite(bps) || bps <= 0) {
return 0;
}
if (bps > MAX_FEE_BPS) {
throw new RangeError(`fee cannot exceed ${MAX_FEE_BPS} basis points`);
}
return bps / BPS_DENOMINATOR;
}

Expand Down Expand Up @@ -50,6 +54,7 @@ function netProfit(profit, bps) {

module.exports = {
BPS_DENOMINATOR,
MAX_FEE_BPS,
bpsToRate,
managementFee,
performanceFee,
Expand Down
Loading