r4bbit 01829a280c feat(apps/amm): add create-pool / new liquidity position flow
Add a "Create Pool" flow to the AMM app that lets a user open a new
liquidity position — seeding a pool's initial liquidity — from token
selection and amount entry, through a confirmation dialog, to on-chain
submission.

- client crate (apps/amm/client): pure, testable protocol logic — account
  decoding, pair/position modelling, and quote/plan computation — exposed to
  the app over a C ABI (config/networks.json drives network selection).
- C++ runtime + backend: AmmClient, ActiveNetwork, and NewPositionRuntime,
  wired into AmmUiBackend (new resolve/quote/submit slots).
- QML flow: NewPositionForm, NewPositionFlow state, NewPositionConfirmation-
  Dialog, TokenSelectorModal, and reusable Amm* presentational components
  (theme, surfaces, buttons) + AmountMath.js.
- tests: C++ (NewPositionRuntimeTest, ActiveNetworkTest) and QML
  (tst_NewPositionForm, tst_LiquidityPage, tst_TokenAmountInput, …).
2026-07-27 12:14:27 +02:00

153 lines
4.7 KiB
Rust

use std::str::FromStr;
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AccountRead {
pub id: String,
pub status: String,
#[serde(default)]
pub account: Option<WalletAccount>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct WalletAccount {
pub program_owner: String,
pub balance: String,
pub nonce: String,
pub data: String,
}
pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 64 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 32];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(bytes)
}
pub(crate) fn parse_program_id(value: &str) -> Result<ProgramId, String> {
let bytes = parse_hex_32(value, "program id")?;
let mut program_id = [0_u32; 8];
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
let chunk: [u8; 4] = chunk
.try_into()
.map_err(|_| String::from("program id word has invalid length"))?;
*word = u32::from_le_bytes(chunk);
}
Ok(program_id)
}
#[cfg(test)]
pub(crate) fn program_id_hex(program_id: ProgramId) -> String {
let bytes = program_id
.iter()
.flat_map(|word| word.to_le_bytes())
.collect::<Vec<_>>();
hex::encode(bytes)
}
pub(crate) fn program_id_base58(program_id: ProgramId) -> String {
AccountId::new(program_id_bytes(program_id)).to_string()
}
pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
pub(crate) fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
}
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
Ok(AccountId::new(parse_hex_32(value, label)?))
}
pub(crate) fn account_id_hex(account_id: AccountId) -> String {
hex::encode(account_id.into_value())
}
fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
if value.len() != 32
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 32 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 16];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(u128::from_le_bytes(bytes))
}
pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
if read.status != "ok" {
return Err(String::from("account read failed"));
}
let account_id = account_id_from_hex(&read.id, "account id")?;
let source = read
.account
.as_ref()
.ok_or_else(|| String::from("successful account read has no account"))?;
let program_owner = parse_program_id(&source.program_owner)?;
let balance = parse_le_u128(&source.balance, "account balance")?;
let nonce = parse_le_u128(&source.nonce, "account nonce")?;
if source.data.len() % 2 != 0
|| !source
.data
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(String::from(
"account data must be lowercase even-length hexadecimal",
));
}
let data =
hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?;
let data =
Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?;
Ok((
account_id,
Account {
program_owner,
balance,
data,
nonce: Nonce(nonce),
},
))
}
#[cfg(test)]
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
AccountRead {
id: account_id_hex(id),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: program_id_hex(account.program_owner),
balance: hex::encode(account.balance.to_le_bytes()),
nonce: hex::encode(account.nonce.0.to_le_bytes()),
data: hex::encode(account.data.as_ref()),
}),
}
}