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
parent 07533bc799
commit b288b543ed
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
6 changed files with 921 additions and 40 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": []
};
}
}

View File

@ -2,6 +2,7 @@
#include <QClipboard>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDebug>
#include <QDir>
#include <QFile>
@ -20,6 +21,9 @@
#include "logos_api.h"
#include "logos_sdk.h"
#include <algorithm>
#include <cmath>
namespace {
const char SETTINGS_ORG[] = "Logos";
const char SETTINGS_APP[] = "AmmUI";
@ -38,6 +42,386 @@ namespace {
return QUrl::fromUserInput(path).toLocalFile();
return path;
}
QString stableId(const QString& key)
{
const QByteArray digest =
QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256).toHex();
return QString::fromLatin1(digest);
}
QString shortId(const QString& id)
{
if (id.length() <= 14)
return id;
return id.left(6) + QStringLiteral("...") + id.right(4);
}
double parsePositiveAmount(QString value)
{
value.remove(QLatin1Char(','));
bool ok = false;
const double parsed = value.toDouble(&ok);
return ok && std::isfinite(parsed) && parsed > 0.0 ? parsed : 0.0;
}
QString formatAmount(double amount)
{
amount = std::max(0.0, amount);
const int decimals = amount >= 1000.0 ? 2 : amount >= 1.0 ? 4 : 6;
QString text = QString::number(amount, 'f', decimals);
while (text.contains(QLatin1Char('.')) && text.endsWith(QLatin1Char('0')))
text.chop(1);
if (text.endsWith(QLatin1Char('.')))
text.chop(1);
return text;
}
QString formatTokenAmount(double amount, const QString& symbol)
{
return QStringLiteral("%1 %2").arg(formatAmount(amount), symbol);
}
QVariantMap amountValue(double amount, const QString& symbol)
{
QVariantMap value;
value.insert(QStringLiteral("value"), amount);
value.insert(QStringLiteral("input"), formatAmount(amount));
value.insert(QStringLiteral("display"), formatTokenAmount(amount, symbol));
value.insert(QStringLiteral("symbol"), symbol);
return value;
}
QString feeLabel(int bps)
{
if (bps == 1)
return QStringLiteral("0.01%");
if (bps == 5)
return QStringLiteral("0.05%");
if (bps == 30)
return QStringLiteral("0.30%");
if (bps == 100)
return QStringLiteral("1.00%");
return QStringLiteral("%1 bps").arg(bps);
}
bool isSupportedFeeTier(int bps)
{
return bps == 1 || bps == 5 || bps == 30 || bps == 100;
}
QVariantList feeTiers()
{
QVariantList tiers;
for (const int bps : {1, 5, 25, 30, 100}) {
QVariantMap tier;
tier.insert(QStringLiteral("bps"), bps);
tier.insert(QStringLiteral("label"), feeLabel(bps));
tier.insert(QStringLiteral("supported"), isSupportedFeeTier(bps));
tiers.append(tier);
}
return tiers;
}
double devnetBalance(const QString& symbol)
{
if (symbol == QStringLiteral("USDC"))
return 12450.0;
if (symbol == QStringLiteral("LOGOS"))
return 850000.0;
if (symbol == QStringLiteral("WETH"))
return 3.25;
return 0.0;
}
QString accentForSymbol(const QString& symbol)
{
if (symbol == QStringLiteral("USDC"))
return QStringLiteral("#2E7CF6");
if (symbol == QStringLiteral("LOGOS"))
return QStringLiteral("#F26A21");
if (symbol == QStringLiteral("WETH"))
return QStringLiteral("#B7C2D8");
return QStringLiteral("#343434");
}
QVariantMap devnetHolding(const QString& owner, const QString& symbol, const QString& name)
{
const double balance = devnetBalance(symbol);
const QString definitionId = stableId(QStringLiteral("devnet:token-definition:%1").arg(symbol));
QVariantMap holding;
holding.insert(QStringLiteral("symbol"), symbol);
holding.insert(QStringLiteral("name"), name);
holding.insert(QStringLiteral("definitionId"), definitionId);
holding.insert(QStringLiteral("holdingId"),
stableId(QStringLiteral("devnet:token-holding:%1:%2").arg(owner, symbol)));
holding.insert(QStringLiteral("balance"), balance);
holding.insert(QStringLiteral("balanceText"), formatTokenAmount(balance, symbol));
holding.insert(QStringLiteral("accent"), accentForSymbol(symbol));
return holding;
}
QVariantList devnetHoldings(const QString& owner)
{
if (owner.isEmpty())
return {};
QVariantList holdings;
holdings.append(devnetHolding(owner, QStringLiteral("USDC"), QStringLiteral("USD Coin")));
holdings.append(devnetHolding(owner, QStringLiteral("LOGOS"), QStringLiteral("Logos")));
holdings.append(devnetHolding(owner, QStringLiteral("WETH"), QStringLiteral("Wrapped Ether")));
return holdings;
}
QVariantMap holdingBySymbol(const QVariantList& holdings, const QString& symbol)
{
for (const QVariant& item : holdings) {
const QVariantMap holding = item.toMap();
if (holding.value(QStringLiteral("symbol")).toString() == symbol)
return holding;
}
return {};
}
QString unorderedPairKey(const QString& symbolA, const QString& symbolB)
{
return symbolA < symbolB
? QStringLiteral("%1/%2").arg(symbolA, symbolB)
: QStringLiteral("%1/%2").arg(symbolB, symbolA);
}
double activeRatio(const QString& symbolA, const QString& symbolB)
{
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("LOGOS"))
return 8.0;
if (symbolA == QStringLiteral("LOGOS") && symbolB == QStringLiteral("USDC"))
return 0.125;
return 1.0;
}
double defaultInitialPrice(const QString& symbolA, const QString& symbolB)
{
if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("WETH"))
return 2500.0;
if (symbolA == QStringLiteral("WETH") && symbolB == QStringLiteral("USDC"))
return 0.0004;
return 1.0;
}
QVariantMap poolContext(const QString& symbolA, const QString& symbolB)
{
QVariantMap context;
context.insert(QStringLiteral("poolStatus"), QStringLiteral("unavailable_pool"));
context.insert(QStringLiteral("statusLabel"), QStringLiteral("Unavailable"));
context.insert(QStringLiteral("detail"), QStringLiteral("Choose two different assets from the active account."));
context.insert(QStringLiteral("instruction"), QString());
context.insert(QStringLiteral("storedFeeBps"), 0);
context.insert(QStringLiteral("poolId"), QString());
context.insert(QStringLiteral("priceText"), QString());
context.insert(QStringLiteral("reserveText"), QString());
if (symbolA.isEmpty() || symbolB.isEmpty() || symbolA == symbolB)
return context;
const QString pairKey = unorderedPairKey(symbolA, symbolB);
const QString poolId = stableId(QStringLiteral("devnet:amm-pool:%1").arg(pairKey));
context.insert(QStringLiteral("poolId"), poolId);
if (pairKey == QStringLiteral("LOGOS/USDC")) {
context.insert(QStringLiteral("poolStatus"), QStringLiteral("active_pool"));
context.insert(QStringLiteral("statusLabel"), QStringLiteral("Active pool"));
context.insert(QStringLiteral("detail"), QStringLiteral("Deposits are quoted against the existing pool ratio. Nonmatching fee tiers are locked."));
context.insert(QStringLiteral("instruction"), QStringLiteral("add_liquidity"));
context.insert(QStringLiteral("storedFeeBps"), 30);
context.insert(QStringLiteral("priceText"),
symbolA == QStringLiteral("USDC")
? QStringLiteral("1 USDC = 8 LOGOS")
: QStringLiteral("1 LOGOS = 0.125 USDC"));
context.insert(QStringLiteral("reserveText"),
symbolA == QStringLiteral("USDC")
? QStringLiteral("1,250,000 USDC / 10,000,000 LOGOS")
: QStringLiteral("10,000,000 LOGOS / 1,250,000 USDC"));
return context;
}
if (pairKey == QStringLiteral("USDC/WETH")) {
context.insert(QStringLiteral("poolStatus"), QStringLiteral("missing_pool"));
context.insert(QStringLiteral("statusLabel"), QStringLiteral("Missing pool"));
context.insert(QStringLiteral("detail"), QStringLiteral("Set the initial price first, then scale both deposits together."));
context.insert(QStringLiteral("instruction"), QStringLiteral("new_definition"));
context.insert(QStringLiteral("priceText"), QStringLiteral("No reserves yet"));
context.insert(QStringLiteral("reserveText"), QStringLiteral("Pool account is empty"));
return context;
}
context.insert(QStringLiteral("detail"), QStringLiteral("A pool account exists, but it cannot be quoted safely for this devnet state."));
context.insert(QStringLiteral("priceText"), QStringLiteral("Quote disabled"));
context.insert(QStringLiteral("reserveText"), QStringLiteral("Unsupported stored pool state"));
return context;
}
QString quoteHash(const QVariantMap& request)
{
const QStringList parts = {
request.value(QStringLiteral("tokenA")).toString(),
request.value(QStringLiteral("tokenB")).toString(),
QString::number(request.value(QStringLiteral("feeBps")).toInt()),
request.value(QStringLiteral("editedSide")).toString(),
request.value(QStringLiteral("amountA")).toString(),
request.value(QStringLiteral("amountB")).toString(),
request.value(QStringLiteral("initialPrice")).toString(),
QString::number(request.value(QStringLiteral("depositScale")).toInt()),
QString::number(request.value(QStringLiteral("slippageBps")).toInt()),
};
const QByteArray digest =
QCryptographicHash::hash(parts.join(QLatin1Char('|')).toUtf8(),
QCryptographicHash::Sha256).toHex();
return QStringLiteral("sha256-%1").arg(QString::fromLatin1(digest));
}
QVariantMap accountChange(const QString& role, const QString& id, const QString& action)
{
QVariantMap change;
change.insert(QStringLiteral("role"), role);
change.insert(QStringLiteral("id"), id);
change.insert(QStringLiteral("action"), action);
return change;
}
QVariantList accountChanges(const QVariantMap& request, const QVariantMap& context)
{
const QString tokenA = request.value(QStringLiteral("tokenA")).toString();
const QString tokenB = request.value(QStringLiteral("tokenB")).toString();
const QString poolId = context.value(QStringLiteral("poolId")).toString();
const bool missingPool =
context.value(QStringLiteral("poolStatus")).toString() == QStringLiteral("missing_pool");
QVariantList changes;
changes.append(accountChange(QStringLiteral("Config"),
stableId(QStringLiteral("devnet:amm-config")),
QStringLiteral("Read")));
changes.append(accountChange(QStringLiteral("Pool"),
poolId,
missingPool ? QStringLiteral("Create") : QStringLiteral("Update")));
changes.append(accountChange(QStringLiteral("Vault A"),
stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenA)),
missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update")));
changes.append(accountChange(QStringLiteral("Vault B"),
stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenB)),
missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update")));
if (missingPool) {
changes.append(accountChange(QStringLiteral("LP definition"),
stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)),
QStringLiteral("Create")));
changes.append(accountChange(QStringLiteral("LP lock holding"),
stableId(QStringLiteral("devnet:lp-lock:%1").arg(poolId)),
QStringLiteral("Create")));
}
changes.append(accountChange(QStringLiteral("User LP holding"),
stableId(QStringLiteral("devnet:user-lp:%1").arg(poolId)),
QStringLiteral("Update or create")));
changes.append(accountChange(QStringLiteral("Current tick"),
stableId(QStringLiteral("devnet:current-tick:%1").arg(poolId)),
missingPool ? QStringLiteral("Create") : QStringLiteral("Update")));
changes.append(accountChange(QStringLiteral("Clock"),
stableId(QStringLiteral("devnet:clock:canonical")),
QStringLiteral("Read")));
return changes;
}
QVariantMap baseQuote(const QVariantMap& context, const QVariantMap& request)
{
QVariantMap quote;
quote.insert(QStringLiteral("poolStatus"), context.value(QStringLiteral("poolStatus")));
quote.insert(QStringLiteral("statusLabel"), context.value(QStringLiteral("statusLabel")));
quote.insert(QStringLiteral("statusDetail"), context.value(QStringLiteral("detail")));
quote.insert(QStringLiteral("instruction"), context.value(QStringLiteral("instruction")));
quote.insert(QStringLiteral("storedFeeBps"), context.value(QStringLiteral("storedFeeBps")));
quote.insert(QStringLiteral("feeBps"), request.value(QStringLiteral("feeBps")).toInt());
quote.insert(QStringLiteral("feeLabel"), feeLabel(request.value(QStringLiteral("feeBps")).toInt()));
quote.insert(QStringLiteral("quoteHash"), quoteHash(request));
QVariantMap pool;
pool.insert(QStringLiteral("id"), context.value(QStringLiteral("poolId")));
pool.insert(QStringLiteral("priceText"), context.value(QStringLiteral("priceText")));
pool.insert(QStringLiteral("reserveText"), context.value(QStringLiteral("reserveText")));
quote.insert(QStringLiteral("pool"), pool);
return quote;
}
QVariantMap quoteOk(const QVariantMap& context,
const QVariantMap& request,
double maxA,
double maxB,
double actualA,
double actualB,
double expectedLp,
double minimumLp,
double lockedLp,
const QVariantMap& position)
{
QVariantMap quote = baseQuote(context, request);
const QString tokenA = request.value(QStringLiteral("tokenA")).toString();
const QString tokenB = request.value(QStringLiteral("tokenB")).toString();
quote.insert(QStringLiteral("status"), QStringLiteral("ok"));
quote.insert(QStringLiteral("error"), QString());
QVariantMap deposit;
deposit.insert(QStringLiteral("maxA"), amountValue(maxA, tokenA));
deposit.insert(QStringLiteral("maxB"), amountValue(maxB, tokenB));
deposit.insert(QStringLiteral("actualA"), amountValue(actualA, tokenA));
deposit.insert(QStringLiteral("actualB"), amountValue(actualB, tokenB));
quote.insert(QStringLiteral("deposit"), deposit);
QVariantMap lp;
lp.insert(QStringLiteral("expected"), amountValue(expectedLp, QStringLiteral("LP")));
lp.insert(QStringLiteral("minimum"), amountValue(minimumLp, QStringLiteral("LP")));
lp.insert(QStringLiteral("locked"), amountValue(lockedLp, QStringLiteral("LP")));
quote.insert(QStringLiteral("lp"), lp);
quote.insert(QStringLiteral("position"), position);
quote.insert(QStringLiteral("accountChanges"), accountChanges(request, context));
QVariantMap transaction;
transaction.insert(QStringLiteral("instruction"), context.value(QStringLiteral("instruction")));
transaction.insert(QStringLiteral("ready"), false);
transaction.insert(QStringLiteral("reason"), QStringLiteral("Submission requires real token holding account ids from wallet discovery."));
quote.insert(QStringLiteral("transaction"), transaction);
return quote;
}
QVariantMap quoteError(const QVariantMap& context, const QVariantMap& request, const QString& errorText)
{
QVariantMap quote = baseQuote(context, request);
const QString tokenA = request.value(QStringLiteral("tokenA")).toString();
const QString tokenB = request.value(QStringLiteral("tokenB")).toString();
quote.insert(QStringLiteral("status"), QStringLiteral("error"));
quote.insert(QStringLiteral("error"), errorText);
QVariantMap deposit;
deposit.insert(QStringLiteral("maxA"), amountValue(0, tokenA));
deposit.insert(QStringLiteral("maxB"), amountValue(0, tokenB));
deposit.insert(QStringLiteral("actualA"), amountValue(0, tokenA));
deposit.insert(QStringLiteral("actualB"), amountValue(0, tokenB));
quote.insert(QStringLiteral("deposit"), deposit);
QVariantMap lp;
lp.insert(QStringLiteral("expected"), amountValue(0, QStringLiteral("LP")));
lp.insert(QStringLiteral("minimum"), amountValue(0, QStringLiteral("LP")));
const bool missingPool =
context.value(QStringLiteral("poolStatus")).toString() == QStringLiteral("missing_pool");
lp.insert(QStringLiteral("locked"), amountValue(missingPool ? 1000.0 : 0.0, QStringLiteral("LP")));
quote.insert(QStringLiteral("lp"), lp);
QVariantMap position;
position.insert(QStringLiteral("userLp"), QStringLiteral("0 LP"));
position.insert(QStringLiteral("share"), QStringLiteral("-"));
position.insert(QStringLiteral("ownedA"), formatTokenAmount(0, tokenA));
position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB));
quote.insert(QStringLiteral("position"), position);
quote.insert(QStringLiteral("accountChanges"), QVariantList());
return quote;
}
}
QString AmmUiBackend::defaultWalletHome()
@ -75,6 +459,7 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
setWalletHome(defaultWalletHome());
// Assume reachable until a probe proves otherwise (avoids a startup flash).
setSequencerReachable(true);
setNewPositionContext(buildNewPositionContext());
// Periodically re-probe the sequencer so the banner reacts to a node going
// up/down while the app is running. Probes are no-ops until a wallet (and
@ -131,6 +516,7 @@ void AmmUiBackend::openOrAdoptWallet()
m_accountModel->replaceFromJsonArray(existing);
refreshBalances();
refreshSequencerAddr();
refreshNewPositionContext();
return;
}
@ -154,6 +540,7 @@ void AmmUiBackend::openOrAdoptWallet()
refreshSequencerAddr();
} else {
qWarning() << "AmmUiBackend: wallet open failed, code" << err;
refreshNewPositionContext();
}
}
@ -199,6 +586,7 @@ QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
refreshNewPositionContext();
return mnemonic;
}
@ -214,6 +602,7 @@ bool AmmUiBackend::openExisting()
refreshBalances();
refreshSequencerAddr();
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
refreshNewPositionContext();
return true;
}
@ -234,6 +623,7 @@ bool AmmUiBackend::openExisting()
refreshAccounts();
refreshBlockHeights();
refreshSequencerAddr();
refreshNewPositionContext();
return true;
}
@ -246,6 +636,7 @@ void AmmUiBackend::disconnectWallet()
setIsWalletOpen(false);
m_accountModel->replaceFromJsonArray(QJsonArray());
QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true);
refreshNewPositionContext();
}
QString AmmUiBackend::createAccountPublic()
@ -269,6 +660,7 @@ void AmmUiBackend::refreshAccounts()
const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
m_accountModel->replaceFromJsonArray(arr);
refreshBalances();
refreshNewPositionContext();
}
void AmmUiBackend::refreshBalances()
@ -284,6 +676,7 @@ void AmmUiBackend::refreshBalances()
m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub));
}
saveWallet();
refreshNewPositionContext();
}
QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
@ -291,6 +684,216 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic);
}
QString AmmUiBackend::activeAccountAddress() const
{
if (m_accountModel->count() <= 0)
return {};
const QModelIndex idx = m_accountModel->index(0, 0);
return m_accountModel->data(idx, AccountModel::AddressRole).toString();
}
QVariantMap AmmUiBackend::buildNewPositionContext() const
{
QVariantMap context;
context.insert(QStringLiteral("minimumLiquidity"), 1000.0);
context.insert(QStringLiteral("feeTiers"), feeTiers());
QVariantMap network;
network.insert(QStringLiteral("id"), QStringLiteral("devnet"));
network.insert(QStringLiteral("name"), QStringLiteral("Local devnet"));
network.insert(QStringLiteral("selector"), QStringLiteral("temporary-file"));
context.insert(QStringLiteral("network"), network);
QVariantMap programIds;
programIds.insert(QStringLiteral("amm"), stableId(QStringLiteral("devnet:program:amm")));
programIds.insert(QStringLiteral("token"), stableId(QStringLiteral("devnet:program:token")));
programIds.insert(QStringLiteral("twapOracle"), stableId(QStringLiteral("devnet:program:twap-oracle")));
context.insert(QStringLiteral("programIds"), programIds);
QVariantMap activeAccount;
const bool hasAccount = isWalletOpen() && m_accountModel->count() > 0;
if (hasAccount) {
const QModelIndex idx = m_accountModel->index(0, 0);
const QString address = m_accountModel->data(idx, AccountModel::AddressRole).toString();
activeAccount.insert(QStringLiteral("name"), m_accountModel->data(idx, AccountModel::NameRole).toString());
activeAccount.insert(QStringLiteral("address"), address);
activeAccount.insert(QStringLiteral("display"), shortId(address));
activeAccount.insert(QStringLiteral("isPublic"), m_accountModel->data(idx, AccountModel::IsPublicRole).toBool());
activeAccount.insert(QStringLiteral("balance"), m_accountModel->data(idx, AccountModel::BalanceRole).toString());
context.insert(QStringLiteral("holdings"), devnetHoldings(address));
context.insert(QStringLiteral("status"), QStringLiteral("ready"));
context.insert(QStringLiteral("statusDetail"), QStringLiteral("Devnet token holdings resolved for the active wallet account."));
} else {
activeAccount.insert(QStringLiteral("name"), QString());
activeAccount.insert(QStringLiteral("address"), QString());
activeAccount.insert(QStringLiteral("display"), QStringLiteral("Not connected"));
activeAccount.insert(QStringLiteral("isPublic"), true);
activeAccount.insert(QStringLiteral("balance"), QString());
context.insert(QStringLiteral("holdings"), QVariantList());
context.insert(QStringLiteral("status"), isWalletOpen() ? QStringLiteral("no_account") : QStringLiteral("no_wallet"));
context.insert(QStringLiteral("statusDetail"),
isWalletOpen()
? QStringLiteral("Create an account before opening a liquidity position.")
: QStringLiteral("Connect a wallet before opening a liquidity position."));
}
context.insert(QStringLiteral("activeAccount"), activeAccount);
context.insert(QStringLiteral("activeAccountDisplay"), activeAccount.value(QStringLiteral("display")));
return context;
}
QVariant AmmUiBackend::refreshNewPositionContext()
{
const QVariantMap context = buildNewPositionContext();
setNewPositionContext(context);
return context;
}
QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const
{
const QString tokenA = request.value(QStringLiteral("tokenA")).toString();
const QString tokenB = request.value(QStringLiteral("tokenB")).toString();
const QVariantMap context = poolContext(tokenA, tokenB);
if (!isWalletOpen())
return quoteError(context, request, tr("Connect a wallet to preview this position."));
const QString owner = activeAccountAddress();
if (owner.isEmpty())
return quoteError(context, request, tr("Create an account before previewing this position."));
const QVariantList holdings = devnetHoldings(owner);
const QVariantMap holdingA = holdingBySymbol(holdings, tokenA);
const QVariantMap holdingB = holdingBySymbol(holdings, tokenB);
if (holdingA.isEmpty() || holdingB.isEmpty())
return quoteError(context, request, tr("Choose two token holdings from the active account."));
const int feeBps = request.value(QStringLiteral("feeBps")).toInt();
if (!isSupportedFeeTier(feeBps))
return quoteError(context, request, tr("Fee tier is not supported by the AMM program."));
const QString poolStatus = context.value(QStringLiteral("poolStatus")).toString();
const int storedFeeBps = context.value(QStringLiteral("storedFeeBps")).toInt();
if (poolStatus == QStringLiteral("active_pool") && feeBps != storedFeeBps)
return quoteError(context, request,
tr("Existing pool uses %1.").arg(feeLabel(storedFeeBps)));
const int slippageBps =
std::max(1, std::min(5000, request.value(QStringLiteral("slippageBps")).toInt()));
if (poolStatus == QStringLiteral("active_pool")) {
const double inputA = parsePositiveAmount(request.value(QStringLiteral("amountA")).toString());
const double inputB = parsePositiveAmount(request.value(QStringLiteral("amountB")).toString());
const bool editA = request.value(QStringLiteral("editedSide")).toString() != QStringLiteral("B");
const double ratio = activeRatio(tokenA, tokenB);
const double amountA = editA ? inputA : inputB / ratio;
const double amountB = editA ? inputA * ratio : inputB;
const double expectedLp = std::floor(std::min(amountA * 5.5, amountB * 0.69));
const double minimumLp = std::floor(expectedLp * (10000 - slippageBps) / 10000.0);
if (inputA <= 0.0 && inputB <= 0.0)
return quoteError(context, request, tr("Enter a deposit amount to preview LP output."));
if (amountA > holdingA.value(QStringLiteral("balance")).toDouble())
return quoteError(context, request, tr("Insufficient %1 balance.").arg(tokenA));
if (amountB > holdingB.value(QStringLiteral("balance")).toDouble())
return quoteError(context, request, tr("Insufficient %1 balance.").arg(tokenB));
if (minimumLp <= 0.0)
return quoteError(context, request, tr("LP minimum rounds to zero. Increase deposit amount."));
QVariantMap position;
position.insert(QStringLiteral("userLp"), QStringLiteral("148320 LP"));
position.insert(QStringLiteral("share"), QStringLiteral("1.18%"));
position.insert(QStringLiteral("ownedA"), formatTokenAmount(tokenA == QStringLiteral("USDC") ? 14750.0 : 118000.0, tokenA));
position.insert(QStringLiteral("ownedB"), formatTokenAmount(tokenB == QStringLiteral("LOGOS") ? 118000.0 : 14750.0, tokenB));
return quoteOk(context, request, amountA, amountB, amountA, amountB, expectedLp, minimumLp, 0.0, position);
}
if (poolStatus == QStringLiteral("missing_pool")) {
const double price =
parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) > 0.0
? parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString())
: defaultInitialPrice(tokenA, tokenB);
const double scale = std::max(1, request.value(QStringLiteral("depositScale")).toInt());
const double amountA = price >= 1.0 ? price * scale : scale;
const double amountB = price >= 1.0 ? scale : scale / price;
const double expectedLp = std::floor(std::sqrt(amountA * amountB) * 48.0);
const double userLp = std::max(0.0, expectedLp - 1000.0);
const double minimumLp = std::floor(userLp * (10000 - slippageBps) / 10000.0);
if (amountA > holdingA.value(QStringLiteral("balance")).toDouble())
return quoteError(context, request, tr("Initial deposit exceeds %1 balance.").arg(tokenA));
if (amountB > holdingB.value(QStringLiteral("balance")).toDouble())
return quoteError(context, request, tr("Initial deposit exceeds %1 balance.").arg(tokenB));
if (userLp <= 0.0)
return quoteError(context, request, tr("Deposit must mint more than the locked minimum liquidity."));
QVariantMap position;
position.insert(QStringLiteral("userLp"), QStringLiteral("0 LP"));
position.insert(QStringLiteral("share"), QStringLiteral("New pool"));
position.insert(QStringLiteral("ownedA"), formatTokenAmount(0, tokenA));
position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB));
QVariantMap quote =
quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, 1000.0, position);
QVariantMap pool = quote.value(QStringLiteral("pool")).toMap();
pool.insert(QStringLiteral("priceText"),
QStringLiteral("1 %1 = %2 %3")
.arg(tokenB, formatAmount(price), tokenA));
quote.insert(QStringLiteral("pool"), pool);
return quote;
}
return quoteError(context, request, context.value(QStringLiteral("detail")).toString());
}
QVariant AmmUiBackend::quoteNewPosition(QVariant request)
{
return quoteNewPositionMap(request.toMap());
}
QVariant AmmUiBackend::submitNewPosition(QVariant request, QString quoteHash)
{
const QVariantMap quote = quoteNewPositionMap(request.toMap());
if (quote.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
QVariantMap result;
result.insert(QStringLiteral("status"), QStringLiteral("error"));
result.insert(QStringLiteral("code"), QStringLiteral("quote_error"));
result.insert(QStringLiteral("error"), quote.value(QStringLiteral("error")));
result.insert(QStringLiteral("quote"), quote);
return result;
}
if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) {
QVariantMap result;
result.insert(QStringLiteral("status"), QStringLiteral("error"));
result.insert(QStringLiteral("code"), QStringLiteral("quote_changed"));
result.insert(QStringLiteral("error"), tr("Quote changed. Refresh preview before submitting."));
result.insert(QStringLiteral("quote"), quote);
return result;
}
const QVariantMap transaction = quote.value(QStringLiteral("transaction")).toMap();
if (!transaction.value(QStringLiteral("ready")).toBool()) {
QVariantMap result;
result.insert(QStringLiteral("status"), QStringLiteral("error"));
result.insert(QStringLiteral("code"), QStringLiteral("submit_unavailable"));
result.insert(QStringLiteral("error"), transaction.value(QStringLiteral("reason")).toString());
result.insert(QStringLiteral("quote"), quote);
return result;
}
QVariantMap result;
result.insert(QStringLiteral("status"), QStringLiteral("ok"));
result.insert(QStringLiteral("message"),
quote.value(QStringLiteral("instruction")).toString() == QStringLiteral("new_definition")
? tr("Pool creation submitted")
: tr("Liquidity deposit submitted"));
result.insert(QStringLiteral("detail"),
QStringLiteral("%1 / %2")
.arg(request.toMap().value(QStringLiteral("tokenA")).toString(),
request.toMap().value(QStringLiteral("tokenB")).toString()));
return result;
}
void AmmUiBackend::refreshBlockHeights()
{
const int lastVal = m_logos->logos_execution_zone.get_last_synced_block();

View File

@ -3,6 +3,7 @@
#include <QObject>
#include <QString>
#include <QVariant>
#include "rep_AmmUiBackend_source.h"
@ -34,6 +35,9 @@ public slots:
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;
@ -61,6 +65,9 @@ private:
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();

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