refactor(amm): remove the dead submitNewPosition path

Now that add-liquidity and pool creation submit via addLiquidity / createPool,
the legacy submitNewPosition path is dead. Remove it end to end.

App backend (AmmUiBackend):
- Drop the submitNewPosition slot/method and its newPositionError helper.

Module (AmmModuleImpl):
- Drop submitNewPosition, the m_requestPending guard, and the now-orphaned
  nowMs/parseU64 helpers (+ the <chrono> include).

FFI (amm_ffi) — the plan op only submitNewPosition called:
- Delete plan.rs; remove amm_plan (extern + regenerated header), api::plan,
  and PlanRequest.
- Remove the plan-only machinery it fed: QuoteBranch, NewPositionPlan,
  EvaluatedQuote.plan/quote_hash, and AccountPlan's wallet_args /
  requires_fresh_lp / contains / validate_ready, plus the plan construction
  in quote.rs.
- Tests: drop the plan-only tests/helpers; keep quote coverage by trimming
  the mixed tests to their quote assertions.

QML tests (tst_LiquidityPage):
- Remove the two legacy-submit tests (base58-only success) + the
  submitNewPosition mock and its now-unused fixtures. Keep the
  finishSubmitFailure test (unchanged behaviour).

quoteNewPosition and its machinery (amm_quote, buildQuoteInput, PairSnapshot,
AccountPlan preview/sources, commitment) stay — they retire with the legacy
quoting in the quote-migration vertical.
This commit is contained in:
r4bbit
2026-08-11 17:21:46 +02:00
parent 71789b303a
commit 2b120fd751
15 changed files with 41 additions and 646 deletions
-42
View File
@@ -22,25 +22,6 @@ namespace {
{ QStringLiteral("warnings"), QVariantList() },
};
}
// A new-position error envelope (matches the module's publicError), for
// the backend-side failure paths (e.g. LP-account creation failing).
QVariantMap newPositionError(const QString& code)
{
return QVariantMap {
{ QStringLiteral("status"), QStringLiteral("error") },
{ QStringLiteral("canSubmit"), false },
{ QStringLiteral("code"), code },
{ QStringLiteral("errors"), QVariantList { QVariantMap {
{ QStringLiteral("code"), code },
{ QStringLiteral("recoverable"), true },
{ QStringLiteral("blockingFields"), QVariantList() },
{ QStringLiteral("details"), QVariantMap() },
} } },
{ QStringLiteral("warnings"), QVariantList() },
{ QStringLiteral("accountPreview"), QVariantList() },
};
}
}
AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
@@ -156,29 +137,6 @@ QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request)
return m_logos->amm_module.quoteNewPosition(request, isWalletOpen());
}
QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash)
{
// First attempt with no LP account. If the module needs a fresh LP holding
// it returns "requires_fresh_lp" without submitting; we own wallet-keyset
// mutation, so create the account here (keeping the account model + on-disk
// storage coherent) and resubmit with its id.
QVariantMap result = m_logos->amm_module.submitNewPosition(
request, quoteHash, isWalletOpen(), QString());
if (result.value(QStringLiteral("status")).toString()
== QStringLiteral("requires_fresh_lp")) {
const QString lpId = m_walletController->createAccount(true);
if (lpId.isEmpty())
return newPositionError(QStringLiteral("wallet_submission_failed"));
result = m_logos->amm_module.submitNewPosition(
request, quoteHash, isWalletOpen(), lpId);
}
if (result.value(QStringLiteral("status")).toString() == QStringLiteral("submitted"))
refreshBalances();
return result;
}
void AmmUiBackend::syncWalletState()
{
const WalletUiState& state = m_walletController->state();
-1
View File
@@ -48,7 +48,6 @@ public slots:
QString getBalance(QString accountIdHex, bool isPublic) override;
void refreshNewPositionContext(QVariantMap request) override;
QVariantMap quoteNewPosition(QVariantMap request) override;
QVariantMap submitNewPosition(QVariantMap 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;
-1
View File
@@ -32,7 +32,6 @@ class AmmUiBackend
PROP(QVariantMap newPositionContext READONLY)
SLOT(void refreshNewPositionContext(QVariantMap request))
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh wallet at the canonical walletHome with no path picking. createNew()
-73
View File
@@ -9,15 +9,12 @@ TestCase {
id: testCase
name: "LiquidityPage"
readonly property string submittedTransactionId:
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
Component {
id: backendComponent
QtObject {
property bool walletStateReady: false
property var submitResult: ({})
property var quoteResult: ({
"status": "ok",
"poolStatus": "missing_pool"
@@ -28,10 +25,6 @@ TestCase {
"feeTiers": []
})
function submitNewPosition(request, quoteHash) {
return submitResult
}
function quoteNewPosition(request) {
return quoteResult
}
@@ -64,16 +57,6 @@ TestCase {
verify(form.width <= page.width - 32)
}
Component {
id: runtimeComponent
QtObject {
function watch(value, succeeded, failed) {
succeeded(value)
}
}
}
Component {
id: pageComponent
@@ -163,60 +146,4 @@ TestCase {
compare(page.flow.quoteStale, false)
}
function test_base58SubmittedResultEntersSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"status": "submitted",
"transactionId": submittedTransactionId,
"deadlineMs": String(Date.now() + 60000)
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({
"request": ({}),
"quoteHash": "sha256:expected"
})
compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.flowErrorCode, "")
compare(page.flow.submitting, false)
}
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"status": "submitted",
"transactionId": "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f"
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({
"request": ({}),
"quoteHash": "sha256:expected"
})
compare(page.flow.transactionId, "")
compare(page.flow.flowErrorCode, "wallet_submission_failed")
compare(page.flow.submitting, false)
}
}
-2
View File
@@ -24,8 +24,6 @@ char *amm_context(const char *request_json);
char *amm_quote(const char *request_json);
char *amm_plan(const char *request_json);
char *amm_swap_pair(const char *request_json);
char *amm_resolve_pool(const char *request_json);
+4 -10
View File
@@ -9,7 +9,6 @@ mod funding;
mod holding;
mod liquidity;
mod pair;
mod plan;
mod position;
mod quote;
mod quote_error;
@@ -24,10 +23,10 @@ use std::{error::Error, fmt};
pub use request::{
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PairSnapshot, PlanRequest,
PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PairSnapshot, PoolIdRequest,
PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
TokenHoldingsRequest, TokenIdsRequest,
};
use serde_json::Value;
@@ -92,11 +91,6 @@ pub fn quote(request: QuoteRequest) -> AmmResult {
quote::quote(request).map_err(Into::into)
}
/// Materializes a previously quoted request into wallet submission arguments.
pub fn plan(request: PlanRequest) -> AmmResult {
plan::plan(request).map_err(Into::into)
}
/// Derives the canonical account ids for a swap pair (tokens in either order).
pub fn swap_pair(request: SwapPairRequest) -> AmmResult {
swap::swap_pair(request).map_err(Into::into)
-130
View File
@@ -1,130 +0,0 @@
use nssa_core::account::Account;
use serde_json::{json, Value};
use super::{
clock::decode_clock,
position::{NewPositionPlan, QuoteBranch, QuoteComputation},
quote::compute_quote,
PlanRequest, QuoteRequest,
};
use crate::account::{account_id_hex, decode_account, AccountRead};
const DEADLINE_WINDOW_MS: u64 = 1_200_000;
pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
let quote_input = QuoteRequest {
network_id: input.network_id,
network_fingerprint: input.network_fingerprint,
amm_program_id: input.amm_program_id.clone(),
request: input.request,
snapshot: input.snapshot,
};
let quote = compute_quote(&quote_input)?;
if quote.quote_hash() != Some(input.quote_hash.as_str()) {
return Ok(json!({
"status": "error",
"code": "quote_changed",
"recoverable": true,
"quote": quote.into_value(&quote_input.request),
}));
}
let evaluated = match quote {
QuoteComputation::Evaluated(evaluated) => evaluated,
QuoteComputation::Failed(failure) => {
return Ok(json!({
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": failure.into_value(&quote_input.request),
}))
}
};
let Some(plan) = evaluated.plan else {
return Ok(json!({
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": evaluated.value,
}));
};
let fresh_lp = if plan.requires_fresh_lp() {
let Some(read) = input.fresh_lp.as_ref() else {
return Ok(json!({
"status": "needs_fresh_lp",
"code": "fresh_lp_required",
}));
};
let Ok((id, account)) = decode_account(read) else {
return Ok(plan_error("wallet_submission_failed"));
};
if account != Account::default() || plan.accounts.contains(id) {
return Ok(plan_error("wallet_submission_failed"));
}
Some(id)
} else {
None
};
let deadline = input
.now_ms
.checked_add(DEADLINE_WINDOW_MS)
.ok_or_else(|| String::from("transaction deadline overflow"))?;
let clock_timestamp = clock_timestamp(&quote_input.snapshot.clock)?;
if clock_timestamp >= deadline {
return Ok(plan_error("transaction_deadline_expired"));
}
let NewPositionPlan { accounts, branch } = plan;
let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
let instruction = match branch {
QuoteBranch::Missing { amount_a, amount_b } => {
let instruction = amm_core::Instruction::NewDefinition {
token_a_amount: amount_a,
token_b_amount: amount_b,
fees: u128::from(quote_input.request.fee_bps),
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
} => {
let (stored_max_a, stored_max_b) = if stored_reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: minimum_lp,
max_amount_to_add_token_a: stored_max_a,
max_amount_to_add_token_b: stored_max_b,
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
};
Ok(json!({
"status": "ready",
"programId": input.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"deadlineMs": deadline.to_string(),
}))
}
fn plan_error(code: &str) -> Value {
json!({
"status": "error",
"code": code,
"recoverable": true,
})
}
fn clock_timestamp(read: &AccountRead) -> Result<u64, String> {
decode_clock(read).map(|(_, clock)| clock.timestamp)
}
-80
View File
@@ -7,24 +7,8 @@ use super::{
};
use crate::account::{account_id_from_hex, program_id_base58};
#[derive(Clone)]
pub(super) enum QuoteBranch {
Missing {
amount_a: u128,
amount_b: u128,
},
Active {
max_a: u128,
max_b: u128,
minimum_lp: u128,
stored_reversed: bool,
},
}
pub(super) struct EvaluatedQuote {
pub(super) value: Value,
pub(super) quote_hash: String,
pub(super) plan: Option<NewPositionPlan>,
}
pub(super) enum QuoteComputation {
@@ -38,11 +22,6 @@ pub(super) struct QuoteFailure {
pub(super) details: Value,
}
pub(super) struct NewPositionPlan {
pub(super) accounts: AccountPlan,
pub(super) branch: QuoteBranch,
}
pub(super) struct AccountPlan {
pub(super) rows: Vec<AccountPlanRow>,
pub(super) sources: Vec<SourceCommitment>,
@@ -70,13 +49,6 @@ impl QuoteComputation {
Self::Evaluated(EvaluatedQuote { value, .. }) => value,
}
}
pub(super) fn quote_hash(&self) -> Option<&str> {
match self {
Self::Failed(_) => None,
Self::Evaluated(quote) => Some(&quote.quote_hash),
}
}
}
impl QuoteFailure {
@@ -100,17 +72,6 @@ impl QuoteFailure {
}
}
impl NewPositionPlan {
pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result<Self, String> {
accounts.validate_ready()?;
Ok(Self { accounts, branch })
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.accounts.requires_fresh_lp()
}
}
impl AccountPlan {
pub(super) fn validate_snapshot_ids(
pair: &PairIds,
@@ -151,47 +112,6 @@ impl AccountPlan {
pub(super) fn take_sources(&mut self) -> Vec<SourceCommitment> {
std::mem::take(&mut self.sources)
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.rows
.iter()
.any(|row| row.role == "user_holding_lp" && row.account_id.is_none())
}
pub(super) fn contains(&self, account_id: AccountId) -> bool {
self.rows
.iter()
.any(|row| row.account_id == Some(account_id))
}
pub(super) fn validate_ready(&self) -> Result<(), String> {
if self.rows.iter().any(|row| {
row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init)
}) {
return Err(String::from("submittable quote has an unresolved account"));
}
Ok(())
}
pub(super) fn wallet_args(
&self,
fresh_lp: Option<AccountId>,
) -> Result<(Vec<AccountId>, Vec<bool>), String> {
let mut account_ids = Vec::with_capacity(self.rows.len());
let mut signing_requirements = Vec::with_capacity(self.rows.len());
for row in &self.rows {
let account_id = match row.account_id {
Some(account_id) => account_id,
None if row.role == "user_holding_lp" => {
fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))?
}
None => return Err(String::from("transaction plan has an unresolved account")),
};
account_ids.push(account_id);
signing_requirements.push(row.signer);
}
Ok((account_ids, signing_requirements))
}
}
impl AccountPlanRow {
+3 -35
View File
@@ -19,10 +19,7 @@ use super::{
funding::{funding_commitments, funding_issues, hash_quote},
holding::{decode_fungible_holding, select_holding, wallet_holdings},
pair::{derive_pair, is_canonical_pair, PairIds},
position::{
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
QuoteComputation,
},
position::{AccountPlan, AccountPlanHoldings, EvaluatedQuote, QuoteComputation},
quote_error::{fatal_quote, issue},
QuoteRequest,
};
@@ -277,19 +274,7 @@ fn compute_missing_quote(
"errors": funding,
"warnings": [],
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Missing { amount_a, amount_b },
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
Ok(QuoteComputation::Evaluated(EvaluatedQuote { value }))
}
fn compute_active_quote(
@@ -525,24 +510,7 @@ fn compute_active_quote(
"errors": funding,
"warnings": warnings,
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
},
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
Ok(QuoteComputation::Evaluated(EvaluatedQuote { value }))
}
fn validate_active_accounts(
-14
View File
@@ -269,17 +269,3 @@ pub struct QuoteRequest {
pub request: PositionRequest,
pub snapshot: PairSnapshot,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlanRequest {
pub network_id: String,
pub network_fingerprint: String,
pub amm_program_id: String,
pub request: PositionRequest,
pub snapshot: PairSnapshot,
pub quote_hash: String,
pub now_ms: u64,
#[serde(default)]
pub fresh_lp: Option<AccountRead>,
}
+25 -115
View File
@@ -19,15 +19,14 @@ use super::{
context::{context, token_ids},
holding::{select_holding, wallet_holdings, SelectedHolding},
pair::{is_canonical_pair, pair_ids, PairIds},
plan::plan,
position::AccountPlanHoldings,
quote::{div_ceil_u256, minimum_opening_pair, quote, Q64},
swap::{swap_exact_in_plan, swap_exact_out_plan},
ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest,
ContextRequest, PairIdsRequest, PairSnapshot, PositionRequest, QuoteRequest,
SwapExactInPlanRequest, SwapExactOutPlanRequest, TokenIdsRequest,
};
use crate::{
account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes},
account::{account_id_hex, account_read, decode_account, program_id_bytes},
AccountRead,
};
@@ -198,44 +197,6 @@ impl Scenario {
fn quote(&self) -> Value {
quote(self.quote_request()).unwrap()
}
fn plan(self, quote_hash: impl Into<String>, fresh_lp: Option<AccountRead>) -> Value {
plan(PlanRequest {
network_id: String::from(self.network_id),
network_fingerprint: String::from(self.network_fingerprint),
amm_program_id: amm_program_id(),
request: self.request,
snapshot: self.snapshot,
quote_hash: quote_hash.into(),
now_ms: 2_000,
fresh_lp,
})
.unwrap()
}
}
fn assert_preview_matches_plan(
quote_value: &Value,
plan_value: &Value,
fresh_lp: Option<AccountId>,
) {
let preview = quote_value["accountPreview"].as_array().unwrap();
let account_ids = plan_value["accountIds"].as_array().unwrap();
let signing_requirements = plan_value["signingRequirements"].as_array().unwrap();
assert_eq!(preview.len(), account_ids.len());
assert_eq!(preview.len(), signing_requirements.len());
for (order, row) in preview.iter().enumerate() {
assert_eq!(row["order"], order);
assert_eq!(row["signer"], signing_requirements[order]);
if let Some(account_id) = row["accountId"].as_str() {
let account_id = parse_base58_id(account_id, "preview account id").unwrap();
assert_eq!(account_ids[order], account_id_hex(account_id));
} else {
assert_eq!(row["role"], "user_holding_lp");
assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap()));
}
}
}
#[test]
@@ -521,37 +482,21 @@ fn missing_pool_snapshot_defaults_remain_real_accounts() {
}
#[test]
fn missing_pool_quote_and_plan_use_current_account_order() {
fn missing_pool_quote_uses_current_account_order() {
let scenario = Scenario::devnet();
let quote_value = scenario.quote();
assert_eq!(quote_value["status"], "ok");
assert_eq!(quote_value["poolStatus"], "missing_pool");
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let fresh_lp = AccountId::new([63; 32]);
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp)));
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11);
assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp));
assert_eq!(plan_value["signingRequirements"][6], true);
assert_eq!(plan_value["signingRequirements"][7], true);
assert_eq!(plan_value["signingRequirements"][8], true);
assert_preview_matches_plan(&quote_value, &plan_value, Some(fresh_lp));
}
#[test]
fn missing_pool_plan_rejects_fresh_lp_account_collision() {
let scenario = Scenario::devnet();
let pool = scenario.pair.pool;
let quote_value = scenario.quote();
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let plan_value = scenario.plan(quote_hash, Some(default_read(pool)));
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "wallet_submission_failed");
// The 11-account NewDefinition preview order (config, pool, vaults, lp def/lock,
// holdings a/b/lp, current_tick, clock) — the create submit consumes the same shape.
let preview = quote_value["accountPreview"].as_array().unwrap();
assert_eq!(preview.len(), 11);
assert_eq!(preview[6]["role"], "user_holding_a");
assert_eq!(preview[6]["signer"], true);
assert_eq!(preview[7]["signer"], true);
assert_eq!(preview[8]["role"], "user_holding_lp");
assert_eq!(preview[8]["signer"], true);
}
#[test]
@@ -572,33 +517,6 @@ fn missing_pool_quote_accepts_large_direct_raw_amounts() {
assert!(quote_value.get("depositScaleBps").is_none());
}
#[test]
fn advancing_clock_does_not_stale_quote() {
let mut scenario = Scenario::testnet();
let quote_value = scenario.quote();
scenario.snapshot.clock = account_read(
scenario.pair.clock,
&account(
[44; 8],
Data::try_from(
ClockAccountData {
block_id: 11,
timestamp: 1_500,
}
.to_bytes(),
)
.unwrap(),
),
);
let plan_value = scenario.plan(
quote_value["quoteHash"].as_str().unwrap(),
Some(default_read(AccountId::new([63; 32]))),
);
assert_eq!(plan_value["status"], "ready");
}
#[test]
fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
let mut scenario = Scenario::testnet();
@@ -671,35 +589,27 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["errors"], json!([]));
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10);
assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding));
assert_eq!(plan_value["signingRequirements"][7], false);
assert_preview_matches_plan(&quote_value, &plan_value, None);
// The existing LP holding (not a fresh one) fills the LP slot of the 10-account
// AddLiquidity preview, and only the two token holdings sign.
let preview = quote_value["accountPreview"].as_array().unwrap();
assert_eq!(preview.len(), 10);
assert_eq!(preview[7]["role"], "user_holding_lp");
assert_eq!(
preview[7]["accountId"].as_str().unwrap(),
lp_holding.to_string()
);
assert_eq!(preview[7]["signer"], false);
assert_eq!(preview[5]["signer"], true);
assert_eq!(preview[6]["signer"], true);
}
#[test]
fn matching_unfunded_quote_has_no_transaction_plan() {
fn unfunded_quote_cannot_submit() {
let mut scenario = Scenario::devnet();
scenario.snapshot.wallet_available = false;
scenario.snapshot.wallet_accounts.clear();
let quote_value = scenario.quote();
assert_eq!(quote_value["canSubmit"], false);
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "quote_not_submittable");
assert_eq!(plan_value["quote"], quote_value);
}
#[test]
fn stale_hash_returns_recomputed_quote_without_plan() {
let value = Scenario::devnet().plan("sha256:deadbeef", None);
assert_eq!(value["status"], "error");
assert_eq!(value["code"], "quote_changed");
assert_eq!(value["quote"]["status"], "ok");
}
#[test]
+3 -8
View File
@@ -8,9 +8,9 @@ use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
PlanRequest, PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
TokenHoldingsRequest, TokenIdsRequest,
};
#[derive(Serialize)]
@@ -104,11 +104,6 @@ pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
call::<QuoteRequest>(request_json, api::quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, api::plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_swap_pair(request_json: *const c_char) -> *mut c_char {
call::<SwapPairRequest>(request_json, api::swap_pair)
+3 -3
View File
@@ -6,11 +6,11 @@ mod ffi;
pub mod api;
pub use api::{
config_id, context, create_pool_plan, liquidity_quote, pair_ids, plan, pool_id, program_id,
quote, resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan,
config_id, context, create_pool_plan, liquidity_quote, pair_ids, pool_id, program_id, quote,
resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan,
swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
PairSnapshot, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount,
};
-112
View File
@@ -2,7 +2,6 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <fstream>
@@ -88,31 +87,6 @@ std::string jStr(const json& obj, const char* key) {
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
}
// Milliseconds since the unix epoch (u64). Used for the plan's `nowMs` and the
// client deadline check — the module runs on the host, not in the zkVM, so wall
// clock is available (unlike a guest).
uint64_t nowMs() {
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count());
}
// Decimal string -> u64. False (leaving `out` unset) on empty, non-digit, or
// overflow.
bool parseU64(const std::string& s, uint64_t& out) {
if (s.empty()) return false;
uint64_t value = 0;
for (const char c : s) {
if (c < '0' || c > '9') return false;
const uint64_t d = static_cast<uint64_t>(c - '0');
if (value > (~static_cast<uint64_t>(0) - d) / 10) return false;
value = value * 10 + d;
}
out = value;
return true;
}
// Coerce a swap amount arg — arriving as EITHER a JSON number (bare `1000` on
// the CLI) or a decimal string ("1000", a big u128 the UI passes, or a
// quote-wrapped big value on the CLI) — to its canonical decimal-string form.
@@ -1214,89 +1188,3 @@ LogosMap AmmModuleImpl::quoteNewPosition(const LogosMap& request, bool wallet_op
return result.ok ? result.value : publicError("backend_error");
}
LogosMap AmmModuleImpl::submitNewPosition(const LogosMap& request,
const std::string& quote_hash,
bool wallet_open,
const std::string& fresh_lp_id) {
if (m_requestPending) return publicError("submit_in_progress");
if (!wallet_open) return publicError("wallet_unavailable");
m_requestPending = true;
struct Guard {
bool* flag;
~Guard() { *flag = false; }
} guard{&m_requestPending};
const Network net = network();
json error;
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/true, &error);
if (!error.is_null()) return error;
const FfiResult quoteResult = call(amm_quote, input);
if (!quoteResult.ok) return publicError("backend_error");
const json quote = quoteResult.value;
if (jStr(quote, "quoteHash") != quote_hash) {
json result = publicError("quote_changed");
result["quote"] = quote;
return result;
}
if (!quote.value("canSubmit", false)) {
json result = publicError("quote_not_submittable");
result["quote"] = quote;
return result;
}
// Fresh LP holding: the app owns wallet-keyset mutation. If the quote needs
// one and the caller hasn't supplied it, ask for it (no submit) so the
// backend can create it through its own wallet provider and call again.
json freshLp; // null
if (quote.value("requiresFreshLp", false)) {
if (fresh_lp_id.empty()) {
return json{
{"status", "requires_fresh_lp"},
{"quote", quote},
};
}
freshLp = readPublicAccount(fresh_lp_id);
}
json planInput = input;
planInput["quoteHash"] = quote_hash;
planInput["nowMs"] = nowMs();
if (!freshLp.is_null()) planInput["freshLp"] = freshLp;
const FfiResult planResult = call(amm_plan, planInput);
if (!planResult.ok) return publicError("backend_error");
const json plan = planResult.value;
if (jStr(plan, "status") != "ready") {
const std::string code = jStr(plan, "code");
return publicError(code.empty() ? "wallet_submission_failed" : code);
}
uint64_t deadline = 0;
if (!parseU64(jStr(plan, "deadlineMs"), deadline) || nowMs() >= deadline)
return publicError("transaction_deadline_expired");
const std::vector<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> instruction = jsonWordsToLeBytes(plan.value("instruction", json::array()));
const std::string program_id = jStr(plan, "programId");
const std::string reply = modules().logos_execution_zone.send_generic_public_transaction(
accounts, signers, instruction, program_id);
const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false);
if (!obj.is_object() || !obj.value("success", false))
return publicError("wallet_submission_failed");
// Native tx hash (64-char hex) -> base58 transaction id via the wallet
// module (avoids linking libbase58 just for this encode).
const std::string tx_hash = jStr(obj, "tx_hash");
const std::string transaction_id =
modules().logos_execution_zone.account_id_to_base58(tx_hash);
if (transaction_id.empty()) return publicError("wallet_submission_failed");
return {
{"status", "submitted"},
{"transactionId", transaction_id},
{"deadlineMs", plan.value("deadlineMs", json())},
};
}
+3 -20
View File
@@ -189,18 +189,6 @@ public:
/// requiresFreshLp, amounts, warnings). Read-only — no submission.
LogosMap quoteNewPosition(const LogosMap& request, bool wallet_open);
/// Submits an add-liquidity transaction. Re-quotes and validates `quote_hash`
/// against the current quote. If the quote requires a fresh LP holding and
/// `fresh_lp_id` is empty, returns `{ "status": "requires_fresh_lp", ... }`
/// WITHOUT submitting — the caller (the app backend, which owns the wallet
/// keyset) creates the account and calls again with its id. Otherwise builds
/// the plan (injecting the fresh LP account when given) and submits, then
/// returns the new-position submitted/error map.
LogosMap submitNewPosition(const LogosMap& request,
const std::string& quote_hash,
bool wallet_open,
const std::string& fresh_lp_id);
private:
// Off-chain "network" context, derived from the process env (the same
// sources the app backend used): AMM deployment id from AMM_PROGRAM_BIN,
@@ -248,20 +236,15 @@ private:
nlohmann::json walletAccountReads(bool wallet_open, bool refresh);
// Builds the { networkId, networkFingerprint, ammProgramId, request,
// snapshot } input shared by quoteNewPosition / submitNewPosition. On a
// recoverable precondition failure, sets *error to a new-position error
// map and returns a null json.
// snapshot } input for quoteNewPosition's amm_quote call. On a recoverable
// precondition failure, sets *error to a new-position error map and returns
// a null json.
nlohmann::json buildQuoteInput(const LogosMap& request,
const Network& net,
bool wallet_open,
bool fresh_wallet_accounts,
nlohmann::json* error);
// Guards against a re-entrant in-flight request (e.g. a double-submit) on the
// shared module instance. Released per call, so the app's fresh-LP resubmit
// still proceeds.
bool m_requestPending = false;
// Process-lifetime network config, resolved once (see network()). Serialized
// module dispatch means no locking is needed; there is no invalidation, as
// runtime env reload is not supported.