feat(amm-ui): add new position backend API #222

This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-10 03:19:41 -03:00 committed by r4bbit
parent 8db90fda35
commit de1c239d80
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
6 changed files with 1341 additions and 100 deletions

View File

@ -87,6 +87,7 @@ Item {
LiquidityPage {
anchors.fill: parent
backend: root.ready ? root.backend : null
visible: navbar.currentIndex === 1
}

View File

@ -2,13 +2,46 @@ import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../shared"
import "../../state"
Rectangle {
id: root
required property NewPositionPrototypeBackend prototypeBackend
property var newPositionContext: ({})
property var quote: ({
"status": "error",
"error": "",
"poolStatus": "unavailable_pool",
"statusLabel": qsTr("Unavailable"),
"statusDetail": qsTr("Connect a wallet to preview this position."),
"instruction": "",
"storedFeeBps": 0,
"feeBps": root.selectedFeeBps,
"feeLabel": root.feeLabel(root.selectedFeeBps),
"quoteHash": "",
"pool": {
"id": "",
"priceText": "",
"reserveText": ""
},
"deposit": {
"maxA": root.amountValue(0, root.tokenA.symbol),
"maxB": root.amountValue(0, root.tokenB.symbol),
"actualA": root.amountValue(0, root.tokenA.symbol),
"actualB": root.amountValue(0, root.tokenB.symbol)
},
"lp": {
"expected": root.amountValue(0, "LP"),
"minimum": root.amountValue(0, "LP"),
"locked": root.amountValue(0, "LP")
},
"position": {
"userLp": "0 LP",
"share": "-",
"ownedA": root.formatTokenAmount(0, root.tokenA.symbol),
"ownedB": root.formatTokenAmount(0, root.tokenB.symbol)
},
"accountChanges": []
})
property int selectedTokenAIndex: 0
property int selectedTokenBIndex: 1
property int selectedFeeBps: 30
@ -19,6 +52,7 @@ Rectangle {
property string initialPrice: "2500"
property int depositScale: 1
property string submitError: ""
property bool applyingQuote: false
readonly property var emptyToken: ({
"symbol": "",
@ -26,20 +60,21 @@ Rectangle {
"balanceText": "",
"accent": "#343434"
})
readonly property var holdings: root.prototypeBackend ? root.prototypeBackend.holdings : []
readonly property var holdings: root.newPositionContext && root.newPositionContext.holdings ? root.newPositionContext.holdings : []
readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers ? root.newPositionContext.feeTiers : []
readonly property string activeAccount: root.newPositionContext && root.newPositionContext.activeAccountDisplay ? root.newPositionContext.activeAccountDisplay : qsTr("Not connected")
readonly property var tokenA: root.holdings[root.selectedTokenAIndex] || root.emptyToken
readonly property var tokenB: root.holdings[root.selectedTokenBIndex] || root.emptyToken
readonly property var poolContext: root.prototypeBackend.poolContext(root.tokenA.symbol, root.tokenB.symbol)
readonly property string poolStatus: root.poolContext.poolStatus
readonly property string poolStatus: root.quote.poolStatus || "unavailable_pool"
readonly property bool activePool: root.poolStatus === "active_pool"
readonly property bool missingPool: root.poolStatus === "missing_pool"
readonly property int slippageBps: Math.round(root.slippageTolerancePercent * 100)
readonly property var quote: root.prototypeBackend.quoteNewPosition(root.buildRequest())
readonly property bool canConfirm: root.quote.status === "ok"
readonly property bool compact: root.width < 820
readonly property bool hasActiveInput: root.amountA.length > 0 || root.amountB.length > 0
readonly property bool showQuoteError: root.quote.status === "error" && (root.poolStatus === "unavailable_pool" || root.missingPool || root.hasActiveInput)
signal quoteRequested(var request)
signal confirmationRequested(var snapshot)
color: "#1B1B1B"
@ -50,9 +85,15 @@ Rectangle {
Component.onCompleted: root.afterPairChanged()
onNewPositionContextChanged: root.afterPairChanged()
onSelectedTokenAIndexChanged: root.afterPairChanged()
onSelectedTokenBIndexChanged: root.afterPairChanged()
onPoolStatusChanged: root.normalizeFeeSelection()
onSelectedFeeBpsChanged: root.requestQuote()
onSlippageTolerancePercentChanged: root.requestQuote()
onInitialPriceChanged: root.requestQuote()
onDepositScaleChanged: root.requestQuote()
onQuoteChanged: root.applyQuoteSideEffects()
ColumnLayout {
id: content
@ -84,7 +125,7 @@ Rectangle {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 12
text: qsTr("Active account %1").arg(root.prototypeBackend.activeAccount)
text: qsTr("Active account %1").arg(root.activeAccount)
Layout.fillWidth: true
}
@ -215,7 +256,7 @@ Rectangle {
}
Repeater {
model: root.prototypeBackend.holdings
model: root.holdings
delegate: Button {
id: tokenAButton
@ -298,7 +339,7 @@ Rectangle {
}
Repeater {
model: root.prototypeBackend.holdings
model: root.holdings
delegate: Button {
id: tokenBButton
@ -385,7 +426,7 @@ Rectangle {
Layout.fillWidth: true
Repeater {
model: root.prototypeBackend.feeTiers
model: root.feeTiers
delegate: Rectangle {
id: feeChip
@ -830,7 +871,7 @@ Rectangle {
label: qsTr("Expected LP")
value: root.quote.lp.expected.display
estimated: true
estimateHelp: qsTr("Prototype quote mirrors the future backend response shape.")
estimateHelp: qsTr("Backend preview quote. Final submission rechecks the quote hash.")
Layout.fillWidth: true
}
@ -1000,30 +1041,111 @@ Rectangle {
return root.holdings.length > 0 ? (index + 1) % root.holdings.length : 0;
}
function requestQuote() {
root.quoteRequested(root.buildRequest());
}
function parseAmount(value) {
const parsed = Number(String(value).replace(/,/g, ""));
return isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function formatAmount(value) {
const amount = Math.max(0, Number(value) || 0);
if (amount >= 1000)
return amount.toFixed(2).replace(/\.00$/, "");
if (amount >= 1)
return amount.toFixed(4).replace(/0+$/, "").replace(/[.]$/, "");
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
}
function formatTokenAmount(value, symbol) {
return qsTr("%1 %2").arg(root.formatAmount(value)).arg(symbol);
}
function amountValue(value, symbol) {
const amount = Math.max(0, Number(value) || 0);
return {
"value": amount,
"input": root.formatAmount(amount),
"display": root.formatTokenAmount(amount, symbol),
"symbol": symbol
};
}
function feeLabel(bps) {
if (bps === 1)
return "0.01%";
if (bps === 5)
return "0.05%";
if (bps === 30)
return "0.30%";
if (bps === 100)
return "1.00%";
return qsTr("%1 bps").arg(bps);
}
function activeRatio(symbolA, symbolB) {
if (symbolA === "USDC" && symbolB === "LOGOS")
return 8;
if (symbolA === "LOGOS" && symbolB === "USDC")
return 0.125;
return 1;
}
function defaultInitialPrice(symbolA, symbolB) {
if (symbolA === "USDC" && symbolB === "WETH")
return 2500;
if (symbolA === "WETH" && symbolB === "USDC")
return 0.0004;
return 1;
}
function afterPairChanged() {
if (root.holdings.length < 2) {
root.selectedTokenAIndex = 0;
root.selectedTokenBIndex = 0;
root.submitError = "";
root.requestQuote();
return;
}
if (root.selectedTokenAIndex >= root.holdings.length)
root.selectedTokenAIndex = 0;
if (root.selectedTokenBIndex >= root.holdings.length)
root.selectedTokenBIndex = root.nextTokenIndex(root.selectedTokenAIndex);
if (root.selectedTokenAIndex === root.selectedTokenBIndex)
root.selectedTokenBIndex = root.nextTokenIndex(root.selectedTokenAIndex);
if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0)
if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0) {
root.requestQuote();
return;
}
root.submitError = "";
root.amountA = "";
root.amountB = "";
root.editedSide = "A";
root.initialPrice = root.prototypeBackend.formatAmount(root.prototypeBackend.defaultInitialPrice(root.tokenA.symbol, root.tokenB.symbol));
root.initialPrice = root.formatAmount(root.defaultInitialPrice(root.tokenA.symbol, root.tokenB.symbol));
root.depositScale = 1;
root.normalizeFeeSelection();
root.requestQuote();
}
function normalizeFeeSelection() {
if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0)
return;
const context = root.prototypeBackend.poolContext(root.tokenA.symbol, root.tokenB.symbol);
if (context.poolStatus === "active_pool") {
root.selectedFeeBps = context.storedFeeBps;
if (root.poolStatus === "active_pool" && root.quote.storedFeeBps > 0) {
root.selectedFeeBps = root.quote.storedFeeBps;
return;
}
@ -1040,8 +1162,8 @@ Rectangle {
if (!tier.supported)
return qsTr("Unsupported by the AMM program. Valid fee tiers are 0.01%, 0.05%, 0.30%, and 1.00%.");
if (root.poolStatus === "active_pool" && tier.bps !== root.poolContext.storedFeeBps)
return qsTr("Existing pool uses %1. Fee tier is fixed by pool configuration.").arg(root.prototypeBackend.feeLabel(root.poolContext.storedFeeBps));
if (root.poolStatus === "active_pool" && tier.bps !== root.quote.storedFeeBps)
return qsTr("Existing pool uses %1. Fee tier is fixed by pool configuration.").arg(root.feeLabel(root.quote.storedFeeBps));
if (root.poolStatus === "unavailable_pool")
return qsTr("Fee selection is disabled until this pair can be quoted.");
@ -1058,30 +1180,31 @@ Rectangle {
else
root.amountB = value;
root.syncActiveCounterpart();
root.requestQuote();
}
function syncActiveCounterpart() {
const nextQuote = root.prototypeBackend.quoteNewPosition(root.buildRequest());
if (nextQuote.status !== "ok" || nextQuote.poolStatus !== "active_pool")
function applyQuoteSideEffects() {
root.normalizeFeeSelection();
if (root.quote.status !== "ok" || root.quote.poolStatus !== "active_pool")
return;
root.applyingQuote = true;
if (root.editedSide === "A")
root.amountB = nextQuote.deposit.maxB.input;
root.amountB = root.quote.deposit.maxB.input;
else
root.amountA = nextQuote.deposit.maxA.input;
root.amountA = root.quote.deposit.maxA.input;
root.applyingQuote = false;
}
function useMaxActive(side) {
const ratio = root.prototypeBackend.activeRatio(root.tokenA.symbol, root.tokenB.symbol);
const ratio = root.activeRatio(root.tokenA.symbol, root.tokenB.symbol);
const maxA = Math.min(root.tokenA.balance, root.tokenB.balance / ratio);
const maxB = Math.min(root.tokenB.balance, root.tokenA.balance * ratio);
if (side === "A")
root.editActiveAmount("A", root.prototypeBackend.formatAmount(maxA));
root.editActiveAmount("A", root.formatAmount(maxA));
else
root.editActiveAmount("B", root.prototypeBackend.formatAmount(maxB));
root.editActiveAmount("B", root.formatAmount(maxB));
}
function setSubmitError(message) {

View File

@ -1,11 +1,17 @@
import QtQuick 2.15
import QtQml 2.15
import "../components/shared"
import "../components/liquidity"
import "../state"
Item {
id: root
property var backend: null
property var newPositionContext: ({})
property var newPositionQuote: ({})
property int quoteSerial: 0
property bool formReady: false
readonly property int pageMargin: 16
readonly property int preferredCardWidth: 960
readonly property int pageCardY: newPositionForm.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.max(root.pageMargin, Math.round((scroll.height - newPositionForm.implicitHeight) / 4)) : root.pageMargin
@ -15,8 +21,17 @@ Item {
implicitWidth: root.preferredCardWidth + root.pageMargin * 2
implicitHeight: newPositionForm.implicitHeight + root.pageMargin * 2
NewPositionPrototypeBackend {
id: newPositionBackend
Component.onCompleted: root.refreshNewPositionContext()
onBackendChanged: root.refreshNewPositionContext()
Connections {
target: root.backend
ignoreUnknownSignals: true
function onNewPositionContextChanged(value) {
root.newPositionContext = value;
root.requestQuote(root.currentRequest());
}
}
Rectangle {
@ -37,11 +52,21 @@ Item {
NewPositionForm {
id: newPositionForm
prototypeBackend: newPositionBackend
newPositionContext: root.newPositionContext
quote: root.newPositionQuote
width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth))
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
y: root.pageCardY
Component.onCompleted: {
root.formReady = true;
root.refreshNewPositionContext();
}
onQuoteRequested: function (request) {
root.requestQuote(request);
}
onConfirmationRequested: function (snapshot) {
confirmationDialog.openWithSnapshot(snapshot);
}
@ -72,14 +97,128 @@ Item {
}
function confirmNewPosition(snapshot) {
const result = newPositionBackend.submitNewPosition(snapshot.request, snapshot.quoteHash);
if (result.status !== "ok") {
newPositionForm.setSubmitError(result.error);
if (!root.backend || typeof logos === "undefined") {
newPositionForm.setSubmitError(qsTr("Wallet backend is unavailable."));
return;
}
newPositionForm.resetAfterSubmit();
successToast.show(result.message, result.detail);
logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
function(result) {
if (result.status !== "ok") {
newPositionForm.setSubmitError(result.error);
return;
}
newPositionForm.resetAfterSubmit();
successToast.show(result.message, result.detail);
},
function(error) {
newPositionForm.setSubmitError(qsTr("Error submitting position: %1").arg(error));
});
}
function refreshNewPositionContext() {
if (!root.backend || typeof logos === "undefined") {
root.newPositionContext = {
"activeAccountDisplay": qsTr("Not connected"),
"holdings": [],
"feeTiers": []
};
root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), root.currentRequest());
return;
}
logos.watch(root.backend.refreshNewPositionContext(),
function(context) {
root.newPositionContext = context;
root.requestQuote(root.currentRequest());
},
function(error) {
root.newPositionQuote = root.errorQuote(qsTr("Error loading position context: %1").arg(error), root.currentRequest());
});
}
function requestQuote(request) {
if (!root.backend || typeof logos === "undefined") {
root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), request);
return;
}
const serial = ++root.quoteSerial;
logos.watch(root.backend.quoteNewPosition(request),
function(quote) {
if (serial === root.quoteSerial)
root.newPositionQuote = quote;
},
function(error) {
if (serial === root.quoteSerial)
root.newPositionQuote = root.errorQuote(qsTr("Error loading quote: %1").arg(error), request);
});
}
function amountValue(symbol) {
return {
"value": 0,
"input": "0",
"display": qsTr("0 %1").arg(symbol),
"symbol": symbol
};
}
function currentRequest() {
if (root.formReady)
return newPositionForm.buildRequest();
return {
"amountA": "",
"amountB": "",
"depositScale": 1,
"editedSide": "A",
"feeBps": 30,
"initialPrice": "1",
"slippageBps": 50,
"tokenA": "",
"tokenB": ""
};
}
function errorQuote(message, request) {
const tokenA = request ? request.tokenA : "";
const tokenB = request ? request.tokenB : "";
return {
"status": "error",
"error": message,
"poolStatus": "unavailable_pool",
"statusLabel": qsTr("Unavailable"),
"statusDetail": message,
"instruction": "",
"storedFeeBps": 0,
"feeBps": request ? request.feeBps : 0,
"feeLabel": "",
"quoteHash": "",
"pool": {
"id": "",
"priceText": "",
"reserveText": ""
},
"deposit": {
"maxA": root.amountValue(tokenA),
"maxB": root.amountValue(tokenB),
"actualA": root.amountValue(tokenA),
"actualB": root.amountValue(tokenB)
},
"lp": {
"expected": root.amountValue("LP"),
"minimum": root.amountValue("LP"),
"locked": root.amountValue("LP")
},
"position": {
"userLp": "0 LP",
"share": "-",
"ownedA": qsTr("0 %1").arg(tokenA),
"ownedB": qsTr("0 %1").arg(tokenB)
},
"accountChanges": []
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +1,84 @@
#ifndef AMM_UI_BACKEND_H
#define AMM_UI_BACKEND_H
#include <memory>
#include <QObject>
#include <QString>
#include <QVariant>
#include "rep_AmmUiBackend_source.h"
#include "WalletAccountModel.h"
#include "AccountModel.h"
class LogosAPI;
class LogosWalletProvider;
class WalletController;
struct LogosModules;
class QNetworkAccessManager;
class QTimer;
// Source-side implementation of the AmmUiBackend .rep interface.
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to
// the core logos_execution_zone wallet module via LogosModules.
class AmmUiBackend : public AmmUiBackendSimpleSource {
Q_OBJECT
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
public:
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
~AmmUiBackend() override;
WalletAccountModel* accountModel() const;
AccountModel* accountModel() const { return m_accountModel; }
public slots:
// Overrides of the pure-virtual slots generated from the .rep.
QString createAccountPublic() override;
QString createAccountPrivate() override;
void refreshAccounts() override;
void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override;
QVariant refreshNewPositionContext() override;
QVariant quoteNewPosition(QVariant request) override;
QVariant submitNewPosition(QVariant request, QString quoteHash) override;
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
// UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override;
QString createNew(QString configPath, QString storagePath, QString password) override;
bool openExisting() override;
void disconnectWallet() override;
bool changeSequencerAddr(QString url) override;
void copyToClipboard(QString text) override;
private:
void syncWalletState();
// Per-app wallet home (kept distinct from the wallet's canonical
// ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing
// is handled by adopting an already-open shared wallet on startup).
static QString defaultWalletHome();
QString defaultConfigPath() const;
QString defaultStoragePath() const;
void persistConfigPath(const QString& path);
void persistStoragePath(const QString& path);
void openOrAdoptWallet();
// True when the shared core already has a wallet open — including a freshly
// created one with zero accounts. See the definition for why list_accounts()
// alone is insufficient.
bool sharedWalletIsOpen();
void refreshBlockHeights();
void refreshSequencerAddr();
void saveWallet();
QString activeAccountAddress() const;
QVariantMap buildNewPositionContext() const;
QVariantMap quoteNewPositionMap(const QVariantMap& request) const;
// Probe the configured sequencer over HTTP and update sequencerReachable.
void checkReachability();
AccountModel* m_accountModel;
LogosAPI* m_logosAPI;
std::unique_ptr<LogosWalletProvider> m_wallet;
std::unique_ptr<WalletController> m_walletController;
LogosModules* m_logos;
QNetworkAccessManager* m_net;
QTimer* m_reachabilityTimer;
};
#endif // AMM_UI_BACKEND_H

View File

@ -23,6 +23,14 @@ class AmmUiBackend
SLOT(void refreshBalances())
SLOT(QString getBalance(QString accountIdHex, bool isPublic))
// New Position backend surface. QML calls these through logos.watch(...).
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
// transactions or duplicates quote state.
PROP(QVariant newPositionContext READONLY)
SLOT(QVariant refreshNewPositionContext())
SLOT(QVariant quoteNewPosition(QVariant request))
SLOT(QVariant submitNewPosition(QVariant request, QString quoteHash))
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh per-app wallet at walletHome with no path picking. createNew()
// keeps explicit paths for an "advanced" flow. Both return the new wallet's