diff --git a/apps/amm/README.md b/apps/amm/README.md index eb52c2d5..232ec853 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -215,16 +215,16 @@ wallet/runbook display them) or hex — the app normalizes both to hex. The Pools view is config-driven the same way: it reads a flat JSON list from the `AMM_POOLS_CONFIG` environment variable (absolute path) and renders one row per -entry. `tokenA`/`tokenB` are the display symbols and `feeBps` the fee tier; +entry. `tokenA`/`tokenB` are the display symbols; `poolId`/`tokenADefinitionId`/`tokenBDefinitionId` identify the pool on-chain. -Adding more pairs is purely a config edit — no app change: +The swap fee is not a pool field — it is instance-wide (`AmmConfig.swapFeeBps`), +read from the config. Adding more pairs is purely a config edit — no app change: ```json [ { "tokenA": "TKA", "tokenB": "TKB", - "feeBps": 1, "poolId": "9qbX…", "tokenADefinitionId": "4T69…", "tokenBDefinitionId": "7Zc2…" @@ -239,9 +239,9 @@ cp apps/amm/amm-pools.json.example apps/amm/amm-pools.json # then replace the ``` If `AMM_POOLS_CONFIG` is unset, unreadable, or not a valid JSON array, the Pools -list shows its empty state. Entries missing `tokenA`, `tokenB`, or a numeric -`feeBps` are skipped individually. The AMM testnet setup script writes this file -for the pool(s) it seeds (see below). +list shows its empty state. Entries missing `tokenA` or `tokenB` are skipped +individually. The AMM testnet setup script writes this file for the pool(s) it +seeds (see below). The **Pool** tab in the nav bar is a dropdown with two entries. *Create pool* opens the new-position / add-liquidity form. *View positions* lists the wallet's @@ -250,7 +250,7 @@ definition id cannot be reversed back to its pool, so the app resolves every pool in this config and matches each pool's `lpDefinitionId` against the wallet's token holdings. A pool that is not in the config therefore cannot appear, however many LP tokens the wallet holds for it. Each row shows the pair, -fee tier, the wallet's claim on both reserves (`reserve × lpBalance / lpSupply`, +the instance swap fee, the wallet's claim on both reserves (`reserve × lpBalance / lpSupply`, floored like the program's own payout), and its share of the pool. The list needs an open wallet. @@ -265,7 +265,7 @@ holds two LP accounts for one pool; a burn names a single account, so the sheet draws on the largest and says so when the position spans more than one. Clicking a row in the Pools list opens the pool detail view, which reads the live pool through -`resolvePoolAccount` and shows the reserve split, spot price, fee tier, LP +`resolvePoolAccount` and shows the reserve split, spot price, the instance swap fee, LP supply, an estimate of the fees accrued into the reserves, and the pool's account ids. Its **Swap** and **Add liquidity** buttons switch tabs with the pair preselected. Both the detail view and the preselection need diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md index ad30b642..4620df45 100644 --- a/apps/amm/VALIDATION.md +++ b/apps/amm/VALIDATION.md @@ -40,8 +40,8 @@ transaction ID is displayed. ## Acceptance Checklist - Context exposes Wallet-Scoped Holdings and configured token definitions. -- Unsupported fee tiers are disabled and explain why. -- Active pool fee tier is fixed to the stored pool fee. +- No fee selection in the form — the swap fee is instance-wide (set at + `initialize`, stored in the AMM config) and applies to every pool. - Missing pool flow accepts an editable `X Token A = Y Token B` ratio and scales either deposit from the minimum that mints more than `MINIMUM_LIQUIDITY`. diff --git a/apps/amm/amm-pools.json.example b/apps/amm/amm-pools.json.example index 004d0cb4..bac32288 100644 --- a/apps/amm/amm-pools.json.example +++ b/apps/amm/amm-pools.json.example @@ -2,7 +2,6 @@ { "tokenA": "TKA", "tokenB": "TKB", - "feeBps": 1, "poolId": "REPLACE_WITH_POOL_PDA", "tokenADefinitionId": "REPLACE_WITH_TOKEN_A_DEFINITION_ID", "tokenBDefinitionId": "REPLACE_WITH_TOKEN_B_DEFINITION_ID" diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml index e836e31a..82b95ac6 100644 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -24,12 +24,6 @@ ColumnLayout { value: root.actionText() } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Fee") - value: root.snapshot.feeText || "-" - } - SummaryRow { Layout.fillWidth: true label: qsTr("Deposit") diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index 42e29343..02300a1a 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -38,7 +38,6 @@ AmmActionCard { ? String(root.activePoolQuote.lpDefinitionId || "") : "" property string selectedTokenAId: "" property string selectedTokenBId: "" - property int selectedFeeBps: 30 property int slippageBps: 50 property string amountA: "" property string amountB: "" @@ -85,13 +84,6 @@ AmmActionCard { // Whether the wallet session is ready (from the flow); gates funding/selection like the // old context "ready"/"no_wallet" status did, minus the network envelope. property bool walletReady: false - // Supported fee tiers as raw bps, injected from backend.feeTiers() (amm_core's - // SUPPORTED_FEE_TIERS). The selector's delegate wants { feeBps } rows, so wrap - // each int; labels are derived locally via feeLabel(). - property var feeTiers: [] - readonly property var feeTierModel: (root.feeTiers || []).map(function(bps) { - return { "feeBps": Number(bps) } - }) readonly property var tokenA: root.tokenById(root.selectedTokenAId) readonly property var tokenB: root.tokenById(root.selectedTokenBId) readonly property int decimalsA: 0 @@ -111,7 +103,6 @@ AmmActionCard { // undefined ⇒ not resolved yet (neither branch shown). readonly property bool activePool: root.flowState.poolExists === true readonly property bool missingPool: root.flowState.poolExists === false - readonly property int poolFeeBps: root.knownPoolFeeBps() readonly property bool compact: root.width < 420 readonly property bool hasPair: root.selectedTokenAId.length > 0 && root.selectedTokenBId.length > 0 @@ -388,87 +379,6 @@ AmmActionCard { color: root.theme.colors.divider } - ColumnLayout { - Layout.fillWidth: true - spacing: 8 - visible: !root.contextLoading - - Text { - text: qsTr("Fee tier") - color: root.theme.colors.textPrimary - font.pixelSize: 13 - font.weight: Font.Medium - } - - GridLayout { - Layout.fillWidth: true - columns: root.compact ? 2 : 4 - columnSpacing: 8 - rowSpacing: 8 - - Repeater { - model: root.feeTierModel - - Item { - id: feeTierOption - - required property var modelData - readonly property string disabledReason: root.feeDisabledReason(modelData) - readonly property bool invalid: root.fieldHasError("feeBps") - && feeTierButton.checked - Layout.fillWidth: true - implicitHeight: 40 - - Button { - id: feeTierButton - - anchors.fill: parent - text: parent.modelData.label || root.feeLabel(parent.modelData.feeBps) - checkable: true - checked: root.selectedFeeBps === parent.modelData.feeBps - enabled: parent.disabledReason.length === 0 && !root.submitting - onClicked: root.selectFee(parent.modelData.feeBps) - - contentItem: Text { - text: feeTierButton.text - color: feeTierButton.enabled - ? root.theme.colors.textPrimary - : root.theme.colors.textPlaceholder - font.pixelSize: 12 - font.weight: Font.Medium - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - radius: 6 - color: feeTierButton.checked - ? root.theme.colors.selection - : root.theme.colors.inputBg - border.color: feeTierOption.invalid - ? root.theme.colors.error - : feeTierButton.checked - ? root.theme.colors.ctaBg - : root.theme.colors.borderStrong - border.width: 1 - } - } - - MouseArea { - id: disabledFeeHover - anchors.fill: parent - enabled: parent.disabledReason.length > 0 - hoverEnabled: true - acceptedButtons: Qt.NoButton - } - - ToolTip.visible: disabledFeeHover.containsMouse - ToolTip.text: disabledReason - } - } - } - } - RowLayout { Layout.fillWidth: true spacing: 10 @@ -851,7 +761,6 @@ AmmActionCard { function resetAll() { root.selectedTokenAId = "" root.selectedTokenBId = "" - root.selectedFeeBps = 30 root.slippageBps = 50 root.resolvingTokenId = "" root.resolvingTokenSide = "" @@ -896,30 +805,6 @@ AmmActionCard { } } - function knownPoolFeeBps() { - var direct = root.feeBpsFromQuote(root.quotePayload) - if (direct > 0) - return direct - if (root.quoteMatchesSelectedPair(root.activePoolQuote)) - return Number(root.activePoolQuote.poolFeeBps || 0) - return 0 - } - - function feeBpsFromQuote(quote) { - if (!root.quoteMatchesSelectedPair(quote)) - return 0 - var direct = Number(quote.poolFeeBps || 0) - if (direct > 0) - return direct - var errors = quote.errors || [] - for (var i = 0; i < errors.length; ++i) { - var value = Number(errors[i].details ? errors[i].details.poolFeeBps : 0) - if (value > 0) - return value - } - return 0 - } - function quoteMatchesPair() { return root.quoteMatchesSelectedPair(root.quotePayload) } @@ -932,33 +817,6 @@ AmmActionCard { || (tokenAId === root.selectedTokenBId && tokenBId === root.selectedTokenAId)) } - function selectFee(feeBps) { - root.selectedFeeBps = feeBps - root.noteDraftChanged() - root.requestQuote(true) - } - - function feeDisabledReason(tier) { - if (tier.enabled === false) - return tier.disabledReason || qsTr("This fee tier is unavailable.") - if (root.poolFeeBps > 0 && Number(tier.feeBps) !== root.poolFeeBps) - return qsTr("Existing pool uses %1. Fee tier is fixed for this pair.") - .arg(root.feeLabel(root.poolFeeBps)) - return "" - } - - function feeLabel(feeBps) { - if (feeBps === 1) - return "0.01%" - if (feeBps === 5) - return "0.05%" - if (feeBps === 30) - return "0.30%" - if (feeBps === 100) - return "1.00%" - return root.formatBps(feeBps) - } - function buildQuoteRequest() { var errors = [] if (!root.hasPair) { @@ -1063,8 +921,7 @@ AmmActionCard { "tokenAId": root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId, "tokenBId": root.displayIsCanonical - ? root.selectedTokenBId : root.selectedTokenAId, - "feeBps": root.selectedFeeBps + ? root.selectedTokenBId : root.selectedTokenAId } } @@ -1205,7 +1062,6 @@ AmmActionCard { "deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."), "minimum_lp_zero": qsTr("Slippage leaves no minimum LP output."), "invalid_slippage": qsTr("Slippage must be between 0% and 50%."), - "fee_tier_mismatch": qsTr("Select the existing pool fee tier."), "no_wallet": qsTr("Connect a wallet to submit this position."), "wallet_unavailable": qsTr("Wallet is unavailable."), "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."), @@ -1327,16 +1183,6 @@ AmmActionCard { function applyQuoteSideEffects() { if (root.quoteStale) return - if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { - root.selectedFeeBps = root.poolFeeBps - root.localErrors = [] - root.quoteRequested(true, { - "ok": true, - "errors": [], - "request": root.poolProbeRequest(root.pairRequest()) - }) - return - } if (root.quotePayload.status !== "ok") return @@ -1455,7 +1301,6 @@ AmmActionCard { // taken from the active-pool quote; ignored by the create path. "minLp": String(root.quotePayload.minimumLp || ""), "pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)), - "feeText": root.feeLabel(root.selectedFeeBps), "depositAText": root.quoteAmount("actualAmountA", "actualAmountB", "A"), "depositBText": root.quoteAmount("actualAmountA", "actualAmountB", "B"), "expectedLpText": root.rawLpText(root.quotePayload.expectedLp), diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 454c5201..547abe82 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -23,11 +23,6 @@ Item { // account selectors; refetched when the wallet opens. property var holdings: [] - // The AMM's supported fee tiers (backend.feeTiers()) feeding the fee selector. - // Program-derived and wallet-independent, so it's fetched once when the backend - // becomes available. - property var feeTiers: [] - // The liquidity token selector rows (backend.resolveTokens()): the app-owned union of // configured tokens and persisted-custom tokens. Refetched when the wallet opens/closes // (holdingId/balance change) and after a custom token is added. @@ -96,14 +91,6 @@ Item { function(err) { console.warn("tokenHoldings error:", err) }) } - function refreshFeeTiers() { - if (!root.backend || root.runtime === null || root.feeTiers.length > 0) - return - root.runtime.watch(root.backend.feeTiers(), - function(list) { root.feeTiers = list }, - function(err) { console.warn("feeTiers error:", err) }) - } - function refreshTokens() { if (!root.backend || root.runtime === null) return @@ -149,9 +136,9 @@ Item { }) } -onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } - onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } - Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() } +onBackendChanged: { root.refreshHoldings(); root.refreshTokens() } + onRuntimeChanged: { root.refreshHoldings(); root.refreshTokens() } + Component.onCompleted: { root.refreshHoldings(); root.refreshTokens() } Connections { target: root.backend @@ -356,10 +343,9 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refresh headingText: form.hasPair ? qsTr("Deposit tokens") : qsTr("Select pair") headingDetail: form.hasPair ? qsTr("Specify the token amounts for your liquidity contribution.") - : qsTr("Choose two tokens and a fee tier for this position.") + : qsTr("Choose two tokens for this position.") showRefreshAction: false holdings: root.holdings - feeTiers: root.feeTiers tokens: root.resolvedTokens loadingTokens: root.tokensLoading walletReady: newPositionFlow.walletStateReady diff --git a/apps/amm/qml/pages/PoolsPage.qml b/apps/amm/qml/pages/PoolsPage.qml index ca469332..9dce9d07 100644 --- a/apps/amm/qml/pages/PoolsPage.qml +++ b/apps/amm/qml/pages/PoolsPage.qml @@ -30,6 +30,10 @@ Item { // backend is ready and the call resolves. property var pools: [] + // The swap fee is instance-wide (AmmConfig.swapFeeBps), not per pool, so it is read once + // from the config and applied to every row. -1 until loaded / if the config is unavailable. + property int swapFeeBps: -1 + function loadPools() { if (!root.backend || !root.runtime) return @@ -38,13 +42,24 @@ Item { function(err) { console.warn("poolList error:", err) }) } - onBackendChanged: root.loadPools() - onRuntimeChanged: root.loadPools() + function loadSwapFee() { + if (!root.backend || !root.runtime) + return + root.runtime.watch(root.backend.configAccount(), + function(res) { + root.swapFeeBps = (res && res.status === "ok" && res.swapFeeBps !== undefined) + ? Number(res.swapFeeBps) : -1 + }, + function(err) { console.warn("configAccount error:", err) }) + } + + onBackendChanged: { root.loadPools(); root.loadSwapFee() } + onRuntimeChanged: { root.loadPools(); root.loadSwapFee() } Connections { target: root.backend // Re-fetch when the registry snapshot refreshes (e.g. a remote list lands). - function onRegistryRevisionChanged() { root.loadPools() } + function onRegistryRevisionChanged() { root.loadPools(); root.loadSwapFee() } } AmmTheme { @@ -52,6 +67,8 @@ Item { } function feeLabel(feeBps) { + if (feeBps === undefined || feeBps === null || Number(feeBps) < 0) + return qsTr("—") var percentage = Number(feeBps) / 100 return qsTr("%1%").arg(percentage.toLocaleString(Qt.locale(), "f", 2)) } @@ -204,7 +221,7 @@ Item { readonly property string pairText: qsTr("%1 / %2") .arg(String(pool.tokenA || "")) .arg(String(pool.tokenB || "")) - readonly property string feeText: root.feeLabel(pool.feeBps) + readonly property string feeText: root.feeLabel(root.swapFeeBps) height: 68 activeFocusOnTab: true diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index bdb7ad27..bcd542b6 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -211,7 +211,6 @@ QtObject { "minimumLp": String(quote.minimumLp || "0"), "reserveA": String(pool.reserveA || "0"), "reserveB": String(pool.reserveB || "0"), - "poolFeeBps": pool.feeBps, "price": String(quote.price || "0"), // The pool's LP token (base58, matching the holdings' definitionId) so the form // can offer the wallet's existing LP holdings as the mint destination. @@ -273,7 +272,6 @@ QtObject { "lpHoldingId": lpHoldingId, "amountA": snapshot.request.amountA, "amountB": snapshot.request.amountB, - "feeBps": snapshot.request.feeBps, // u64-max sentinel = no deadline, same as the swap submits. "deadlineMs": "18446744073709551615" } diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 3c56594d..a9b697ff 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -79,14 +79,14 @@ namespace { const QString tokenA = obj.value(QStringLiteral("tokenA")).toString(); const QString tokenB = obj.value(QStringLiteral("tokenB")).toString(); - const QJsonValue feeBps = obj.value(QStringLiteral("feeBps")); - if (tokenA.isEmpty() || tokenB.isEmpty() || !feeBps.isDouble()) + // The swap fee is instance-wide (AMM config), no longer a pool field, so it is + // not required here. Only the pair + on-chain ids identify the pool. + if (tokenA.isEmpty() || tokenB.isEmpty()) continue; QVariantMap pool; pool.insert(QStringLiteral("tokenA"), tokenA); pool.insert(QStringLiteral("tokenB"), tokenB); - pool.insert(QStringLiteral("feeBps"), feeBps.toInt()); pool.insert(QStringLiteral("poolId"), obj.value(QStringLiteral("poolId")).toString()); pool.insert(QStringLiteral("tokenADefinitionId"), diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml index 4e8bb749..20419d15 100644 --- a/apps/amm/tests/qml/tst_NewPositionForm.qml +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -426,7 +426,6 @@ TestCase { "tokenAId": tokenHigh, "tokenBId": tokenLow, "poolStatus": "active_pool", - "poolFeeBps": 30, "reserveA": "2", "reserveB": "10", "maxAmountA": "4", @@ -444,7 +443,6 @@ TestCase { }) wait(0) - compare(form.poolFeeBps, 30) form.finishActiveAmount("B", "1") compare(form.amountA, "5") @@ -471,32 +469,6 @@ TestCase { compare(quoteRequestedSpy.count, 0) } - function test_existingPoolFeeCorrectionKeepsQuoteRequestValid() { - var form = createForm() - quoteRequestedSpy.target = form - quoteRequestedSpy.clear() - - form.flowState = flowState({ - "status": "error", - "code": "fee_tier_mismatch", - "tokenAId": tokenHigh, - "tokenBId": tokenLow, - "errors": [{ - "code": "fee_tier_mismatch", - "details": { "poolFeeBps": "5" } - }] - }) - wait(0) - - compare(form.selectedFeeBps, 5) - compare(form.amountA, "") - compare(form.amountB, "") - compare(quoteRequestedSpy.count, 1) - verify(quoteRequestedSpy.signalArguments[0][1].ok) - compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountA, "5000000000") - compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountB, "1000") - } - function test_contextFailureFinishesTokenResolution() { var form = createForm() form.resolvingTokenId = tokenThird diff --git a/apps/amm/tests/qml/tst_PoolsPage.qml b/apps/amm/tests/qml/tst_PoolsPage.qml index 45f95412..c262094d 100644 --- a/apps/amm/tests/qml/tst_PoolsPage.qml +++ b/apps/amm/tests/qml/tst_PoolsPage.qml @@ -16,15 +16,20 @@ TestCase { QtObject { property var poolListResult: [ { - "tokenA": "TKA", "tokenB": "TKB", "feeBps": 5, + "tokenA": "TKA", "tokenB": "TKB", "tokenADefinitionId": "DEF_A", "tokenBDefinitionId": "DEF_B" }, - { "tokenA": "TKC", "tokenB": "TKA", "feeBps": 30 } + { "tokenA": "TKC", "tokenB": "TKA" } ] function poolList() { return poolListResult } + + // The swap fee is instance-wide, read from the config (no longer per pool). + function configAccount() { + return { "status": "ok", "error": "", "swapFeeBps": 5 } + } } } @@ -65,6 +70,8 @@ TestCase { // so adding entries to poolList() is all it takes to render more rows. compare(page.poolCount, 2) verify(page.feeLabel(5).endsWith("%")) + // The fee shown is the instance-wide config fee (swapFeeBps), not a per-pool value. + compare(page.swapFeeBps, 5) var list = findChild(page, "poolsList") var firstRow = findChild(page, "poolRow0") @@ -95,7 +102,7 @@ TestCase { // it needs to resolve the pool — not just the displayed pair. compare(spy.count, 1) compare(spy.signalArguments[0][0].tokenA, "TKC") - compare(spy.signalArguments[0][0].feeBps, 30) + compare(spy.signalArguments[0][0].tokenB, "TKA") findChild(page, "poolRow0").activate() compare(spy.count, 2) diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index 03380367..ee1c60fa 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -132,7 +132,9 @@ TOKEN_D_NAME="TOKEN D"; TOKEN_D_SYMBOL="TKD"; TOKEN_D_SUPPLY="100000000000000000 CLOCK_ACCOUNT="4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU" # canonical LEZ system clock POOL_TOKEN_A_AMOUNT="10000" POOL_TOKEN_B_AMOUNT="10000" -POOL_FEES="1" +# Instance-wide swap fee (basis points), set once at `initialize` and stored in the AMM +# config — no longer a per-pool value. Every swap in this namespace uses it. +SWAP_FEE_BPS="1" POOL_DEADLINE="18446744073709551615" # Where the UI token config is written for TESTS ONLY (git-ignored). This is @@ -547,7 +549,8 @@ run_tx strict "initialize AMM config" -- \ --nonce "$AMM_NONCE" \ --token-program-id "$TOKEN_PID" \ --twap-oracle-program-id "$TWAP_PID" \ - --authority "$AMM_AUTHORITY" + --authority "$AMM_AUTHORITY" \ + --swap-fee-bps "$SWAP_FEE_BPS" ############################################################################### # 8. Create the pool (seed initial liquidity) @@ -567,7 +570,6 @@ run_tx strict "create pool + seed liquidity" -- \ --clock "$CLOCK_ACCOUNT" \ --token-a-amount "$POOL_TOKEN_A_AMOUNT" \ --token-b-amount "$POOL_TOKEN_B_AMOUNT" \ - --fees "$POOL_FEES" \ --deadline "$POOL_DEADLINE" ############################################################################### @@ -612,11 +614,12 @@ kv "wrote" "$TOKENS_CONFIG_OUT" # 11. Write the UI known-pools config from the seeded pool(s) ############################################################################### sec "Write UI pools config -> $POOLS_CONFIG_OUT" -# One row per seeded pool: "SYMBOL_A SYMBOL_B FEE_BPS POOL_ID DEF_A DEF_B". +# One row per seeded pool: "SYMBOL_A SYMBOL_B POOL_ID DEF_A DEF_B". The swap fee is +# instance-wide (AMM config), not per pool, so it is no longer part of a pool entry. # Add a line here for each new seeded pool — nothing else (script or app) needs # to change; the Pools page renders one row per entry generically. POOL_SPECS=( - "$TOKEN_A_SYMBOL $TOKEN_B_SYMBOL $POOL_FEES $POOL $TOKEN_A_DEF $TOKEN_B_DEF" + "$TOKEN_A_SYMBOL $TOKEN_B_SYMBOL $POOL $TOKEN_A_DEF $TOKEN_B_DEF" ) pool_entry() { @@ -624,10 +627,9 @@ pool_entry() { { "tokenA": "$1", "tokenB": "$2", - "feeBps": $3, - "poolId": "$4", - "tokenADefinitionId": "$5", - "tokenBDefinitionId": "$6" + "poolId": "$3", + "tokenADefinitionId": "$4", + "tokenBDefinitionId": "$5" } JSON } @@ -638,7 +640,7 @@ JSON [ "$i" -gt 0 ] && echo " ," # shellcheck disable=SC2086 # deliberate word-split of the spec into fields set -- ${POOL_SPECS[$i]} - pool_entry "$1" "$2" "$3" "$4" "$5" "$6" + pool_entry "$1" "$2" "$3" "$4" "$5" done echo "]" } > "$POOLS_CONFIG_OUT" @@ -672,7 +674,7 @@ JSON # shellcheck disable=SC2086 # deliberate word-split of the spec into fields set -- ${POOL_SPECS[$i]} cat < Result { })) } -/// Decodes the singleton config account: authority + the token/twap program ids the AMM chains -/// into. Ids are base58 (app-facing). `config_unavailable` when the config PDA isn't on-chain -/// yet / undecodable; `configId` / `ammProgramId` are still derivable from `amm_program_id` via -/// `config_id` for address derivation. +/// Decodes the AMM config account: authority, the token/twap program ids the AMM chains into, and +/// the instance-wide `swapFeeBps` (the swap fee charged on every swap in this namespace — fees are +/// not per-pool). Ids are base58 (app-facing). `config_unavailable` when the config PDA isn't +/// on-chain yet / undecodable; `configId` / `ammProgramId` are still derivable from +/// `amm_program_id` via `config_id` for address derivation. pub(super) fn config_account(request: ConfigAccountRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; let Ok((config_id, config)) = load_config(amm_program, &request.config) else { @@ -43,6 +44,7 @@ pub(super) fn config_account(request: ConfigAccountRequest) -> Result Result Result, #[serde(default)] pub amount_b: Option, - pub fee_bps: u32, pub deadline_ms: String, pub user_holding_a_id: String, pub user_holding_b_id: String, diff --git a/modules/amm/ffi/src/api/swap.rs b/modules/amm/ffi/src/api/swap.rs index f2297180..4199788a 100644 --- a/modules/amm/ffi/src/api/swap.rs +++ b/modules/amm/ffi/src/api/swap.rs @@ -5,7 +5,7 @@ use amm_core::{ compute_pool_pda, mul_div_ceil, mul_div_floor, price_impact_bps, swap_exact_in_amounts, - swap_exact_out_amounts, PoolDefinition, FEE_BPS_DENOMINATOR, + swap_exact_out_amounts, AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, }; use lee_core::account::AccountId; use risc0_binfmt::ProgramBinary; @@ -18,8 +18,19 @@ use super::{ }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes, + AccountRead, }; +/// Reads the instance-wide swap fee (basis points) from an AMM config account read. Fees moved +/// from `PoolDefinition` to `AmmConfig::swap_fee_bps`, so quotes and pool resolution source the +/// fee here. Returns `invalid_config` if the read can't be decoded as an `AmmConfig`. +fn swap_fee_bps_from_config(config: &AccountRead) -> Result { + let (_, account) = decode_account(config)?; + AmmConfig::try_from(&account.data) + .map(|cfg| cfg.swap_fee_bps) + .map_err(|_| String::from("invalid_config")) +} + /// Orders `(token_in, token_out)` into the pool's canonical `(token_a, token_b)` /// so derived vault PDAs line up with the pool's stored `vault_a`/`vault_b`. fn canonical_pair(token_in: AccountId, token_out: AccountId) -> (AccountId, AccountId) { @@ -97,7 +108,9 @@ pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result if pool.liquidity_pool_supply == 0 { return Ok(missing()); } - let fee_bps = u32::try_from(pool.fees).map_err(|_| String::from("invalid_fee_tier"))?; + // The fee is instance-wide (AmmConfig::swap_fee_bps), not a pool field. + let fee_bps = u32::try_from(swap_fee_bps_from_config(&request.config)?) + .map_err(|_| String::from("invalid_fee_tier"))?; Ok(json!({ "status": "ok", "error": "", @@ -154,6 +167,9 @@ pub(super) fn swap_exact_in_quote(request: SwapExactInQuoteRequest) -> Result Result Result< return Err(String::from("invalid_slippage")); } + // The swap fee is instance-wide (AmmConfig::swap_fee_bps), read from the config. + let swap_fee_bps = swap_fee_bps_from_config(&request.config)?; + // Decode the pool; absent / undecodable / empty ⇒ nothing to swap against. let pool = hex::decode(&request.pool_data) .ok() @@ -256,7 +275,7 @@ pub(super) fn swap_exact_out_quote(request: SwapExactOutQuoteRequest) -> Result< // Required input for the desired output (shared with amm_program::swap). None // when the pool can't deliver that much (amount_out >= reserve_out). let Some((_, required_in)) = - swap_exact_out_amounts(amount_out, reserve_in, reserve_out, pool.fees) + swap_exact_out_amounts(amount_out, reserve_in, reserve_out, swap_fee_bps) else { return Err(String::from("output_exceeds_liquidity")); }; @@ -442,10 +461,27 @@ pub(super) fn program_id(request: ProgramIdRequest) -> Result { #[cfg(test)] mod tests { use amm_core::PoolDefinition; - use lee_core::account::AccountId; + use lee_core::account::{Account, AccountId, Data}; use super::*; - use crate::account::{AccountRead, WalletAccount}; + use crate::account::{account_read, AccountRead, WalletAccount}; + + /// A decodable AMM config read carrying `swap_fee_bps = 30` — the quotes/resolve read the + /// instance-wide fee from here now that it is no longer a pool field. 30 bps matches the fee + /// the pricing assertions were computed against. + fn valid_config() -> AccountRead { + let account = Account { + program_owner: parse_program_id(&"00".repeat(32)).unwrap(), + data: Data::from(&AmmConfig { + token_program_id: parse_program_id(&"01".repeat(32)).unwrap(), + twap_oracle_program_id: parse_program_id(&"02".repeat(32)).unwrap(), + authority: AccountId::new([0x09; 32]), + swap_fee_bps: 30, + }), + ..Account::default() + }; + account_read(AccountId::new([0xEE; 32]), &account) + } fn pool_read(pool: &PoolDefinition) -> AccountRead { AccountRead { @@ -476,10 +512,10 @@ mod tests { liquidity_pool_supply: 1_000, reserve_a: 111, reserve_b: 222, - fees: 30, }; let value = resolve_pool(ResolvePoolRequest { + config: valid_config(), pool: pool_read(&pool), }) .unwrap(); @@ -507,6 +543,7 @@ mod tests { ..Default::default() }; let value = resolve_pool(ResolvePoolRequest { + config: valid_config(), pool: pool_read(&pool), }) .unwrap(); @@ -625,12 +662,12 @@ mod tests { liquidity_pool_supply: 1_000_000, reserve_a: 1_000_000, reserve_b: 2_000_000, - fees: 30, ..Default::default() }; // Sell A → receive B: reserveIn = reserve_a, reserveOut = reserve_b. let ab = swap_exact_in_quote(SwapExactInQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_in: "10000".into(), @@ -653,6 +690,7 @@ mod tests { // Reverse direction orients reserves the other way. let ba = swap_exact_in_quote(SwapExactInQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_b), token_out_id: account_id_hex(def_a), amount_in: "10000".into(), @@ -669,6 +707,7 @@ mod tests { let def_a = AccountId::new([0xAA; 32]); let def_b = AccountId::new([0xBB; 32]); let req = |pool_data: String| SwapExactInQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_in: "10000".into(), @@ -703,10 +742,10 @@ mod tests { liquidity_pool_supply: 1_000_000, reserve_a: 1_000_000, reserve_b: 2_000_000, - fees: 30, ..Default::default() }; let req = |amount: &str| SwapExactInQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_in: amount.into(), @@ -741,10 +780,10 @@ mod tests { liquidity_pool_supply: 1, reserve_a: 1, reserve_b: u128::MAX, - fees: 30, ..Default::default() }; let quote = swap_exact_in_quote(SwapExactInQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_in: "2".into(), @@ -769,10 +808,10 @@ mod tests { liquidity_pool_supply: 1_000_000, reserve_a: 1_000_000, reserve_b: 2_000_000, - fees: 30, ..Default::default() }; let req = |amount_out: &str| SwapExactOutQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_out: amount_out.into(), @@ -815,11 +854,11 @@ mod tests { liquidity_pool_supply: 1, reserve_a: 0, reserve_b: 2_000_000, - fees: 30, ..Default::default() }; assert_eq!( swap_exact_out_quote(SwapExactOutQuoteRequest { + config: valid_config(), token_in_id: account_id_hex(def_a), token_out_id: account_id_hex(def_b), amount_out: "10000".into(), diff --git a/modules/amm/ffi/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs index 6098f523..964d5485 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -65,6 +65,7 @@ fn config_account() -> Account { token_program_id: TOKEN_PROGRAM, twap_oracle_program_id: TWAP_PROGRAM, authority: AccountId::new([7; 32]), + swap_fee_bps: 30, }), ) } @@ -403,6 +404,8 @@ fn config_account_decodes_authority_and_program_ids() { value["twapOracleProgramId"], program_id_base58(TWAP_PROGRAM) ); + // The instance-wide swap fee is surfaced from the config (the fixture sets 30 bps). + assert_eq!(value["swapFeeBps"], 30); } #[test] @@ -447,7 +450,6 @@ fn swap_plan_uses_the_pool_stored_vaults_not_canonical_order() { liquidity_pool_supply: 1_000, reserve_a: 1_000, reserve_b: 1_000, - fees: 30, }; let holding = AccountId::new([9; 32]); @@ -522,7 +524,6 @@ fn swap_exact_out_plan_uses_the_pool_stored_vaults_not_canonical_order() { liquidity_pool_supply: 1_000, reserve_a: 1_000, reserve_b: 1_000, - fees: 30, }; let holding = AccountId::new([9; 32]); diff --git a/modules/amm/ffi/src/api/token_holdings.rs b/modules/amm/ffi/src/api/token_holdings.rs index 4c5f7198..ba619d1c 100644 --- a/modules/amm/ffi/src/api/token_holdings.rs +++ b/modules/amm/ffi/src/api/token_holdings.rs @@ -73,6 +73,7 @@ mod tests { token_program_id: token_program(), twap_oracle_program_id: parse_program_id(&"02".repeat(32)).unwrap(), authority: AccountId::new([0x09; 32]), + swap_fee_bps: 30, }), ..Account::default() }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index e64d065f..1f97906e 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -423,7 +423,9 @@ LogosMap AmmModuleImpl::resolvePoolAccount(const std::string& def_a_hex, } const json pool = readPublicAccount(jStr(pairResult.value, "poolId")); - const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}}); + // The fee shown for the pool is the instance-wide AmmConfig::swap_fee_bps, so resolve_pool + // needs the config account to read it (it is no longer a pool field). + const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}, {"config", config}}); if (!resolveResult.ok) return failed("bad_config"); // amm_resolve_pool op failed // resolve_pool returns status:"error"/no_pool for a missing pool (pass through) or @@ -647,6 +649,8 @@ LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex, {"tokenOutId", token_out}, {"amountIn", amount_in_decimal}, {"slippageBps", slippage_bps}, + // The swap fee is instance-wide (AmmConfig::swap_fee_bps); the quote reads it from config. + {"config", config}, {"poolData", pool_data}, }); if (!quoteResult.ok) @@ -706,6 +710,8 @@ LogosMap AmmModuleImpl::swapExactOutQuote(const std::string& token_in_hex, {"tokenOutId", token_out}, {"amountOut", amount_out_decimal}, {"slippageBps", slippage_bps}, + // The swap fee is instance-wide (AmmConfig::swap_fee_bps); the quote reads it from config. + {"config", config}, {"poolData", pool_data}, }); if (!quoteResult.ok) @@ -991,15 +997,11 @@ LogosMap AmmModuleImpl::createPool(const LogosMap& request) { || !jsonAmountToDecimal(request.value("deadlineMs", json()), deadline_decimal)) return error("bad_amount"); - // feeBps deserializes into a u32 in the plan request, so a missing / null / float / string - // value would fail the FFI's serde parse and leak an "invalid request JSON" error instead of - // a stable code. Require a JSON integer here; fee-tier support is validated in the plan op. - const json fee_val = request.value("feeBps", json()); - if (!fee_val.is_number_integer()) - return error("bad_fee_bps_amount"); - + // The swap fee is set once per namespace at initialize (AmmConfig::swap_fee_bps); pools no + // longer carry a fee, so pool creation takes none. + // // amm_create_pool_plan resolves the pool accounts (canonicalizing the pair), - // encodes NewDefinition (with the fee), and returns a ready-to-submit plan. + // encodes NewDefinition, and returns a ready-to-submit plan. const FfiResult planResult = call(amm_create_pool_plan, json{ {"ammProgramId", amm_program_id}, {"config", config}, @@ -1007,7 +1009,6 @@ LogosMap AmmModuleImpl::createPool(const LogosMap& request) { {"tokenBId", token_b}, {"amountA", amount_a_decimal}, {"amountB", amount_b_decimal}, - {"feeBps", fee_val}, {"deadlineMs", deadline_decimal}, {"userHoldingAId", holding_a}, {"userHoldingBId", holding_b}, diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 219d1bf6..1e4bd02b 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -159,17 +159,18 @@ class AmmModuleImpl : public LogosModuleContext { /// Submits a `NewDefinition` transaction creating the pool for the request's pair. /// `request` carries `{ tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, - /// amountA, amountB, feeBps, deadlineMs }` (ids hex or base58, normalized to + /// amountA, amountB, deadlineMs }` (ids hex or base58, normalized to /// hex; amounts/deadline a JSON integer or decimal string, deadline a u64 unix-ms). + /// The swap fee is instance-wide (set at `initialize`, stored in the config), so pool + /// creation no longer takes a `feeBps`. /// The caller provides `lpHoldingId` — a fresh (empty) account the guest initializes /// and mints the creator's LP tokens into; a new pool has no pre-existing LP holding, /// and the module never creates wallet accounts. On success: /// `{ status:"ok", error:"", transactionId: }`. On failure: /// `{ status:"error", error: }` — `config_missing`, `backend_error`, - /// `invalid_account_id`, `bad_amount` (malformed amount/deadline), `bad_fee_bps_amount` - /// (`feeBps` not a JSON integer), `wallet_submission_failed`, or a plan code (e.g. - /// `invalid_fee_tier`, `config_unavailable`). Unlike the swaps, a submit failure carries - /// a code so the create-pool UI can tell the user why. + /// `invalid_account_id`, `bad_amount` (malformed amount/deadline), + /// `wallet_submission_failed`, or a plan code (e.g. `config_unavailable`). Unlike the + /// swaps, a submit failure carries a code so the create-pool UI can tell the user why. LogosMap createPool(const LogosMap& request); /// Prices an `AddLiquidity` into the existing pool for (tokenAId, tokenBId) from the diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index f21fe557..fe224654 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -32,6 +32,10 @@ pub enum Instruction { /// and downstream PDA is derived from this config's account id, so instances are fully /// isolated even for the same token pair. Rejects if the config already exists. /// + /// The config also stores the instance-wide `swap_fee_bps` — the swap fee (in basis points) + /// every pool in this namespace charges. Fees are no longer per-pool: whoever initializes the + /// instance sets the fee here once, and every swap reads it from the config. + /// /// Required accounts: /// - Owner Account — signs this instruction; its account id is the namespace owner. /// - AMM Config Account, uninitialized, derived as `compute_config_pda(self_program_id, @@ -45,6 +49,9 @@ pub enum Instruction { twap_oracle_program_id: ProgramId, /// Admin authority allowed to transfer admin control via `UpdateConfig`. authority: AccountId, + /// Instance-wide swap fee in basis points, applied to every swap in this namespace. + /// Must be below `FEE_BPS_DENOMINATOR` (100%). + swap_fee_bps: u128, }, /// Transfers the AMM Program's admin authority to a new account. Only the configured admin @@ -133,7 +140,6 @@ pub enum Instruction { NewDefinition { token_a_amount: u128, token_b_amount: u128, - fees: u128, /// Unix timestamp (milliseconds) after which this transaction is invalid. deadline: u64, }, @@ -257,8 +263,6 @@ pub struct PoolDefinition { pub liquidity_pool_supply: u128, pub reserve_a: u128, pub reserve_b: u128, - /// Fee tier in basis points. - pub fees: u128, } pub const FEE_BPS_DENOMINATOR: u128 = 10_000; @@ -292,6 +296,17 @@ pub fn assert_supported_fee_tier(fees: u128) { ); } +/// Validates the instance-wide swap fee stored in [`AmmConfig`]. Any value below +/// `FEE_BPS_DENOMINATOR` (100%) is allowed — a namespace sets its own fee at `Initialize`, no +/// longer restricted to the fixed tiers. A fee at or above 100% would leave a trade with zero +/// effective input (the fee multiplier saturates to 0), so it is rejected. +pub fn assert_valid_swap_fee_bps(swap_fee_bps: u128) { + assert!( + swap_fee_bps < FEE_BPS_DENOMINATOR, + "Swap fee must be below FEE_BPS_DENOMINATOR (100%) basis points" + ); +} + /// Computes a `Q64.64` spot price (`reserve_quote` per `reserve_base`) from raw pool reserves. /// /// This is the constant-product AMM's spot price (`reserve_quote / reserve_base`) expressed as a @@ -516,6 +531,9 @@ pub struct AmmConfig { pub twap_oracle_program_id: ProgramId, /// Admin authority allowed to transfer admin control via `UpdateConfig`. pub authority: AccountId, + /// Instance-wide swap fee in basis points, applied to every swap in this namespace. + /// Set at `Initialize`; always below `FEE_BPS_DENOMINATOR` (100%). Fees are not per-pool. + pub swap_fee_bps: u128, } impl TryFrom<&Data> for AmmConfig { diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 8f26d5d9..550b6799 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -37,6 +37,13 @@ mod amm { /// already AMM-owned and echoed unchanged. /// 2. `config` — uninitialized config PDA at /// `compute_config_pda(self_program_id, owner.account_id, nonce)`. + /// + /// `swap_fee_bps` is the instance-wide swap fee (basis points) stored in the config; every + /// swap in this namespace reads it. Fees are no longer configured per pool. + #[expect( + clippy::too_many_arguments, + reason = "instruction interface requires explicit owner, config, namespace, program ids, and fee" + )] #[instruction] pub fn initialize( ctx: ProgramContext, @@ -48,6 +55,7 @@ mod amm { token_program_id: ProgramId, twap_oracle_program_id: ProgramId, authority: AccountId, + swap_fee_bps: u128, ) -> SpelResult { let post_states = amm_program::initialize::initialize( owner, @@ -56,6 +64,7 @@ mod amm { token_program_id, twap_oracle_program_id, authority, + swap_fee_bps, ctx.self_program_id, ); Ok(spel_framework::SpelOutput::execute(post_states, vec![])) @@ -182,7 +191,6 @@ mod amm { clock: AccountWithMetadata, token_a_amount: u128, token_b_amount: u128, - fees: u128, deadline: u64, ) -> SpelResult { let (post_states, chained_calls) = amm_program::new_definition::new_definition( @@ -199,7 +207,6 @@ mod amm { clock, NonZeroU128::new(token_a_amount).expect("token_a_amount must be nonzero"), NonZeroU128::new(token_b_amount).expect("token_b_amount must be nonzero"), - fees, ctx.self_program_id, ); Ok(spel_framework::SpelOutput::execute(post_states, chained_calls) diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index e8e3821b..b3ecbe6d 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -1,9 +1,8 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_pool_pda, - compute_pool_pda_seed, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, - AmmConfig, PoolDefinition, + compute_liquidity_token_pda_seed, compute_pool_pda, compute_pool_pda_seed, mul_div_floor, + read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -63,8 +62,6 @@ pub fn add_liquidity( "Add liquidity: pool account is not derived under this config's namespace" ); - assert_supported_fee_tier(pool_def_data.fees); - assert_eq!( vault_a.account_id, pool_def_data.vault_a_id, "Vault A was not provided" diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs index 9d2d27f4..6a36d0b0 100644 --- a/programs/amm/src/create_oracle_price_account.rs +++ b/programs/amm/src/create_oracle_price_account.rs @@ -195,6 +195,7 @@ mod tests { token_program_id: TOKEN_PROGRAM_ID, twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, authority: AccountId::new([9; 32]), + swap_fee_bps: amm_core::FEE_TIER_BPS_30, }), nonce: Nonce(0), }, @@ -217,7 +218,6 @@ mod tests { liquidity_pool_supply: 5_000, reserve_a, reserve_b, - fees: amm_core::FEE_TIER_BPS_30, }), nonce: Nonce(0), }, diff --git a/programs/amm/src/create_price_observations.rs b/programs/amm/src/create_price_observations.rs index cae49f3c..2109f256 100644 --- a/programs/amm/src/create_price_observations.rs +++ b/programs/amm/src/create_price_observations.rs @@ -177,6 +177,7 @@ mod tests { token_program_id: TOKEN_PROGRAM_ID, twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, authority: AccountId::new([9; 32]), + swap_fee_bps: amm_core::FEE_TIER_BPS_30, }), nonce: Nonce(0), }, @@ -199,7 +200,6 @@ mod tests { liquidity_pool_supply: 5_000, reserve_a: 5_000, reserve_b: 2_500, - fees: amm_core::FEE_TIER_BPS_30, }), nonce: Nonce(0), }, diff --git a/programs/amm/src/initialize.rs b/programs/amm/src/initialize.rs index 9fd01c7f..32ff63f1 100644 --- a/programs/amm/src/initialize.rs +++ b/programs/amm/src/initialize.rs @@ -1,4 +1,4 @@ -use amm_core::{compute_config_pda, compute_config_pda_seed, AmmConfig}; +use amm_core::{assert_valid_swap_fee_bps, compute_config_pda, compute_config_pda_seed, AmmConfig}; use lee_core::{ account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, @@ -18,12 +18,20 @@ use lee_core::{ /// `update_config`). Its existence is the instance's "initialized" flag, and its account id is the /// namespace root every pool and downstream PDA derives from. /// +/// `swap_fee_bps` is the instance-wide swap fee (basis points) every pool in this namespace +/// charges; it is stored in the config and read by each swap. Fees are no longer per-pool. +/// /// # Panics /// Panics if: /// - `owner.is_authorized` is false (the owner did not sign). /// - `config.account_id` does not match `compute_config_pda(amm_program_id, owner.account_id, /// nonce)`. /// - `config.account` is not the default (the instance is already initialized). +/// - `swap_fee_bps` is not below `FEE_BPS_DENOMINATOR` (100%). +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit owner, config, namespace, program ids, and fee" +)] pub fn initialize( owner: AccountWithMetadata, config: AccountWithMetadata, @@ -31,6 +39,7 @@ pub fn initialize( token_program_id: ProgramId, twap_oracle_program_id: ProgramId, authority: AccountId, + swap_fee_bps: u128, amm_program_id: ProgramId, ) -> Vec { assert!( @@ -48,12 +57,14 @@ pub fn initialize( Account::default(), "Initialize: AMM config account must be uninitialized" ); + assert_valid_swap_fee_bps(swap_fee_bps); let mut config_post = config.account.clone(); config_post.data = Data::from(&AmmConfig { token_program_id, twap_oracle_program_id, authority, + swap_fee_bps, }); // On first use the owner is a fresh EOA; the program claims it (the owner authorizes this by @@ -92,6 +103,7 @@ mod tests { const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; const NONCE: [u8; 32] = [3; 32]; + const SWAP_FEE_BPS: u128 = 30; fn authority() -> AccountId { AccountId::new([9; 32]) @@ -125,6 +137,7 @@ mod tests { TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), + SWAP_FEE_BPS, AMM_PROGRAM_ID, ) } @@ -159,6 +172,7 @@ mod tests { TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), + SWAP_FEE_BPS, AMM_PROGRAM_ID, ); assert_eq!(post_states[0].required_claim(), None); @@ -173,6 +187,25 @@ mod tests { assert_eq!(config.token_program_id, TOKEN_PROGRAM_ID); assert_eq!(config.twap_oracle_program_id, TWAP_ORACLE_PROGRAM_ID); assert_eq!(config.authority, authority()); + // The instance-wide swap fee is stored in the config (no longer per-pool). + assert_eq!(config.swap_fee_bps, SWAP_FEE_BPS); + } + + /// A swap fee at or above 100% would leave a trade with zero effective input, so it is + /// rejected — the only validation on the otherwise free-form per-namespace fee. + #[test] + #[should_panic(expected = "Swap fee must be below")] + fn swap_fee_at_or_above_100_percent_panics() { + initialize( + owner_signed(), + config_uninit(), + NONCE, + TOKEN_PROGRAM_ID, + TWAP_ORACLE_PROGRAM_ID, + authority(), + amm_core::FEE_BPS_DENOMINATOR, + AMM_PROGRAM_ID, + ); } /// A different nonce is a different instance: same owner, distinct config PDA. @@ -196,6 +229,7 @@ mod tests { TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), + SWAP_FEE_BPS, AMM_PROGRAM_ID, ); } @@ -212,6 +246,7 @@ mod tests { TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), + SWAP_FEE_BPS, AMM_PROGRAM_ID, ); } @@ -224,6 +259,7 @@ mod tests { token_program_id: TOKEN_PROGRAM_ID, twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, authority: authority(), + swap_fee_bps: SWAP_FEE_BPS, }); initialized.account.nonce = Nonce(0); initialize( @@ -233,6 +269,7 @@ mod tests { TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), + SWAP_FEE_BPS, AMM_PROGRAM_ID, ); } diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index c1dcd4a4..88d95c84 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -1,10 +1,10 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, - compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, - compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, isqrt_product, - spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, + compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, + compute_vault_pda_seed, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, + MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -32,7 +32,6 @@ pub fn new_definition( clock: AccountWithMetadata, token_a_amount: NonZeroU128, token_b_amount: NonZeroU128, - fees: u128, amm_program_id: ProgramId, ) -> (Vec, Vec) { let definition_token_a_id = token_core::TokenHolding::try_from(&user_holding_a.account.data) @@ -96,7 +95,6 @@ pub fn new_definition( compute_lp_lock_holding_pda(amm_program_id, pool.account_id), "LP lock holding Account ID does not match PDA" ); - assert_supported_fee_tier(fees); // Assert that pool is uninitialized (hard precondition) assert_eq!( @@ -142,7 +140,6 @@ pub fn new_definition( liquidity_pool_supply: initial_lp, reserve_a: token_a_amount.into(), reserve_b: token_b_amount.into(), - fees, }; let mut pool_initialized = pool.account.clone(); diff --git a/programs/amm/src/remove.rs b/programs/amm/src/remove.rs index 2dd8e789..ff74eba7 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -1,9 +1,9 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_pool_pda, - compute_pool_pda_seed, compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, - PoolDefinition, MINIMUM_LIQUIDITY, + compute_liquidity_token_pda_seed, compute_pool_pda, compute_pool_pda_seed, + compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, PoolDefinition, + MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -65,8 +65,6 @@ pub fn remove_liquidity( "Remove liquidity: pool account is not derived under this config's namespace" ); - assert_supported_fee_tier(pool_def_data.fees); - assert!( pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 4b5d0481..900644fa 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,9 +1,8 @@ +pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; use amm_core::{ - assert_supported_fee_tier, compute_pool_pda, compute_pool_pda_seed, - read_vault_fungible_balances, spot_price_q64_64, swap_exact_in_amounts, swap_exact_out_amounts, - AmmConfig, MINIMUM_LIQUIDITY, + compute_pool_pda, compute_pool_pda_seed, read_vault_fungible_balances, spot_price_q64_64, + swap_exact_in_amounts, swap_exact_out_amounts, AmmConfig, MINIMUM_LIQUIDITY, }; -pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, @@ -19,7 +18,6 @@ fn validate_swap_setup( ) -> PoolDefinition { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); assert!( pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, @@ -241,7 +239,7 @@ pub fn swap_exact_input( user_holding_b.clone(), swap_amount_in, min_amount_out, - pool_def_data.fees, + config_data.swap_fee_bps, pool_def_data.reserve_a, pool_def_data.reserve_b, pool.account_id, @@ -256,7 +254,7 @@ pub fn swap_exact_input( user_holding_a.clone(), swap_amount_in, min_amount_out, - pool_def_data.fees, + config_data.swap_fee_bps, pool_def_data.reserve_b, pool_def_data.reserve_a, pool.account_id, @@ -457,7 +455,7 @@ pub fn swap_exact_output( max_amount_in, pool_def_data.reserve_a, pool_def_data.reserve_b, - pool_def_data.fees, + config_data.swap_fee_bps, pool.account_id, ); @@ -472,7 +470,7 @@ pub fn swap_exact_output( max_amount_in, pool_def_data.reserve_b, pool_def_data.reserve_a, - pool_def_data.fees, + config_data.swap_fee_bps, pool.account_id, ); diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 22f042fb..c9b7f08e 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,6 +1,6 @@ use amm_core::{ - assert_supported_fee_tier, compute_pool_pda, compute_pool_pda_seed, - read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_pool_pda, compute_pool_pda_seed, read_vault_fungible_balances, spot_price_q64_64, + AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -20,7 +20,6 @@ pub fn sync_reserves( ) -> (Vec, Vec) { let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); - assert_supported_fee_tier(pool_def_data.fees); // The TWAP oracle program ID is taken from the config account. Validating the config PDA is // also the Program's initialization gate. diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 8274c0de..d7b50668 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -11,8 +11,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, isqrt_product, mul_div_floor, - AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, FEE_TIER_BPS_1, FEE_TIER_BPS_100, - FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, + AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use lee_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, @@ -673,6 +672,7 @@ impl AccountWithMetadataForTests { token_program_id: TOKEN_PROGRAM_ID, twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, authority: AccountId::new([9; 32]), + swap_fee_bps: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1011,7 +1011,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1046,7 +1045,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: 1_000, reserve_b: 500, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1069,7 +1067,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: 1_000, reserve_b: 1_000, - fees: FEE_TIER_BPS_30, }), nonce: Nonce(0), }, @@ -1092,7 +1089,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: 0, reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1115,7 +1111,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: 0, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1138,7 +1133,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::vault_a_reserve_low(), reserve_a: BalanceForTests::vault_a_reserve_low(), reserve_b: BalanceForTests::vault_b_reserve_high(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1161,7 +1155,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::vault_a_reserve_high(), reserve_a: BalanceForTests::vault_a_reserve_high(), reserve_b: BalanceForTests::vault_b_reserve_low(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1184,7 +1177,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: BalanceForTests::vault_a_swap_test_1(), reserve_b: BalanceForTests::vault_b_swap_test_1(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1207,7 +1199,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: BalanceForTests::vault_a_swap_test_2(), reserve_b: BalanceForTests::vault_b_swap_test_2(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1233,7 +1224,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: 1500_u128, reserve_b: 334_u128, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1256,7 +1246,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: 715_u128, reserve_b: 701_u128, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1279,7 +1268,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: 1003_u128, reserve_b: 999_u128, - fees: FEE_TIER_BPS_30, }), nonce: Nonce(0), }, @@ -1302,7 +1290,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::vault_a_reserve_low(), reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1325,7 +1312,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::add_lp_supply_successful(), reserve_a: BalanceForTests::vault_a_add_successful(), reserve_b: BalanceForTests::vault_b_add_successful(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1348,7 +1334,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: BalanceForTests::vault_a_reserve_low(), reserve_b: BalanceForTests::vault_b_reserve_low(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1371,7 +1356,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::remove_lp_supply_successful(), reserve_a: BalanceForTests::vault_a_remove_successful(), reserve_b: BalanceForTests::vault_b_remove_successful(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1394,7 +1378,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: MINIMUM_LIQUIDITY - 1, reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1417,7 +1400,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: BalanceForTests::lp_supply_init(), reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -1506,7 +1488,6 @@ impl AccountWithMetadataForTests { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: BalanceForTests::vault_a_reserve_init(), reserve_b: BalanceForTests::vault_b_reserve_init(), - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -2237,7 +2218,6 @@ fn test_call_new_definition_with_zero_balance_1() { AccountWithMetadataForTests::clock(), NonZero::new(0).expect("Balances must be nonzero"), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2259,7 +2239,6 @@ fn test_call_new_definition_with_zero_balance_2() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(0).expect("Balances must be nonzero"), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2281,7 +2260,6 @@ fn test_call_new_definition_same_token_definition() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2303,7 +2281,6 @@ fn test_call_new_definition_wrong_liquidity_id() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2325,7 +2302,6 @@ fn test_call_new_definition_wrong_lp_lock_holding_id() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2347,7 +2323,6 @@ fn test_call_new_definition_wrong_pool_id() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2369,7 +2344,6 @@ fn test_call_new_definition_wrong_vault_id_1() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2391,7 +2365,6 @@ fn test_call_new_definition_wrong_vault_id_2() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2414,7 +2387,6 @@ fn test_call_new_definition_rejects_initialized_pool() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2437,7 +2409,6 @@ fn test_call_new_definition_initial_lp_too_small() { AccountWithMetadataForTests::clock(), NonZero::new(MINIMUM_LIQUIDITY).unwrap(), NonZero::new(MINIMUM_LIQUIDITY).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); } @@ -2458,7 +2429,6 @@ fn test_call_new_definition_chained_call_successful() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -2621,29 +2591,6 @@ fn test_call_swap_below_minimum_liquidity() { ); } -#[should_panic(expected = "Fee tier must be one of 1, 5, 30, or 100 basis points")] -#[test] -fn test_call_swap_rejects_unsupported_fee_tier() { - let mut pool = AccountWithMetadataForTests::pool_definition_init(); - let mut pool_def = PoolDefinition::try_from(&pool.account.data).unwrap(); - pool_def.fees = 2; - pool.account.data = Data::from(&pool_def); - - let _post_states = swap_exact_input( - AccountWithMetadataForTests::config_init(), - pool, - AccountWithMetadataForTests::vault_a_init(), - AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::user_holding_a(), - AccountWithMetadataForTests::user_holding_b(), - AccountWithMetadataForTests::current_tick_account_uninit(), - AccountWithMetadataForTests::clock(), - BalanceForTests::add_max_amount_a(), - BalanceForTests::add_max_amount_a_low(), - AMM_PROGRAM_ID, - ); -} - #[should_panic(expected = "Withdraw amount is less than minimal amount out")] #[test] fn test_call_swap_below_min_out() { @@ -3199,7 +3146,6 @@ fn swap_exact_output_overflow_protection() { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: large_reserve, reserve_b, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -3267,7 +3213,6 @@ fn test_new_definition_lp_asymmetric_amounts() { AccountWithMetadataForTests::clock(), NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -3307,7 +3252,6 @@ fn test_new_definition_lp_symmetric_amounts() { AccountWithMetadataForTests::clock(), NonZero::new(token_a_amount).unwrap(), NonZero::new(token_b_amount).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -3378,7 +3322,6 @@ fn test_new_definition_large_18_decimal_amounts_no_overflow() { AccountWithMetadataForTests::clock(), NonZero::new(token_a_amount).unwrap(), NonZero::new(token_b_amount).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -3416,7 +3359,6 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() { AccountWithMetadataForTests::clock(), NonZero::new(token_a_amount).unwrap(), NonZero::new(token_b_amount).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -3580,25 +3522,6 @@ fn test_sync_reserves_rejects_pool_below_minimum_liquidity() { ); } -#[should_panic(expected = "Fee tier must be one of 1, 5, 30, or 100 basis points")] -#[test] -fn test_sync_reserves_rejects_unsupported_fee_tier() { - let mut pool = AccountWithMetadataForTests::pool_definition_init(); - let mut pool_def = PoolDefinition::try_from(&pool.account.data).unwrap(); - pool_def.fees = 2; - pool.account.data = Data::from(&pool_def); - - let _ = sync_reserves( - AccountWithMetadataForTests::config_init(), - pool, - AccountWithMetadataForTests::vault_a_init(), - AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::current_tick_account_uninit(), - AccountWithMetadataForTests::clock(), - AMM_PROGRAM_ID, - ); -} - #[test] fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { let donation_a = 100u128; @@ -3694,7 +3617,6 @@ fn new_definition_overflow_protection() { AccountWithMetadataForTests::clock(), NonZero::new(large_amount).unwrap(), NonZero::new(2).unwrap(), - BalanceForTests::fee_tier(), AMM_PROGRAM_ID, ); @@ -3726,7 +3648,6 @@ fn add_liquidity_overflow_protection() { liquidity_pool_supply: large, reserve_a: large, reserve_b: large, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -3809,7 +3730,6 @@ fn remove_liquidity_overflow_protection() { liquidity_pool_supply: lp_supply, reserve_a: large_reserve, reserve_b, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -3906,7 +3826,6 @@ fn swap_exact_input_overflow_protection() { liquidity_pool_supply: MINIMUM_LIQUIDITY, reserve_a: 1_000, reserve_b: large_reserve, - fees: BalanceForTests::fee_tier(), }), nonce: Nonce(0), }, @@ -3973,60 +3892,6 @@ fn swap_exact_input_overflow_protection() { assert_eq!(pool_def.reserve_b, large_reserve - expected_withdraw); } -#[test] -fn test_new_definition_supports_all_fee_tiers() { - for fees in [ - FEE_TIER_BPS_1, - FEE_TIER_BPS_5, - FEE_TIER_BPS_30, - FEE_TIER_BPS_100, - ] { - let (post_states, _) = new_definition( - AccountWithMetadataForTests::config_init(), - AccountWithMetadataForTests::pool_definition_uninit(), - AccountWithMetadataForTests::vault_a_init(), - AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::pool_lp_uninit(), - AccountWithMetadataForTests::lp_lock_holding_uninit(), - AccountWithMetadataForTests::user_holding_a(), - AccountWithMetadataForTests::user_holding_b(), - AccountWithMetadataForTests::user_holding_lp_uninit(), - AccountWithMetadataForTests::current_tick_account_uninit(), - AccountWithMetadataForTests::clock(), - NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), - NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - fees, - AMM_PROGRAM_ID, - ); - - let pool_post = post_states[1].clone(); - let pool_def = PoolDefinition::try_from(&pool_post.account().data).unwrap(); - assert_eq!(pool_def.fees, fees); - } -} - -#[should_panic(expected = "Fee tier must be one of 1, 5, 30, or 100 basis points")] -#[test] -fn test_new_definition_rejects_unsupported_fee_tier() { - let _ = new_definition( - AccountWithMetadataForTests::config_init(), - AccountWithMetadataForTests::pool_definition_uninit(), - AccountWithMetadataForTests::vault_a_init(), - AccountWithMetadataForTests::vault_b_init(), - AccountWithMetadataForTests::pool_lp_uninit(), - AccountWithMetadataForTests::lp_lock_holding_uninit(), - AccountWithMetadataForTests::user_holding_a(), - AccountWithMetadataForTests::user_holding_b(), - AccountWithMetadataForTests::user_holding_lp_uninit(), - AccountWithMetadataForTests::current_tick_account_uninit(), - AccountWithMetadataForTests::clock(), - NonZero::new(BalanceForTests::vault_a_reserve_init()).unwrap(), - NonZero::new(BalanceForTests::vault_b_reserve_init()).unwrap(), - 2, - AMM_PROGRAM_ID, - ); -} - // --- Token program ownership validation tests --- #[should_panic(expected = "User Token A holding must be owned by the configured Token Program")] diff --git a/programs/amm/src/update_config.rs b/programs/amm/src/update_config.rs index 15ba6d07..2ef07f41 100644 --- a/programs/amm/src/update_config.rs +++ b/programs/amm/src/update_config.rs @@ -92,6 +92,7 @@ mod tests { token_program_id: TOKEN_PROGRAM_ID, twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, authority: admin_id(), + swap_fee_bps: amm_core::FEE_TIER_BPS_30, }), nonce: Nonce(0), }, diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index e887107a..6569b426 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -3,10 +3,7 @@ reason = "integration fixtures use fixed balances to assert AMM state transitions" )] -use amm_core::{ - PoolDefinition, FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, - MINIMUM_LIQUIDITY, -}; +use amm_core::{PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY}; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; use lee::{ error::LeeError, @@ -395,6 +392,8 @@ impl Accounts { token_program_id: Ids::token_program(), twap_oracle_program_id: Ids::twap_oracle_program(), authority: Ids::admin(), + // The swap fee is now instance-wide, moved off the pool. + swap_fee_bps: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -451,7 +450,6 @@ impl Accounts { liquidity_pool_supply: Balances::pool_lp_supply_init(), reserve_a: Balances::vault_a_init(), reserve_b: Balances::vault_b_init(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -562,7 +560,6 @@ impl Accounts { liquidity_pool_supply: Balances::pool_lp_supply_init(), reserve_a: Balances::reserve_a_swap_1(), reserve_b: Balances::reserve_b_swap_1(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -629,7 +626,6 @@ impl Accounts { liquidity_pool_supply: Balances::pool_lp_supply_init(), reserve_a: Balances::reserve_a_swap_2(), reserve_b: Balances::reserve_b_swap_2(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -696,7 +692,6 @@ impl Accounts { liquidity_pool_supply: Balances::pool_lp_supply_init(), reserve_a: Balances::reserve_a_swap_exact_output_a_to_b(), reserve_b: Balances::reserve_b_swap_exact_output_a_to_b(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -763,7 +758,6 @@ impl Accounts { liquidity_pool_supply: Balances::pool_lp_supply_init(), reserve_a: Balances::reserve_a_swap_exact_output_b_to_a(), reserve_b: Balances::reserve_b_swap_exact_output_b_to_a(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -830,7 +824,6 @@ impl Accounts { liquidity_pool_supply: Balances::token_lp_supply_add(), reserve_a: Balances::vault_a_add(), reserve_b: Balances::vault_b_add(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -923,7 +916,6 @@ impl Accounts { liquidity_pool_supply: Balances::token_lp_supply_remove(), reserve_a: Balances::vault_a_remove(), reserve_b: Balances::vault_b_remove(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -1003,20 +995,6 @@ impl Accounts { } } - fn token_lp_definition_reinitializable() -> Account { - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenDefinition::Fungible { - name: String::from("LP Token"), - total_supply: 0, - metadata_id: None, - authority: Some(Ids::token_lp_definition()), - }), - nonce: Nonce(0), - } - } - fn vault_a_reinitializable() -> Account { Account { program_owner: Ids::token_program(), @@ -1041,25 +1019,6 @@ impl Accounts { } } - fn pool_definition_zero_supply_reinitializable() -> Account { - Account { - program_owner: Ids::amm_program(), - balance: 0_u128, - data: Data::from(&PoolDefinition { - definition_token_a_id: Ids::token_a_definition(), - definition_token_b_id: Ids::token_b_definition(), - vault_a_id: Ids::vault_a(), - vault_b_id: Ids::vault_b(), - liquidity_pool_id: Ids::token_lp_definition(), - liquidity_pool_supply: 0, - reserve_a: 0, - reserve_b: 0, - fees: Balances::fee_tier(), - }), - nonce: Nonce(0), - } - } - fn user_a_holding_new_init() -> Account { Account { program_owner: Ids::token_program(), @@ -1135,7 +1094,6 @@ impl Accounts { liquidity_pool_supply: Balances::lp_supply_init(), reserve_a: Balances::vault_a_init(), reserve_b: Balances::vault_b_init(), - fees: Balances::fee_tier(), }), nonce: Nonce(0), } @@ -1246,13 +1204,11 @@ fn state_for_amm_tests_with_precreated_user_lp_for_new_def() -> V03State { #[cfg(test)] fn try_execute_new_definition( state: &mut V03State, - fees: u128, authorize_user_lp: bool, ) -> Result<(), LeeError> { let instruction = amm_core::Instruction::NewDefinition { token_a_amount: Balances::vault_a_init(), token_b_amount: Balances::vault_b_init(), - fees, deadline: u64::MAX, }; @@ -1301,8 +1257,8 @@ fn try_execute_new_definition( } #[cfg(test)] -fn execute_new_definition(state: &mut V03State, fees: u128) { - try_execute_new_definition(state, fees, true).unwrap(); +fn execute_new_definition(state: &mut V03State) { + try_execute_new_definition(state, true).unwrap(); } #[cfg(test)] @@ -1477,6 +1433,7 @@ fn execute_initialize_for( token_program_id: Ids::token_program(), twap_oracle_program_id: Ids::twap_oracle_program(), authority: Ids::admin(), + swap_fee_bps: Balances::fee_tier(), }; let message = public_transaction::Message::try_new( @@ -1543,7 +1500,6 @@ fn execute_new_definition_in( let instruction = amm_core::Instruction::NewDefinition { token_a_amount: Balances::vault_a_init(), token_b_amount: Balances::vault_b_init(), - fees: Balances::fee_tier(), deadline: u64::MAX, }; @@ -1674,7 +1630,7 @@ fn state_with_pool_created_via_new_definition() -> V03State { let mut state = state_for_amm_tests_with_new_def(); state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - execute_new_definition(&mut state, Balances::fee_tier()); + execute_new_definition(&mut state); state } @@ -1838,6 +1794,7 @@ fn amm_initialize_requires_owner_signature() { token_program_id: Ids::token_program(), twap_oracle_program_id: Ids::twap_oracle_program(), authority: Ids::admin(), + swap_fee_bps: Balances::fee_tier(), }; // The owner account is declared, but no signature (and no nonce) is supplied for it. @@ -2744,7 +2701,7 @@ fn amm_new_definition_uninitialized_pool() { state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - execute_new_definition(&mut state, Balances::fee_tier()); + execute_new_definition(&mut state); assert_eq!( state.get_account_by_id(Ids::pool_definition()), @@ -2799,7 +2756,7 @@ fn amm_new_definition_without_user_lp_authorization_fails() { state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - let result = try_execute_new_definition(&mut state, Balances::fee_tier(), false); + let result = try_execute_new_definition(&mut state, false); assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_)))); assert_eq!( @@ -2841,7 +2798,7 @@ fn amm_new_definition_precreated_user_lp_unsigned_fails() { state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - let result = try_execute_new_definition(&mut state, Balances::fee_tier(), false); + let result = try_execute_new_definition(&mut state, false); assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_)))); assert_eq!( @@ -2870,75 +2827,6 @@ fn amm_new_definition_precreated_user_lp_unsigned_fails() { ); } -#[test] -fn amm_new_definition_supports_all_fee_tiers() { - for fees in [ - FEE_TIER_BPS_1, - FEE_TIER_BPS_5, - FEE_TIER_BPS_30, - FEE_TIER_BPS_100, - ] { - let mut state = state_for_amm_tests_with_new_def(); - state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); - state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - - execute_new_definition(&mut state, fees); - - let pool_definition = - PoolDefinition::try_from(&state.get_account_by_id(Ids::pool_definition()).data) - .expect("new definition should create a valid pool"); - assert_eq!(pool_definition.fees, fees); - } -} - -#[test] -fn amm_new_definition_rejects_unsupported_fee_tier_transaction() { - let mut state = state_for_amm_tests_with_precreated_user_lp_for_new_def(); - state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); - state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - state.force_insert_account( - Ids::pool_definition(), - Accounts::pool_definition_zero_supply_reinitializable(), - ); - state.force_insert_account( - Ids::token_lp_definition(), - Accounts::token_lp_definition_reinitializable(), - ); - - // `user_holding_lp` is signed so the rejection isolates the unsupported fee tier. - let result = try_execute_new_definition(&mut state, 2, true); - - assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_)))); - assert_eq!( - state.get_account_by_id(Ids::pool_definition()), - Accounts::pool_definition_zero_supply_reinitializable() - ); - assert_eq!( - state.get_account_by_id(Ids::vault_a()), - Accounts::vault_a_reinitializable() - ); - assert_eq!( - state.get_account_by_id(Ids::vault_b()), - Accounts::vault_b_reinitializable() - ); - assert_eq!( - state.get_account_by_id(Ids::token_lp_definition()), - Accounts::token_lp_definition_reinitializable() - ); - assert_eq!( - state.get_account_by_id(Ids::user_a()), - Accounts::user_a_holding() - ); - assert_eq!( - state.get_account_by_id(Ids::user_b()), - Accounts::user_b_holding() - ); - assert_eq!( - state.get_account_by_id(Ids::user_lp()), - Accounts::user_lp_holding_init_zero() - ); -} - #[test] fn amm_add_liquidity() { let mut state = state_for_amm_tests(); @@ -3485,7 +3373,8 @@ fn amm_fee_accumulates_across_multiple_swaps_and_pays_out_on_remove() { let pool_before_remove = pool_definition(&state.get_account_by_id(Ids::pool_definition())); assert_eq!(pool_before_remove.reserve_a, 4_060); assert_eq!(pool_before_remove.reserve_b, 3_085); - assert_eq!(pool_before_remove.fees, Balances::fee_tier()); + // The swap fee is instance-wide now (AmmConfig::swap_fee_bps), not stored on the pool. + assert_eq!(config_data(&state).swap_fee_bps, Balances::fee_tier()); let vault_a_before_remove = fungible_balance(&state.get_account_by_id(Ids::vault_a())); let vault_b_before_remove = fungible_balance(&state.get_account_by_id(Ids::vault_b())); @@ -3698,7 +3587,6 @@ fn amm_new_definition_rejects_expired_deadline() { let instruction = amm_core::Instruction::NewDefinition { token_a_amount: Balances::vault_a_init(), token_b_amount: Balances::vault_b_init(), - fees: amm_core::FEE_TIER_BPS_30, deadline: deadline_ms, };