diff --git a/README.md b/README.md index d48c47b..8bcfc9c 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 ``` diff --git a/docs/PRECISION_POLICY.md b/docs/PRECISION_POLICY.md new file mode 100644 index 0000000..db8ad82 --- /dev/null +++ b/docs/PRECISION_POLICY.md @@ -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. diff --git a/src/controllers/vaultController.js b/src/controllers/vaultController.js index 60be9d7..13aad02 100644 --- a/src/controllers/vaultController.js +++ b/src/controllers/vaultController.js @@ -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, @@ -57,4 +65,5 @@ module.exports = { getVaultApyHistory, getVaultStats, getVaultProjection, + getDepositPreview, }; diff --git a/src/routes/vaultRoutes.js b/src/routes/vaultRoutes.js index 48c3547..7574a17 100644 --- a/src/routes/vaultRoutes.js +++ b/src/routes/vaultRoutes.js @@ -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)); diff --git a/src/services/positionService.js b/src/services/positionService.js index 88a7bfa..9693e2f 100644 --- a/src/services/positionService.js +++ b/src/services/positionService.js @@ -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'); @@ -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 }); @@ -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 }) { @@ -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 }); @@ -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) { @@ -165,6 +188,7 @@ function getUserSummary(user) { module.exports = { serialize, deposit, + previewDeposit, withdraw, getPosition, listPositions, diff --git a/src/utils/fees.js b/src/utils/fees.js index 2bf1e23..abf689b 100644 --- a/src/utils/fees.js +++ b/src/utils/fees.js @@ -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). @@ -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; } @@ -50,6 +54,7 @@ function netProfit(profit, bps) { module.exports = { BPS_DENOMINATOR, + MAX_FEE_BPS, bpsToRate, managementFee, performanceFee, diff --git a/src/utils/math.js b/src/utils/math.js index a34a421..1b85f4c 100644 --- a/src/utils/math.js +++ b/src/utils/math.js @@ -11,17 +11,52 @@ * The very first deposit into an empty vault mints shares 1:1 with assets. */ -// Precision used to avoid floating point dust when rounding. +// All user-facing amounts use six decimal places. Keeping the policy here +// means previews, validation, and execution cannot silently choose different +// rounding rules. const PRECISION = 1e6; +const DECIMAL_PLACES = 6; +const MIN_UNIT = 1 / PRECISION; +const MAX_SUPPORTED_AMOUNT = 1e12; function round(value) { return Math.round(value * PRECISION) / PRECISION; } +/** Normalize an amount under the protocol's canonical precision policy. */ +function canonicalizeAmount(value, { allowZero = true } = {}) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new RangeError('amount must be a finite number'); + } + if (value < 0 || (!allowZero && value === 0)) { + throw new RangeError('amount must be greater than zero'); + } + if (value > MAX_SUPPORTED_AMOUNT) { + throw new RangeError(`amount must not exceed ${MAX_SUPPORTED_AMOUNT}`); + } + + const normalized = round(value); + if (Math.abs(normalized - value) > Number.EPSILON * Math.max(1, Math.abs(value))) { + throw new RangeError(`amount supports at most ${DECIMAL_PLACES} decimal places`); + } + return normalized; +} + +function conversionMetadata() { + return { + decimalPlaces: DECIMAL_PLACES, + minimumUnit: MIN_UNIT, + rounding: 'nearest', + }; +} + /** * Convert an asset amount into shares given current vault totals. */ function assetsToShares(assets, totalAssets, totalShares) { + assets = canonicalizeAmount(assets, { allowZero: false }); + totalAssets = canonicalizeAmount(totalAssets); + totalShares = canonicalizeAmount(totalShares); if (totalShares === 0 || totalAssets === 0) { return round(assets); } @@ -32,6 +67,9 @@ function assetsToShares(assets, totalAssets, totalShares) { * Convert a share amount into the underlying asset amount. */ function sharesToAssets(shares, totalAssets, totalShares) { + shares = canonicalizeAmount(shares, { allowZero: false }); + totalAssets = canonicalizeAmount(totalAssets); + totalShares = canonicalizeAmount(totalShares); if (totalShares === 0) { return 0; } @@ -42,16 +80,45 @@ function sharesToAssets(shares, totalAssets, totalShares) { * Price of a single share expressed in underlying assets. */ function pricePerShare(totalAssets, totalShares) { + totalAssets = canonicalizeAmount(totalAssets); + totalShares = canonicalizeAmount(totalShares); if (totalShares === 0) { return 1; } return round(totalAssets / totalShares); } +function quoteAssetsToShares(assets, totalAssets, totalShares) { + const normalizedAssets = canonicalizeAmount(assets, { allowZero: false }); + return { + assets: normalizedAssets, + shares: assetsToShares(normalizedAssets, totalAssets, totalShares), + pricePerShare: pricePerShare(totalAssets, totalShares), + policy: conversionMetadata(), + }; +} + +function quoteSharesToAssets(shares, totalAssets, totalShares) { + const normalizedShares = canonicalizeAmount(shares, { allowZero: false }); + return { + shares: normalizedShares, + assets: sharesToAssets(normalizedShares, totalAssets, totalShares), + pricePerShare: pricePerShare(totalAssets, totalShares), + policy: conversionMetadata(), + }; +} + module.exports = { PRECISION, + DECIMAL_PLACES, + MIN_UNIT, + MAX_SUPPORTED_AMOUNT, round, + canonicalizeAmount, + conversionMetadata, assetsToShares, sharesToAssets, pricePerShare, + quoteAssetsToShares, + quoteSharesToAssets, }; diff --git a/test/fees.test.js b/test/fees.test.js index 471e246..cd414a5 100644 --- a/test/fees.test.js +++ b/test/fees.test.js @@ -17,6 +17,10 @@ test('bpsToRate converts basis points to a decimal rate', () => { assert.equal(bpsToRate(-5), 0); }); +test('bpsToRate rejects fees above the 100 percent cap', () => { + assert.throws(() => bpsToRate(10001), /cannot exceed/); +}); + test('managementFee pro-rates the annual fee for a period', () => { // 200 bps (2%) on 1000 over a full year is 20. assert.equal(managementFee(1000, 200, 365), 20); diff --git a/test/math.test.js b/test/math.test.js index 9fdd906..f14ffa0 100644 --- a/test/math.test.js +++ b/test/math.test.js @@ -8,6 +8,8 @@ const { assetsToShares, sharesToAssets, pricePerShare, + canonicalizeAmount, + quoteAssetsToShares, } = require('../src/utils/math'); test('round trims floating point dust to six decimals', () => { @@ -39,3 +41,20 @@ test('pricePerShare defaults to 1 for an empty vault', () => { assert.equal(pricePerShare(0, 0), 1); assert.equal(pricePerShare(1500, 1000), 1.5); }); + +test('canonicalizeAmount rejects unsupported precision and unsafe ranges', () => { + assert.equal(canonicalizeAmount(1.123456), 1.123456); + assert.throws(() => canonicalizeAmount(1.1234567), /at most 6 decimal places/); + assert.throws(() => canonicalizeAmount(1e12 + 1), /must not exceed/); +}); + +test('conversion quote exposes the same canonical result used by execution', () => { + const quote = quoteAssetsToShares(200, 2000, 1000); + assert.equal(quote.assets, 200); + assert.equal(quote.shares, 100); + assert.deepEqual(quote.policy, { + decimalPlaces: 6, + minimumUnit: 0.000001, + rounding: 'nearest', + }); +}); diff --git a/test/precisionPolicy.test.js b/test/precisionPolicy.test.js new file mode 100644 index 0000000..f7f4788 --- /dev/null +++ b/test/precisionPolicy.test.js @@ -0,0 +1,171 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DECIMAL_PLACES, + MIN_UNIT, + MAX_SUPPORTED_AMOUNT, + canonicalizeAmount, + conversionMetadata, + assetsToShares, + sharesToAssets, + quoteAssetsToShares, + quoteSharesToAssets, +} = require('../src/utils/math'); +const { bpsToRate, managementFee, performanceFee, netProfit } = require('../src/utils/fees'); + +test('the policy constants describe one supported unit', () => { + assert.equal(DECIMAL_PLACES, 6); + assert.equal(MIN_UNIT, 0.000001); + assert.equal(MAX_SUPPORTED_AMOUNT, 1e12); + assert.deepEqual(conversionMetadata(), { + decimalPlaces: 6, + minimumUnit: 0.000001, + rounding: 'nearest', + }); +}); + +test('canonicalizeAmount accepts supported whole and fractional amounts', () => { + const values = [0, 1, 1.1, 1.123456, 1000.000001, 1e12]; + for (const value of values) assert.equal(canonicalizeAmount(value), value); +}); + +test('canonicalizeAmount rejects non-number inputs', () => { + for (const value of [undefined, null, '1', {}, [], NaN, Infinity, -Infinity]) { + assert.throws(() => canonicalizeAmount(value), /finite number/); + } +}); + +test('canonicalizeAmount rejects negative and forbidden zero values', () => { + assert.throws(() => canonicalizeAmount(-1), /greater than zero/); + assert.throws(() => canonicalizeAmount(0, { allowZero: false }), /greater than zero/); + assert.equal(canonicalizeAmount(0), 0); +}); + +test('canonicalizeAmount rejects values larger than the supported maximum', () => { + assert.throws(() => canonicalizeAmount(MAX_SUPPORTED_AMOUNT + 1), /must not exceed/); + assert.throws(() => canonicalizeAmount(Number.MAX_SAFE_INTEGER), /must not exceed/); +}); + +test('canonicalizeAmount rejects values below the minimum unit', () => { + assert.throws(() => canonicalizeAmount(0.0000001), /at most 6 decimal places/); + assert.throws(() => canonicalizeAmount(10.0000009), /at most 6 decimal places/); +}); + +test('asset to share conversion is deterministic at one-to-one price', () => { + for (const amount of [0.000001, 1, 10.5, 1000000.123456]) { + assert.equal(assetsToShares(amount, 1000, 1000), amount); + } +}); + +test('asset to share conversion scales down when the vault price rises', () => { + assert.equal(assetsToShares(100, 2000, 1000), 50); + assert.equal(assetsToShares(0.000001, 2000, 1000), 0.000001); +}); + +test('asset to share conversion uses one-to-one for an empty vault', () => { + assert.equal(assetsToShares(123.456789, 0, 0), 123.456789); + assert.equal(assetsToShares(123.456789, 0, 100), 123.456789); +}); + +test('share to asset conversion is deterministic at one-to-one price', () => { + for (const shares of [0.000001, 1, 10.5, 1000000.123456]) { + assert.equal(sharesToAssets(shares, 1000, 1000), shares); + } +}); + +test('share to asset conversion scales up when yield accrues', () => { + assert.equal(sharesToAssets(50, 2000, 1000), 100); + assert.equal(sharesToAssets(0.000001, 2000, 1000), 0.000002); +}); + +test('share conversion has a safe empty-share result', () => { + assert.equal(sharesToAssets(1, 1000, 0), 0); + assert.equal(sharesToAssets(0.000001, 0, 0), 0); +}); + +test('asset and share round trips stay within the canonical unit', () => { + const cases = [ + [1, 1000, 1000], + [12.345678, 1000, 800], + [999999.999999, 5000000, 2500000], + [0.000001, 1, 3], + ]; + for (const [amount, totalAssets, totalShares] of cases) { + const shares = assetsToShares(amount, totalAssets, totalShares); + const recovered = sharesToAssets(shares, totalAssets, totalShares); + assert.ok(Math.abs(recovered - amount) <= 2 * MIN_UNIT); + } +}); + +test('asset quote carries the normalized input, result and policy', () => { + const quote = quoteAssetsToShares(125.5, 1000, 800); + assert.equal(quote.assets, 125.5); + assert.equal(quote.shares, 100.4); + assert.equal(quote.pricePerShare, 1.25); + assert.equal(quote.policy.decimalPlaces, DECIMAL_PLACES); +}); + +test('share quote carries the normalized input, result and policy', () => { + const quote = quoteSharesToAssets(100.4, 1000, 800); + assert.equal(quote.shares, 100.4); + assert.equal(quote.assets, 125.5); + assert.equal(quote.pricePerShare, 1.25); + assert.equal(quote.policy.rounding, 'nearest'); +}); + +test('quotes reject unsupported precision before calculating a result', () => { + assert.throws(() => quoteAssetsToShares(1.0000001, 1000, 1000), /at most 6/); + assert.throws(() => quoteSharesToAssets(1.0000001, 1000, 1000), /at most 6/); +}); + +test('quotes reject unsafe maximums before state mutation can occur', () => { + assert.throws(() => quoteAssetsToShares(1e12 + 1, 1000, 1000), /must not exceed/); + assert.throws(() => quoteSharesToAssets(1e12 + 1, 1000, 1000), /must not exceed/); +}); + +test('fee rates allow zero through the maximum supported basis points', () => { + assert.equal(bpsToRate(0), 0); + assert.equal(bpsToRate(10000), 1); + assert.throws(() => bpsToRate(10001), /cannot exceed/); +}); + +test('management fees preserve the same amount precision policy', () => { + assert.equal(managementFee(1000.123456, 200, 365), 20.002469); + assert.equal(managementFee(1000, 200, 0), 0); + assert.equal(managementFee(1000, 200, -1), 0); +}); + +test('performance and net profit never turn a loss into a fee', () => { + assert.equal(performanceFee(-1, 1000), 0); + assert.equal(performanceFee(0, 1000), 0); + assert.equal(netProfit(-1, 1000), 0); + assert.equal(netProfit(0, 1000), 0); +}); + +test('performance fee is bounded by the configured fee cap', () => { + assert.equal(performanceFee(100, 10000), 100); + assert.throws(() => performanceFee(100, 10001), /cannot exceed/); +}); + +test('policy metadata is safe to serialize for API clients', () => { + const encoded = JSON.stringify(conversionMetadata()); + assert.equal(encoded, '{"decimalPlaces":6,"minimumUnit":0.000001,"rounding":"nearest"}'); + assert.deepEqual(JSON.parse(encoded), conversionMetadata()); +}); + +test('repeated quotes are byte-equivalent for identical vault state', () => { + const first = JSON.stringify(quoteAssetsToShares(42.123456, 1000, 777)); + const second = JSON.stringify(quoteAssetsToShares(42.123456, 1000, 777)); + assert.equal(first, second); +}); + +test('changing the vault price changes both quote value and metadata consistently', () => { + const low = quoteAssetsToShares(100, 1000, 1000); + const high = quoteAssetsToShares(100, 2000, 1000); + assert.equal(low.shares, 100); + assert.equal(high.shares, 50); + assert.deepEqual(low.policy, high.policy); +});