feat(amm): byte-encode swap instruction for QtRO + AMM_DEBUG tracing + token config

This commit is contained in:
Andrea Franz 2026-07-16 14:10:50 +02:00 committed by r4bbit
parent caf53d0409
commit c0568e3a88
6 changed files with 204 additions and 52 deletions

View File

@ -30,16 +30,30 @@ mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" >> ~
### Example (`apps/amm`)
```bash
cd apps/amm
The AMM UI is built from the **repository-root** flake (which also provides the
`amm_client_ffi` library it links). Run it from the repo root by its named
attribute — there is no bare `nix run .` default, so future apps are
`nix run .#<name>`:
# Run the app
nix run .
```bash
# Run the app (from the repo root)
nix run .#amm-ui
# Update pinned dependencies
nix flake update
```
To use the Swap view, also set `AMM_PROGRAM_BIN` (your deployed `amm.bin`) and
`TOKENS_CONFIG` (your token list) — use absolute paths, from the repo root:
```bash
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
TOKENS_CONFIG=$(pwd)/amm-tokens.json \
nix run .#amm-ui
```
See `apps/amm/README.md` for full details on both variables.
## Prerequisites
- **Rust** — install via [rustup](https://rustup.rs/). The pinned toolchain version is set in `rust-toolchain.toml`.

View File

@ -131,6 +131,20 @@ core module from `result-core/` and the UI plugin from `result-lgx/`:
4. Choose the core module `.lgx` from `result-core/`, then the UI plugin `.lgx`
from `result-lgx/`
To actually use the Swap view you must also set `AMM_PROGRAM_BIN` and
`TOKENS_CONFIG` (both explained below). Run this **from the repo root** — use
absolute paths (`$(pwd)/…`), because `nix run` may not preserve the working
directory, so relative paths won't resolve:
```bash
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
TOKENS_CONFIG=$(pwd)/amm-tokens.json \
nix run .#amm-ui
```
Without `AMM_PROGRAM_BIN` the Swap view stays disabled; without `TOKENS_CONFIG`
the token picker is empty. Each is detailed below.
### AMM program binary (required for swaps)
To execute a swap, the app must submit a transaction against the **exact AMM
@ -178,10 +192,16 @@ account the wallet will sign transfers from/to for that token):
```
If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token
picker stays empty (a `qWarning` is logged; no swap can be started).
picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no
swap can be started). `definitionId`/`holding` may be given as base58 (as the
wallet/runbook display them) or hex — the app normalizes both to hex.
Full command with both variables set (absolute paths, from the repo root):
```bash
AMM_PROGRAM_BIN=/abs/path/to/amm.bin TOKENS_CONFIG=/abs/path/to/tokens.json nix run .#amm-ui
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
TOKENS_CONFIG=$(pwd)/amm-tokens.json \
nix run .#amm-ui
```
## Updating Dependencies

View File

@ -97,8 +97,10 @@ Rectangle {
root.poolReserveA = (pool && pool.reserveA) || "0"
root.poolReserveB = (pool && pool.reserveB) || "0"
root.poolDefAHex = (pool && pool.defAHex) || ""
root.poolFeeBps = (pool && pool.feeBps) || 30
root.poolError = ""
// feeBps === 0 is a legitimate zero-fee pool; only fall back
// to the 30bps default when the backend didn't send a value.
root.poolFeeBps = (pool && pool.feeBps !== undefined) ? pool.feeBps : 30
root.poolError = (pool && pool.error && pool.error !== "no_pool") ? pool.error : ""
},
function (error) {
console.warn("resolvePool error:", error)
@ -294,6 +296,9 @@ Rectangle {
amount: root.sellDisplay
token: root.sellToken
active: root.editingSide === "sell"
// Sell amount is sent to the backend as a raw base-units integer
// string; reject fractional entry rather than fail opaquely.
digitsOnly: true
onInputEdited: function(v) {
root.sellInput = v
if (root.editingSide !== "sell") root.editingSide = "sell"

View File

@ -11,6 +11,11 @@ Rectangle {
property string usdValue: ""
property var token: null
property bool active: true
// When true, restrict input to digits only used for the sell-amount
// field, whose value is sent to the backend as a raw base-units integer
// string (see decimalToU128Le in AmmUiBackend.cpp); fractional/decimal
// input there fails opaquely rather than being scaled.
property bool digitsOnly: false
signal tokenClicked()
signal inputEdited(string newValue)
@ -57,9 +62,21 @@ Rectangle {
font.weight: Font.Bold
selectionColor: theme.colors.selection
clip: true
onTextEdited: root.inputEdited(text)
onTextEdited: {
if (root.digitsOnly) {
// Amounts are base units (integers) strip any
// character that slips past the validator (e.g.
// via paste) before it reaches the backend.
var filtered = text.replace(/[^0-9]/g, "")
if (filtered !== text)
text = filtered // does not re-trigger onTextEdited
root.inputEdited(filtered)
} else {
root.inputEdited(text)
}
}
validator: RegularExpressionValidator {
regularExpression: /^[0-9]*\.?[0-9]*$/
regularExpression: root.digitsOnly ? /^[0-9]*$/ : /^[0-9]*\.?[0-9]*$/
}
}

View File

@ -27,6 +27,28 @@ extern "C" {
#include "amm_client_ffi.h"
}
// Debug tracing for the swap path (resolvePool / swapExactInput), gated behind
// the AMM_DEBUG env var so normal runs stay quiet. When disabled, the streamed
// arguments — including the account_id_to_base58 round-trips used to render
// accounts — are NOT evaluated, so there's no per-call overhead.
//
// NOTE: `send_generic_public_transaction` takes `instruction` and
// `program_dependencies` as byte strings (`bstr`). Declaring them as Vec<u32> /
// Vec<Vec<u8>> makes the module's Qt/QtRO glue downgrade them to an opaque `any`
// the separate module process can't deserialize, silently dropping every
// argument. We therefore send `instruction` as the little-endian bytes of the
// u32 words and `program_dependencies` empty. Requires the wallet module built
// with byte-string params; see docs/amm-swap-qtro-serialization-bug.md.
static bool ammDebugEnabled()
{
static const bool on = qEnvironmentVariableIsSet("AMM_DEBUG");
return on;
}
// AMM_DBG() << ... behaves like qWarning().noquote() but only when AMM_DEBUG is
// set; otherwise the whole statement (and its arguments) is skipped.
#define AMM_DBG() \
if (ammDebugEnabled()) qWarning().noquote()
namespace {
const char SETTINGS_ORG[] = "Logos";
const char SETTINGS_APP[] = "AmmUI";
@ -98,11 +120,15 @@ namespace {
if (trimmed.isEmpty())
return false;
const unsigned __int128 kMax = ~static_cast<unsigned __int128>(0);
unsigned __int128 value = 0;
for (const QChar ch : trimmed) {
if (!ch.isDigit())
return false;
value = value * 10 + static_cast<unsigned int>(ch.digitValue());
const unsigned int d = static_cast<unsigned int>(ch.digitValue());
if (value > (kMax - d) / 10)
return false; // would overflow u128
value = value * 10 + d;
}
for (int i = 0; i < 16; ++i) {
@ -246,16 +272,16 @@ void AmmUiBackend::openOrAdoptWallet()
bool AmmUiBackend::sharedWalletIsOpen()
{
// list_accounts() is non-empty only once the wallet holds accounts, so it
// can't distinguish "no wallet open" from "open but empty" (a wallet that
// was just created and hasn't had an account added yet). Fall back to a
// handle-dependent, account-independent signal: an open wallet always has a
// sequencer address (from its config, defaulted on open), while a closed
// core returns an empty string. This lets us adopt a freshly-created shared
// wallet instead of falling through and re-opening it from disk.
if (!QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty())
return true;
return !m_logos->logos_execution_zone.get_sequencer_addr().isEmpty();
// Treat the shared core as "already open" ONLY when it actually holds
// accounts. We used to also treat a non-empty sequencer address as proof of
// an open wallet ("a closed core returns an empty string"), but the wallet
// module now reports a DEFAULT sequencer (e.g. http://localhost:…) even on a
// CLOSED core. That made standalone launches wrongly take the adopt path —
// mirroring an empty account list and returning early — instead of opening
// the real on-disk wallet, so the user saw no accounts. Keying off accounts
// alone means a genuinely-open shared wallet (Basecamp) is still adopted,
// while a closed standalone core correctly falls through to open-from-disk.
return !QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty();
}
QString AmmUiBackend::createNewDefault(QString password)
@ -507,6 +533,27 @@ QString AmmUiBackend::normalizeAccountId(const QString& id)
return hex.toLower(); // account_id_from_base58 returns "" on failure
}
QByteArray AmmUiBackend::loadAmmElf()
{
const QByteArray binPath = qgetenv(AMM_PROGRAM_BIN_ENV);
if (binPath.isEmpty()) {
qWarning() << "AmmUiBackend::loadAmmElf: AMM_PROGRAM_BIN not set";
return QByteArray();
}
QFile elfFile(QString::fromLocal8Bit(binPath));
if (!elfFile.open(QIODevice::ReadOnly)) {
qWarning() << "AmmUiBackend::loadAmmElf: cannot read AMM_PROGRAM_BIN at" << elfFile.fileName();
return QByteArray();
}
const QByteArray elf = elfFile.readAll();
elfFile.close();
if (elf.isEmpty()) {
qWarning() << "AmmUiBackend::loadAmmElf: AMM_PROGRAM_BIN is empty";
return QByteArray();
}
return elf;
}
QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
{
QVariantMap out;
@ -514,20 +561,9 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
// 1. Load the deployed AMM program's ELF. We can't derive this ourselves —
// it must match whatever is actually deployed on the target sequencer.
const QByteArray binPath = qgetenv(AMM_PROGRAM_BIN_ENV);
if (binPath.isEmpty()) {
qWarning() << "AmmUiBackend::resolvePool: AMM_PROGRAM_BIN not set";
return out;
}
QFile elfFile(QString::fromLocal8Bit(binPath));
if (!elfFile.open(QIODevice::ReadOnly)) {
qWarning() << "AmmUiBackend::resolvePool: cannot read AMM_PROGRAM_BIN at" << elfFile.fileName();
return out;
}
const QByteArray elf = elfFile.readAll();
elfFile.close();
const QByteArray elf = loadAmmElf();
if (elf.isEmpty()) {
qWarning() << "AmmUiBackend::resolvePool: AMM_PROGRAM_BIN is empty";
out[QStringLiteral("error")] = QStringLiteral("no_program_bin");
return out;
}
@ -536,6 +572,7 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
if (!amm_client_program_id_from_elf(reinterpret_cast<const uint8_t*>(elf.constData()),
static_cast<uintptr_t>(elf.size()), &ammId)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_program_id_from_elf failed";
out[QStringLiteral("error")] = QStringLiteral("bad_program_bin");
return out;
}
@ -546,10 +583,26 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
amm_client_config_pda(&ammId, &configPda);
const QString configHex = bytes32ToHex(configPda);
// Debug: log every derived account (base58 + hex) and every on-chain read so
// a failed resolve can be traced directly against `spel inspect`.
// account_id_to_base58 is a LOCAL encode (safe even when on-chain reads
// time out).
auto b58 = [this](const QString& hex) {
const QString s = m_logos->logos_execution_zone.account_id_to_base58(hex);
return s.isEmpty() ? hex : QStringLiteral("%1 (hex %2)").arg(s, hex);
};
AMM_DBG() << "[amm-debug] resolvePool: defA=" << b58(defAHex)
<< "defB=" << b58(defBHex);
AMM_DBG() << "[amm-debug] resolvePool: reading config account" << b58(configHex);
const QString configJson = m_logos->logos_execution_zone.get_account_public(configHex);
AMM_DBG() << "[amm-debug] resolvePool: config get_account_public ->"
<< configJson.size() << "chars; raw:" << configJson.left(400);
const QString configDataHex = accountDataHex(configJson);
if (configDataHex.isEmpty()) {
qWarning() << "AmmUiBackend::resolvePool: AMM config account has no data (not initialized)";
qWarning() << "AmmUiBackend::resolvePool: AMM config read returned no data"
<< "(a failed/timed-out RPC or an uninitialized account)";
out[QStringLiteral("error")] = QStringLiteral("amm_not_initialized");
return out;
}
@ -558,6 +611,7 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
if (!amm_client_decode_config(reinterpret_cast<const uint8_t*>(configBytes.constData()),
static_cast<uintptr_t>(configBytes.size()), &configView)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_decode_config failed";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
@ -567,6 +621,7 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
uint8_t defB[32];
if (!hexToBytes32(defAHex, defA) || !hexToBytes32(defBHex, defB)) {
qWarning() << "AmmUiBackend::resolvePool: invalid defAHex/defBHex";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
@ -574,10 +629,14 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
amm_client_pool_pda(&ammId, &defA, &defB, &poolPda);
const QString poolHex = bytes32ToHex(poolPda);
AMM_DBG() << "[amm-debug] resolvePool: reading pool account" << b58(poolHex);
const QString poolJson = m_logos->logos_execution_zone.get_account_public(poolHex);
AMM_DBG() << "[amm-debug] resolvePool: pool get_account_public ->"
<< poolJson.size() << "chars; raw:" << poolJson.left(400);
const QString poolDataHex = accountDataHex(poolJson);
if (poolDataHex.isEmpty()) {
// Not a warning: this is the normal "no liquidity yet" state.
out[QStringLiteral("error")] = QStringLiteral("no_pool");
return out;
}
@ -586,6 +645,7 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
if (!amm_client_decode_pool(reinterpret_cast<const uint8_t*>(poolBytes.constData()),
static_cast<uintptr_t>(poolBytes.size()), &poolView)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_decode_pool failed";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
@ -605,6 +665,17 @@ QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
out[QStringLiteral("reserveA")] = u128leToDecimal(poolView.reserve_a);
out[QStringLiteral("reserveB")] = u128leToDecimal(poolView.reserve_b);
out[QStringLiteral("feeBps")] = static_cast<int>(poolView.fees);
AMM_DBG() << "[amm-debug] resolvePool: RESOLVED"
<< "pool=" << b58(poolHex)
<< "poolDefA=" << b58(out[QStringLiteral("defAHex")].toString())
<< "poolDefB=" << b58(out[QStringLiteral("defBHex")].toString())
<< "vaultA=" << b58(out[QStringLiteral("vaultAHex")].toString())
<< "vaultB=" << b58(out[QStringLiteral("vaultBHex")].toString())
<< "currentTick=" << b58(out[QStringLiteral("currentTickHex")].toString())
<< "reserveA=" << out[QStringLiteral("reserveA")].toString()
<< "reserveB=" << out[QStringLiteral("reserveB")].toString()
<< "feeBps=" << out[QStringLiteral("feeBps")].toInt();
return out;
}
@ -612,6 +683,13 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u
QString userOutputHoldingHex, QString amountInDecimal,
QString minOutDecimal, QString deadlineDecimal)
{
AMM_DBG() << "[amm-debug] swapExactInput: ARGS"
<< "defA=" << defAHex << "defB=" << defBHex
<< "userInputHolding=" << userInputHoldingHex
<< "userOutputHolding=" << userOutputHoldingHex
<< "amountIn=" << amountInDecimal << "minOut=" << minOutDecimal
<< "deadline=" << deadlineDecimal;
// 1. Resolve the pool's PDAs; refuse if there's no pool (or no liquidity)
// for this token pair.
const QVariantMap pool = resolvePool(defAHex, defBHex);
@ -623,20 +701,9 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u
// 2. Load the deployed AMM program's ELF (must match resolvePool's — both
// read the same AMM_PROGRAM_BIN — since the instruction is proven against
// this exact binary's image id).
const QByteArray binPath = qgetenv(AMM_PROGRAM_BIN_ENV);
if (binPath.isEmpty()) {
qWarning() << "AmmUiBackend::swapExactInput: AMM_PROGRAM_BIN not set";
return QString();
}
QFile elfFile(QString::fromLocal8Bit(binPath));
if (!elfFile.open(QIODevice::ReadOnly)) {
qWarning() << "AmmUiBackend::swapExactInput: cannot read AMM_PROGRAM_BIN at" << elfFile.fileName();
return QString();
}
const QByteArray elf = elfFile.readAll();
elfFile.close();
const QByteArray elf = loadAmmElf();
if (elf.isEmpty()) {
qWarning() << "AmmUiBackend::swapExactInput: AMM_PROGRAM_BIN is empty";
qWarning() << "AmmUiBackend::swapExactInput: failed to load AMM_PROGRAM_BIN";
return QString();
}
@ -688,12 +755,37 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u
};
const QVariantList signers = { false, false, false, false, true, false, false, false };
// Debug: dump the exact accounts/signers we submit (base58 for `spel`
// comparison), plus the instruction/elf sizes.
auto b58s = [this](const QString& hex) {
const QString s = m_logos->logos_execution_zone.account_id_to_base58(hex);
return s.isEmpty() ? hex : QStringLiteral("%1 (hex %2)").arg(s, hex);
};
static const char* const kSlot[] = { "config", "pool",
"vault_a", "vault_b",
"user_input_holding", "user_output_holding",
"current_tick", "clock" };
AMM_DBG() << "[amm-debug] swapExactInput: SUBMIT"
<< "instruction_words=" << static_cast<int>(instruction.size())
<< "elf_bytes=" << elf.size();
for (int i = 0; i < accounts.size(); ++i) {
AMM_DBG() << "[amm-debug] account[" << i << "]"
<< (i < 8 ? kSlot[i] : "?") << "=" << b58s(accounts[i])
<< "signer=" << (i < signers.size() && signers[i].toBool());
}
// 6. Submit through the wallet module's generic public-transaction entry
// point. program_dependencies is empty: the sequencer resolves the AMM's
// chained token/twap calls on-chain for a public tx.
// point. `instruction` is sent as the little-endian bytes of the u32 words
// (see the note atop this file); program_dependencies is empty because the
// sequencer resolves the AMM's chained token/twap calls on-chain for a
// public tx.
const QByteArray instructionBytes(reinterpret_cast<const char*>(instruction.data()),
static_cast<int>(instruction.size() * sizeof(uint32_t)));
const QString resultJson = m_logos->logos_execution_zone.send_generic_public_transaction(
accounts, signers, QVariant::fromValue(instruction), QVariant::fromValue(elf),
QVariant::fromValue(std::vector<std::vector<uint8_t>>()));
accounts, signers, QVariant::fromValue(instructionBytes), QVariant::fromValue(elf),
QVariant::fromValue(QByteArray()));
AMM_DBG() << "[amm-debug] swapExactInput: send_generic_public_transaction ->"
<< resultJson.size() << "chars; raw:" << resultJson.left(600);
const QJsonObject obj = QJsonDocument::fromJson(resultJson.toUtf8()).object();
if (!obj.value(QStringLiteral("success")).toBool()) {

View File

@ -81,6 +81,10 @@ private:
void refreshSequencerAddr();
void saveWallet();
// Returns the deployed AMM program ELF bytes from $AMM_PROGRAM_BIN, or an empty
// QByteArray (with a qWarning) if the env var is unset/unreadable/empty.
QByteArray loadAmmElf();
// Probe the configured sequencer over HTTP and update sequencerReachable.
void checkReachability();