mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-14 02:49:27 +00:00
test(amm-ui): validate new position flow #224
This commit is contained in:
parent
742c73ecae
commit
20bfbddbdc
@ -52,6 +52,11 @@ nix run .
|
|||||||
|
|
||||||
This builds and runs the application in development mode.
|
This builds and runs the application in development mode.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
New Position validation commands and acceptance criteria live in
|
||||||
|
[VALIDATION.md](VALIDATION.md).
|
||||||
|
|
||||||
## Updating Dependencies
|
## Updating Dependencies
|
||||||
|
|
||||||
To update the pinned versions of dependencies in `flake.lock`:
|
To update the pinned versions of dependencies in `flake.lock`:
|
||||||
|
|||||||
47
apps/amm/VALIDATION.md
Normal file
47
apps/amm/VALIDATION.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# AMM UI Validation
|
||||||
|
|
||||||
|
Validation for the New Position flow covers deterministic quote math, account
|
||||||
|
construction, QML checks, and the module build.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/amm/tests/new-position-validation.mjs
|
||||||
|
qmllint apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml
|
||||||
|
nix build ./apps/amm#packages.x86_64-linux.default --no-link
|
||||||
|
```
|
||||||
|
|
||||||
|
When shared AMM program types or instruction signatures change, also run the
|
||||||
|
affected Rust checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RISC0_DEV_MODE=1 cargo +1.94.0 test -p amm_program
|
||||||
|
cargo +1.94.0 build -p idl-gen --release
|
||||||
|
./target/release/idl-gen programs/amm/methods/guest/src/bin/amm.rs > /tmp/amm-idl.json
|
||||||
|
diff artifacts/amm-idl.json /tmp/amm-idl.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Live wallet/sequencer validation is manual until transaction dispatch has real
|
||||||
|
token holding IDs. Use a local devnet wallet/sequencer when available, open the
|
||||||
|
AMM UI, create or adopt one active account, preview both the active LOGOS/USDC
|
||||||
|
pool and the missing USDC/WETH pool, then submit and verify either a successful
|
||||||
|
transaction or the deterministic `submit_unavailable` backend response.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- Active wallet context exposes only holdings for the active account.
|
||||||
|
- Unsupported fee tiers are disabled and explain why.
|
||||||
|
- Active pool fee tier is fixed to the stored pool fee.
|
||||||
|
- Missing pool flow locks the user-selected price and scales from the minimum
|
||||||
|
deposit that mints more than `MINIMUM_LIQUIDITY`.
|
||||||
|
- Active pool flow keeps the reserve ratio and previews LP with
|
||||||
|
`floor(min(supply * amount_a / reserve_a, supply * amount_b / reserve_b))`.
|
||||||
|
- Decimal inference keeps small supplies at 0 decimals, medium supplies at 6,
|
||||||
|
large supplies at 9, and massive supplies at 18.
|
||||||
|
- Human amount parse/format is reversible for 0, 6, 9, and 18 decimals.
|
||||||
|
- `new_definition` and `add_liquidity` account lists match the committed AMM IDL
|
||||||
|
order.
|
||||||
|
- Submit re-quotes and surfaces `quote_changed` when the request no longer
|
||||||
|
matches the preview hash.
|
||||||
@ -35,6 +35,10 @@ namespace {
|
|||||||
// Wallet home env override. Mirrors LEZ's own var so the app shares the
|
// Wallet home env override. Mirrors LEZ's own var so the app shares the
|
||||||
// canonical wallet (~/.lee/wallet) used by the wallet UI and other apps.
|
// canonical wallet (~/.lee/wallet) used by the wallet UI and other apps.
|
||||||
const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR";
|
const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR";
|
||||||
|
constexpr double MINIMUM_LIQUIDITY = 1000.0;
|
||||||
|
constexpr double ACTIVE_POOL_USDC_RESERVE = 1'250'000.0;
|
||||||
|
constexpr double ACTIVE_POOL_LOGOS_RESERVE = 10'000'000.0;
|
||||||
|
constexpr double ACTIVE_POOL_LP_SUPPLY = 6'875'000.0;
|
||||||
|
|
||||||
// Normalise file:// URLs and OS paths to a plain local path.
|
// Normalise file:// URLs and OS paths to a plain local path.
|
||||||
QString toLocalPath(const QString& path) {
|
QString toLocalPath(const QString& path) {
|
||||||
@ -126,11 +130,11 @@ namespace {
|
|||||||
double devnetBalance(const QString& symbol)
|
double devnetBalance(const QString& symbol)
|
||||||
{
|
{
|
||||||
if (symbol == QStringLiteral("USDC"))
|
if (symbol == QStringLiteral("USDC"))
|
||||||
return 12450.0;
|
return 125000.0;
|
||||||
if (symbol == QStringLiteral("LOGOS"))
|
if (symbol == QStringLiteral("LOGOS"))
|
||||||
return 850000.0;
|
return 850000.0;
|
||||||
if (symbol == QStringLiteral("WETH"))
|
if (symbol == QStringLiteral("WETH"))
|
||||||
return 3.25;
|
return 100.0;
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,12 +197,21 @@ namespace {
|
|||||||
double activeRatio(const QString& symbolA, const QString& symbolB)
|
double activeRatio(const QString& symbolA, const QString& symbolB)
|
||||||
{
|
{
|
||||||
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("LOGOS"))
|
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("LOGOS"))
|
||||||
return 8.0;
|
return ACTIVE_POOL_LOGOS_RESERVE / ACTIVE_POOL_USDC_RESERVE;
|
||||||
if (symbolA == QStringLiteral("LOGOS") && symbolB == QStringLiteral("USDC"))
|
if (symbolA == QStringLiteral("LOGOS") && symbolB == QStringLiteral("USDC"))
|
||||||
return 0.125;
|
return ACTIVE_POOL_USDC_RESERVE / ACTIVE_POOL_LOGOS_RESERVE;
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double activePoolReserve(const QString& symbol)
|
||||||
|
{
|
||||||
|
if (symbol == QStringLiteral("USDC"))
|
||||||
|
return ACTIVE_POOL_USDC_RESERVE;
|
||||||
|
if (symbol == QStringLiteral("LOGOS"))
|
||||||
|
return ACTIVE_POOL_LOGOS_RESERVE;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
double defaultInitialPrice(const QString& symbolA, const QString& symbolB)
|
double defaultInitialPrice(const QString& symbolA, const QString& symbolB)
|
||||||
{
|
{
|
||||||
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("WETH"))
|
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("WETH"))
|
||||||
@ -309,14 +322,20 @@ namespace {
|
|||||||
changes.append(accountChange(QStringLiteral("Vault B"),
|
changes.append(accountChange(QStringLiteral("Vault B"),
|
||||||
stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenB)),
|
stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenB)),
|
||||||
missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update")));
|
missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update")));
|
||||||
|
changes.append(accountChange(QStringLiteral("LP definition"),
|
||||||
|
stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)),
|
||||||
|
missingPool ? QStringLiteral("Create") : QStringLiteral("Update")));
|
||||||
if (missingPool) {
|
if (missingPool) {
|
||||||
changes.append(accountChange(QStringLiteral("LP definition"),
|
|
||||||
stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)),
|
|
||||||
QStringLiteral("Create")));
|
|
||||||
changes.append(accountChange(QStringLiteral("LP lock holding"),
|
changes.append(accountChange(QStringLiteral("LP lock holding"),
|
||||||
stableId(QStringLiteral("devnet:lp-lock:%1").arg(poolId)),
|
stableId(QStringLiteral("devnet:lp-lock:%1").arg(poolId)),
|
||||||
QStringLiteral("Create")));
|
QStringLiteral("Create")));
|
||||||
}
|
}
|
||||||
|
changes.append(accountChange(QStringLiteral("User holding A"),
|
||||||
|
stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenA)),
|
||||||
|
QStringLiteral("Update")));
|
||||||
|
changes.append(accountChange(QStringLiteral("User holding B"),
|
||||||
|
stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenB)),
|
||||||
|
QStringLiteral("Update")));
|
||||||
changes.append(accountChange(QStringLiteral("User LP holding"),
|
changes.append(accountChange(QStringLiteral("User LP holding"),
|
||||||
stableId(QStringLiteral("devnet:user-lp:%1").arg(poolId)),
|
stableId(QStringLiteral("devnet:user-lp:%1").arg(poolId)),
|
||||||
QStringLiteral("Update or create")));
|
QStringLiteral("Update or create")));
|
||||||
@ -695,7 +714,7 @@ QString AmmUiBackend::activeAccountAddress() const
|
|||||||
QVariantMap AmmUiBackend::buildNewPositionContext() const
|
QVariantMap AmmUiBackend::buildNewPositionContext() const
|
||||||
{
|
{
|
||||||
QVariantMap context;
|
QVariantMap context;
|
||||||
context.insert(QStringLiteral("minimumLiquidity"), 1000.0);
|
context.insert(QStringLiteral("minimumLiquidity"), MINIMUM_LIQUIDITY);
|
||||||
context.insert(QStringLiteral("feeTiers"), feeTiers());
|
context.insert(QStringLiteral("feeTiers"), feeTiers());
|
||||||
|
|
||||||
QVariantMap network;
|
QVariantMap network;
|
||||||
@ -787,7 +806,11 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const
|
|||||||
const double ratio = activeRatio(tokenA, tokenB);
|
const double ratio = activeRatio(tokenA, tokenB);
|
||||||
const double amountA = editA ? inputA : inputB / ratio;
|
const double amountA = editA ? inputA : inputB / ratio;
|
||||||
const double amountB = editA ? inputA * ratio : inputB;
|
const double amountB = editA ? inputA * ratio : inputB;
|
||||||
const double expectedLp = std::floor(std::min(amountA * 5.5, amountB * 0.69));
|
const double reserveA = activePoolReserve(tokenA);
|
||||||
|
const double reserveB = activePoolReserve(tokenB);
|
||||||
|
const double expectedLp = std::floor(std::min(
|
||||||
|
ACTIVE_POOL_LP_SUPPLY * amountA / reserveA,
|
||||||
|
ACTIVE_POOL_LP_SUPPLY * amountB / reserveB));
|
||||||
const double minimumLp = std::floor(expectedLp * (10000 - slippageBps) / 10000.0);
|
const double minimumLp = std::floor(expectedLp * (10000 - slippageBps) / 10000.0);
|
||||||
|
|
||||||
if (inputA <= 0.0 && inputB <= 0.0)
|
if (inputA <= 0.0 && inputB <= 0.0)
|
||||||
@ -812,11 +835,17 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const
|
|||||||
parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) > 0.0
|
parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) > 0.0
|
||||||
? parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString())
|
? parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString())
|
||||||
: defaultInitialPrice(tokenA, tokenB);
|
: defaultInitialPrice(tokenA, tokenB);
|
||||||
const double scale = std::max(1, request.value(QStringLiteral("depositScale")).toInt());
|
const double scaleMultiplier =
|
||||||
const double amountA = price >= 1.0 ? price * scale : scale;
|
std::max(1, request.value(QStringLiteral("depositScale")).toInt());
|
||||||
const double amountB = price >= 1.0 ? scale : scale / price;
|
const double baseA = price >= 1.0 ? price : 1.0;
|
||||||
const double expectedLp = std::floor(std::sqrt(amountA * amountB) * 48.0);
|
const double baseB = price >= 1.0 ? 1.0 : 1.0 / price;
|
||||||
const double userLp = std::max(0.0, expectedLp - 1000.0);
|
const double minimumScale =
|
||||||
|
std::floor(MINIMUM_LIQUIDITY / std::sqrt(baseA * baseB)) + 1.0;
|
||||||
|
const double scale = minimumScale * scaleMultiplier;
|
||||||
|
const double amountA = baseA * scale;
|
||||||
|
const double amountB = baseB * scale;
|
||||||
|
const double initialLp = std::floor(std::sqrt(amountA * amountB));
|
||||||
|
const double userLp = std::max(0.0, initialLp - MINIMUM_LIQUIDITY);
|
||||||
const double minimumLp = std::floor(userLp * (10000 - slippageBps) / 10000.0);
|
const double minimumLp = std::floor(userLp * (10000 - slippageBps) / 10000.0);
|
||||||
|
|
||||||
if (amountA > holdingA.value(QStringLiteral("balance")).toDouble())
|
if (amountA > holdingA.value(QStringLiteral("balance")).toDouble())
|
||||||
@ -833,7 +862,7 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const
|
|||||||
position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB));
|
position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB));
|
||||||
|
|
||||||
QVariantMap quote =
|
QVariantMap quote =
|
||||||
quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, 1000.0, position);
|
quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, MINIMUM_LIQUIDITY, position);
|
||||||
QVariantMap pool = quote.value(QStringLiteral("pool")).toMap();
|
QVariantMap pool = quote.value(QStringLiteral("pool")).toMap();
|
||||||
pool.insert(QStringLiteral("priceText"),
|
pool.insert(QStringLiteral("priceText"),
|
||||||
QStringLiteral("1 %1 = %2 %3")
|
QStringLiteral("1 %1 = %2 %3")
|
||||||
|
|||||||
312
apps/amm/tests/new-position-validation.mjs
Executable file
312
apps/amm/tests/new-position-validation.mjs
Executable file
@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const MINIMUM_LIQUIDITY = 1000;
|
||||||
|
const ACTIVE_POOL_RESERVES = {
|
||||||
|
LOGOS: 10_000_000,
|
||||||
|
USDC: 1_250_000,
|
||||||
|
};
|
||||||
|
const ACTIVE_POOL_LP_SUPPLY = 6_875_000;
|
||||||
|
const SUPPORTED_FEE_TIERS = new Set([1, 5, 30, 100]);
|
||||||
|
const ROLE_TO_IDL_NAME = {
|
||||||
|
Config: 'config',
|
||||||
|
Pool: 'pool',
|
||||||
|
'Vault A': 'vault_a',
|
||||||
|
'Vault B': 'vault_b',
|
||||||
|
'LP definition': 'pool_definition_lp',
|
||||||
|
'LP lock holding': 'lp_lock_holding',
|
||||||
|
'User holding A': 'user_holding_a',
|
||||||
|
'User holding B': 'user_holding_b',
|
||||||
|
'User LP holding': 'user_holding_lp',
|
||||||
|
'Current tick': 'current_tick_account',
|
||||||
|
Clock: 'clock',
|
||||||
|
};
|
||||||
|
|
||||||
|
function stableId(key) {
|
||||||
|
return createHash('sha256').update(key).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDecimals(totalSupply) {
|
||||||
|
const supply = BigInt(totalSupply);
|
||||||
|
const digits = supply.toString().length;
|
||||||
|
|
||||||
|
if (supply < 10_000_000n)
|
||||||
|
return 0;
|
||||||
|
if (digits <= 14)
|
||||||
|
return 6;
|
||||||
|
if (digits <= 21)
|
||||||
|
return 9;
|
||||||
|
return 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHumanAmount(input, decimals) {
|
||||||
|
const text = String(input).replaceAll(',', '').trim();
|
||||||
|
assert.match(text, /^\d+(\.\d+)?$/, `invalid amount: ${input}`);
|
||||||
|
|
||||||
|
const [whole, fraction = ''] = text.split('.');
|
||||||
|
assert.ok(fraction.length <= decimals, `too many decimal places: ${input}`);
|
||||||
|
|
||||||
|
const scale = 10n ** BigInt(decimals);
|
||||||
|
const paddedFraction = fraction.padEnd(decimals, '0') || '0';
|
||||||
|
return BigInt(whole) * scale + BigInt(paddedFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBaseUnits(units, decimals) {
|
||||||
|
const value = BigInt(units);
|
||||||
|
if (decimals === 0)
|
||||||
|
return value.toString();
|
||||||
|
|
||||||
|
const scale = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / scale;
|
||||||
|
const fraction = (value % scale).toString().padStart(decimals, '0').replace(/0+$/, '');
|
||||||
|
return fraction.length > 0 ? `${whole}.${fraction}` : whole.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveAmount(value) {
|
||||||
|
const parsed = Number(String(value).replaceAll(',', '').trim());
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unorderedPairKey(symbolA, symbolB) {
|
||||||
|
return symbolA < symbolB ? `${symbolA}/${symbolB}` : `${symbolB}/${symbolA}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeRatio(symbolA, symbolB) {
|
||||||
|
if (symbolA === 'USDC' && symbolB === 'LOGOS')
|
||||||
|
return ACTIVE_POOL_RESERVES.LOGOS / ACTIVE_POOL_RESERVES.USDC;
|
||||||
|
if (symbolA === 'LOGOS' && symbolB === 'USDC')
|
||||||
|
return ACTIVE_POOL_RESERVES.USDC / ACTIVE_POOL_RESERVES.LOGOS;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultInitialPrice(symbolA, symbolB) {
|
||||||
|
if (symbolA === 'USDC' && symbolB === 'WETH')
|
||||||
|
return 2500;
|
||||||
|
if (symbolA === 'WETH' && symbolB === 'USDC')
|
||||||
|
return 0.0004;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolContext(symbolA, symbolB) {
|
||||||
|
const pairKey = unorderedPairKey(symbolA, symbolB);
|
||||||
|
const poolId = stableId(`devnet:amm-pool:${pairKey}`);
|
||||||
|
|
||||||
|
if (pairKey === 'LOGOS/USDC')
|
||||||
|
return { poolId, poolStatus: 'active_pool', instruction: 'add_liquidity', storedFeeBps: 30 };
|
||||||
|
if (pairKey === 'USDC/WETH')
|
||||||
|
return { poolId, poolStatus: 'missing_pool', instruction: 'new_definition', storedFeeBps: 0 };
|
||||||
|
return { poolId, poolStatus: 'unavailable_pool', instruction: '', storedFeeBps: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountChange(role, id, action) {
|
||||||
|
return { role, id, action };
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountChanges(request, context) {
|
||||||
|
const { tokenA, tokenB } = request;
|
||||||
|
const { poolId } = context;
|
||||||
|
const missingPool = context.poolStatus === 'missing_pool';
|
||||||
|
const changes = [
|
||||||
|
accountChange('Config', stableId('devnet:amm-config'), 'Read'),
|
||||||
|
accountChange('Pool', poolId, missingPool ? 'Create' : 'Update'),
|
||||||
|
accountChange('Vault A', stableId(`devnet:vault:${poolId}:${tokenA}`), missingPool ? 'Update or create' : 'Update'),
|
||||||
|
accountChange('Vault B', stableId(`devnet:vault:${poolId}:${tokenB}`), missingPool ? 'Update or create' : 'Update'),
|
||||||
|
accountChange('LP definition', stableId(`devnet:lp-definition:${poolId}`), missingPool ? 'Create' : 'Update'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (missingPool)
|
||||||
|
changes.push(accountChange('LP lock holding', stableId(`devnet:lp-lock:${poolId}`), 'Create'));
|
||||||
|
|
||||||
|
changes.push(
|
||||||
|
accountChange('User holding A', stableId(`devnet:user-holding:${tokenA}`), 'Update'),
|
||||||
|
accountChange('User holding B', stableId(`devnet:user-holding:${tokenB}`), 'Update'),
|
||||||
|
accountChange('User LP holding', stableId(`devnet:user-lp:${poolId}`), 'Update or create'),
|
||||||
|
accountChange('Current tick', stableId(`devnet:current-tick:${poolId}`), missingPool ? 'Create' : 'Update'),
|
||||||
|
accountChange('Clock', stableId('devnet:clock:canonical'), 'Read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteNewPosition(request) {
|
||||||
|
assert.ok(SUPPORTED_FEE_TIERS.has(request.feeBps), 'unsupported fee tier');
|
||||||
|
|
||||||
|
const context = poolContext(request.tokenA, request.tokenB);
|
||||||
|
const slippageBps = Math.max(1, Math.min(5000, request.slippageBps));
|
||||||
|
|
||||||
|
if (context.poolStatus === 'active_pool') {
|
||||||
|
assert.equal(request.feeBps, context.storedFeeBps, 'active pool fee tier mismatch');
|
||||||
|
|
||||||
|
const inputA = parsePositiveAmount(request.amountA);
|
||||||
|
const inputB = parsePositiveAmount(request.amountB);
|
||||||
|
const editA = request.editedSide !== 'B';
|
||||||
|
const ratio = activeRatio(request.tokenA, request.tokenB);
|
||||||
|
const amountA = editA ? inputA : inputB / ratio;
|
||||||
|
const amountB = editA ? inputA * ratio : inputB;
|
||||||
|
const reserveA = ACTIVE_POOL_RESERVES[request.tokenA];
|
||||||
|
const reserveB = ACTIVE_POOL_RESERVES[request.tokenB];
|
||||||
|
const expectedLp = Math.floor(Math.min(
|
||||||
|
ACTIVE_POOL_LP_SUPPLY * amountA / reserveA,
|
||||||
|
ACTIVE_POOL_LP_SUPPLY * amountB / reserveB,
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountChanges: accountChanges(request, context),
|
||||||
|
deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB },
|
||||||
|
instruction: context.instruction,
|
||||||
|
lp: {
|
||||||
|
expected: expectedLp,
|
||||||
|
locked: 0,
|
||||||
|
minimum: Math.floor(expectedLp * (10000 - slippageBps) / 10000),
|
||||||
|
},
|
||||||
|
poolStatus: context.poolStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.poolStatus === 'missing_pool') {
|
||||||
|
const parsedPrice = parsePositiveAmount(request.initialPrice);
|
||||||
|
const price = parsedPrice > 0 ? parsedPrice : defaultInitialPrice(request.tokenA, request.tokenB);
|
||||||
|
const baseA = price >= 1 ? price : 1;
|
||||||
|
const baseB = price >= 1 ? 1 : 1 / price;
|
||||||
|
const minimumScale = Math.floor(MINIMUM_LIQUIDITY / Math.sqrt(baseA * baseB)) + 1;
|
||||||
|
const scale = minimumScale * Math.max(1, request.depositScale);
|
||||||
|
const amountA = baseA * scale;
|
||||||
|
const amountB = baseB * scale;
|
||||||
|
const initialLp = Math.floor(Math.sqrt(amountA * amountB));
|
||||||
|
const userLp = Math.max(0, initialLp - MINIMUM_LIQUIDITY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountChanges: accountChanges(request, context),
|
||||||
|
deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB },
|
||||||
|
instruction: context.instruction,
|
||||||
|
lp: {
|
||||||
|
expected: userLp,
|
||||||
|
locked: MINIMUM_LIQUIDITY,
|
||||||
|
minimum: Math.floor(userLp * (10000 - slippageBps) / 10000),
|
||||||
|
},
|
||||||
|
poolStatus: context.poolStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unsupported pool: ${request.tokenA}/${request.tokenB}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function idlAccounts(instructionName) {
|
||||||
|
const idl = JSON.parse(readFileSync(new URL('../../../artifacts/amm-idl.json', import.meta.url), 'utf8'));
|
||||||
|
const instruction = idl.instructions.find((item) => item.name === instructionName);
|
||||||
|
assert.ok(instruction, `missing IDL instruction: ${instructionName}`);
|
||||||
|
return instruction.accounts.map((account) => account.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAccountOrderMatchesIdl(quote, instructionName) {
|
||||||
|
const actual = quote.accountChanges.map((change) => {
|
||||||
|
assert.ok(ROLE_TO_IDL_NAME[change.role], `unmapped account role: ${change.role}`);
|
||||||
|
return ROLE_TO_IDL_NAME[change.role];
|
||||||
|
});
|
||||||
|
assert.deepEqual(actual, idlAccounts(instructionName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDecimals() {
|
||||||
|
assert.equal(inferDecimals(9_999_999n), 0);
|
||||||
|
assert.equal(inferDecimals(1_000_000_000_000n), 6);
|
||||||
|
assert.equal(inferDecimals(1_000_000_000_000_000_000n), 9);
|
||||||
|
assert.equal(inferDecimals(1_000_000_000_000_000_000_000_000n), 18);
|
||||||
|
assert.ok([9_999_999n, 1_000_000_000_000n, 1_000_000_000_000_000_000n]
|
||||||
|
.every((supply) => inferDecimals(supply) !== 8));
|
||||||
|
|
||||||
|
for (const [amount, decimals] of [
|
||||||
|
['12345', 0],
|
||||||
|
['12.3456', 6],
|
||||||
|
['0.000000001', 9],
|
||||||
|
['1.000000000000000001', 18],
|
||||||
|
]) {
|
||||||
|
assert.equal(formatBaseUnits(parseHumanAmount(amount, decimals), decimals), amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.throws(() => parseHumanAmount('1.1', 0), /too many decimal places/);
|
||||||
|
assert.throws(() => parseHumanAmount('1.0000000001', 9), /too many decimal places/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testMissingPool() {
|
||||||
|
const quote1x = quoteNewPosition({
|
||||||
|
amountA: '',
|
||||||
|
amountB: '',
|
||||||
|
depositScale: 1,
|
||||||
|
editedSide: 'A',
|
||||||
|
feeBps: 30,
|
||||||
|
initialPrice: '2500',
|
||||||
|
slippageBps: 50,
|
||||||
|
tokenA: 'USDC',
|
||||||
|
tokenB: 'WETH',
|
||||||
|
});
|
||||||
|
assert.equal(quote1x.poolStatus, 'missing_pool');
|
||||||
|
assert.equal(quote1x.instruction, 'new_definition');
|
||||||
|
assert.equal(quote1x.deposit.maxA, 52_500);
|
||||||
|
assert.equal(quote1x.deposit.maxB, 21);
|
||||||
|
assert.equal(quote1x.lp.expected, 50);
|
||||||
|
assert.equal(quote1x.lp.locked, 1000);
|
||||||
|
assert.equal(quote1x.lp.minimum, 49);
|
||||||
|
assertAccountOrderMatchesIdl(quote1x, 'new_definition');
|
||||||
|
|
||||||
|
const quote2x = quoteNewPosition({
|
||||||
|
amountA: '',
|
||||||
|
amountB: '',
|
||||||
|
depositScale: 2,
|
||||||
|
editedSide: 'A',
|
||||||
|
feeBps: 30,
|
||||||
|
initialPrice: '2500',
|
||||||
|
slippageBps: 50,
|
||||||
|
tokenA: 'USDC',
|
||||||
|
tokenB: 'WETH',
|
||||||
|
});
|
||||||
|
assert.equal(quote2x.deposit.maxA / quote2x.deposit.maxB, 2500);
|
||||||
|
assert.equal(quote2x.deposit.maxA, 105_000);
|
||||||
|
assert.equal(quote2x.deposit.maxB, 42);
|
||||||
|
assert.equal(quote2x.lp.expected, 1100);
|
||||||
|
assert.equal(quote2x.lp.minimum, 1094);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testActivePool() {
|
||||||
|
const quoteByA = quoteNewPosition({
|
||||||
|
amountA: '100',
|
||||||
|
amountB: '',
|
||||||
|
depositScale: 1,
|
||||||
|
editedSide: 'A',
|
||||||
|
feeBps: 30,
|
||||||
|
initialPrice: '',
|
||||||
|
slippageBps: 50,
|
||||||
|
tokenA: 'USDC',
|
||||||
|
tokenB: 'LOGOS',
|
||||||
|
});
|
||||||
|
assert.equal(quoteByA.poolStatus, 'active_pool');
|
||||||
|
assert.equal(quoteByA.instruction, 'add_liquidity');
|
||||||
|
assert.equal(quoteByA.deposit.maxA, 100);
|
||||||
|
assert.equal(quoteByA.deposit.maxB, 800);
|
||||||
|
assert.equal(quoteByA.lp.expected, 550);
|
||||||
|
assert.equal(quoteByA.lp.minimum, 547);
|
||||||
|
assertAccountOrderMatchesIdl(quoteByA, 'add_liquidity');
|
||||||
|
|
||||||
|
const quoteByB = quoteNewPosition({
|
||||||
|
amountA: '',
|
||||||
|
amountB: '100',
|
||||||
|
depositScale: 1,
|
||||||
|
editedSide: 'B',
|
||||||
|
feeBps: 30,
|
||||||
|
initialPrice: '',
|
||||||
|
slippageBps: 50,
|
||||||
|
tokenA: 'LOGOS',
|
||||||
|
tokenB: 'USDC',
|
||||||
|
});
|
||||||
|
assert.equal(quoteByB.deposit.maxA, 800);
|
||||||
|
assert.equal(quoteByB.deposit.maxB, 100);
|
||||||
|
assert.equal(quoteByB.lp.expected, 550);
|
||||||
|
}
|
||||||
|
|
||||||
|
testDecimals();
|
||||||
|
testMissingPool();
|
||||||
|
testActivePool();
|
||||||
|
|
||||||
|
console.log('New Position validation harness passed');
|
||||||
Loading…
x
Reference in New Issue
Block a user