Просмотр исходного кода

app/schema: split wallet.rs into multiple files

epiphany 2 месяцев назад
Родитель
Сommit
5d6f7d3d99

+ 0 - 3144
bin/app/src/app/schema/wallet.rs

@@ -1,3144 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2026 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use darkfi::system::msleep;
-use darkfi::tx::Transaction;
-use darkfi::util::parse::encode_base10;
-use darkfi_money_contract::model::TokenId;
-use darkfi_sdk::crypto::keypair::Address;
-use darkfi_serial::{deserialize, Decodable, Encodable};
-use super::{ColorScheme, COLOR_SCHEME};
-use std::sync::{Arc, Mutex};
-
-macro_rules! d { ($($arg:tt)*) => { debug!(target: "app::schema::wallet", $($arg)*); } }
-macro_rules! e { ($($arg:tt)*) => { error!(target: "app::schema::wallet", $($arg)*); } }
-
-use crate::{
-    app::{
-        App, node::{
-            create_button, create_layer, create_singleline_edit, create_text,
-            create_tokentable, create_vector_art,
-        }
-    },
-    expr,
-    gfx::{Renderer, gfxtag},
-    mesh::{COLOR_CYAN, COLOR_TEAL},
-    prop::{Property, PropertyAtomicGuard, PropertyBool, PropertyFloat32, PropertyRect, PropertyStr, PropertySubType, PropertyType, Role},
-    scene::{CallArgType, Pimpl, SceneNodePtr, Slot},
-    shape,
-    ui::{BaseEdit, BaseEditType, Button, Layer, Text, TokenRow, TokenTable, VectorArt, VectorShape},
-    util::i18n::I18nBabelFish,
-};
-
-pub const BALANCE_BASE10_DECIMALS: usize = 8;
-
-#[cfg(any(target_os = "android", feature = "emulate-android"))]
-mod android_ui_consts {
-    pub const BACKARROW_SCALE: f32 = 30.;
-    pub const BACKARROW_X: f32 = 50.;
-    pub const BACKARROW_Y: f32 = 70.;
-    pub const TITLE_FONTSIZE: f32 = 56.;
-    pub const BUTTON_FONTSIZE: f32 = 48.;
-    pub const BASE_FONTSIZE: f32 = 48.;
-    pub const HINT_FONTSIZE: f32 = BASE_FONTSIZE * 0.8;
-    pub const AMOUNT_FONTSIZE: f32 = 118.;
-    pub const AMOUNT_CHAR_WIDTH: f32 = AMOUNT_FONTSIZE * 0.6;
-    pub const AMOUNT_TOKEN_SPACING: f32 = AMOUNT_FONTSIZE * 0.35;
-    pub const BUTTON_HEIGHT: f32 = 200.;
-    pub const TOKEN_ROW_HEIGHT: f32 = 80.;
-    pub const TITLE_PADDING: f32 = 50.;
-    pub const TOKEN_NAME_OFFSET: f32 = 200.;
-    pub const PADDING_X: f32 = 40.;
-    pub const PADDING_Y: f32 = 30.;
-    pub const RECIPIENT_INPUT_MARGIN: f32 = 30.;
-    pub const RECIPIENT_INPUT_PADDING_X: f32 = 40.;
-    pub const RECIPIENT_INPUT_HEIGHT: f32 = 120.;
-    pub const RECIPIENT_INPUT_FONTSIZE: f32 = 48.;
-    pub const HEADER_HEIGHT: f32 = 140.;
-    pub const ROW_HEIGHT: f32 = 80.;
-    pub const WALLET_BTN_SIZE: f32 = 50.;
-    pub const COPY_WIDTH: f32 = 200.;
-    pub const COPY_SCALE: f32 = 30.;
-}
-
-#[cfg(target_os = "android")]
-mod ui_consts {
-    pub use super::android_ui_consts::*;
-}
-
-#[cfg(feature = "emulate-android")]
-mod ui_consts {
-    pub use super::android_ui_consts::*;
-}
-
-#[cfg(all(
-    any(target_os = "linux", target_os = "macos", target_os = "windows"),
-    not(feature = "emulate-android")
-))]
-mod ui_consts {
-    pub const BACKARROW_SCALE: f32 = 15.;
-    pub const BACKARROW_X: f32 = 38.;
-    pub const BACKARROW_Y: f32 = 26.;
-    pub const TITLE_FONTSIZE: f32 = 20.;
-    pub const BUTTON_FONTSIZE: f32 = 20.;
-    pub const BASE_FONTSIZE: f32 = 20.;
-    pub const HINT_FONTSIZE: f32 = BASE_FONTSIZE * 0.8;
-    pub const AMOUNT_FONTSIZE: f32 = 56.;
-    pub const AMOUNT_CHAR_WIDTH: f32 = AMOUNT_FONTSIZE * 0.6;
-    pub const AMOUNT_TOKEN_SPACING: f32 = AMOUNT_FONTSIZE * 0.35;
-    pub const BUTTON_HEIGHT: f32 = 90.;
-    pub const TOKEN_ROW_HEIGHT: f32 = 40.;
-    pub const TITLE_PADDING: f32 = 25.;
-    pub const TOKEN_NAME_OFFSET: f32 = 90.;
-    pub const PADDING_X: f32 = 20.;
-    pub const PADDING_Y: f32 = 15.;
-    pub const RECIPIENT_INPUT_MARGIN: f32 = 15.;
-    pub const RECIPIENT_INPUT_PADDING_X: f32 = 20.;
-    pub const RECIPIENT_INPUT_HEIGHT: f32 = 60.;
-    pub const RECIPIENT_INPUT_FONTSIZE: f32 = 20.;
-    pub const HEADER_HEIGHT: f32 = 60.;
-    pub const ROW_HEIGHT: f32 = 80.;
-    pub const WALLET_BTN_SIZE: f32 = 50.;
-    pub const COPY_WIDTH: f32 = 100.;
-    pub const COPY_SCALE: f32 = 15.;
-}
-
-use ui_consts::*;
-
-const MOCK_TOKENS: &[(&str, &str, f32)] = &[
-    ("DRK", "Dark", 100.24),
-    ("wXMR", "wrapped Monero", 5.56487),
-    ("wBTC", "wrapped Bitcoin", 0.78956413),
-    ("RNDM", "Random", 0.78956413),
-    ("OTHER", "Other token", 0.78956413),
-    ("OTHER", "Other token", 0.78956413),
-    ("OTHER", "Other token", 0.78956413),
-    ("OTHER", "Other token", 0.78956413),
-];
-
-const MOCK_RECEIVE_ADDRESS: &str = "8siycy4q5rsf3rwxa9ptw5v7syfj1m1ov27tt78rj7kprth1ux41lqpvut6cht7w";
-const MOCK_TX_FEE: &str = "0.001 DRK";
-
-fn is_valid_address(address: &str) -> bool {
-    address.len() > 3 // TODO
-}
-
-fn get_balance(token_symbol: &str) -> f32 {
-    MOCK_TOKENS
-        .iter()
-        .find(|(symbol, _, _)| *symbol == token_symbol)
-        .map(|(_, _, balance)| *balance)
-        .unwrap_or(0.0)
-}
-
-// Send transaction data shared across all send pages
-#[derive(Debug, Clone)]
-struct SendTxData {
-    token_symbol: Option<String>,
-    token_name: Option<String>,
-    token_id: Option<TokenId>,
-    recipient_str: Option<String>,
-    recipient: Option<Address>,
-    amount: Option<String>,
-    tx_built: bool,
-    tx: Option<Transaction>,
-}
-
-impl SendTxData {
-    fn new() -> Self {
-        Self {
-            token_symbol: None,
-            token_name: None,
-            token_id: None,
-            recipient_str: None,
-            recipient: None,
-            amount: None,
-            tx_built: false,
-            tx: None,
-        }
-    }
-}
-
-pub async fn make(app: &App, content: SceneNodePtr, i18n_fish: &I18nBabelFish) {
-    let window_scale = PropertyFloat32::wrap(
-        &app.sg_root.lookup_node("/window").unwrap(),
-        Role::Internal,
-        "scale",
-        0,
-    )
-    .unwrap();
-
-    let mut cc = expr::Compiler::new();
-    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
-    cc.add_const_f32("TOKEN_ROW_HEIGHT", TOKEN_ROW_HEIGHT);
-    cc.add_const_f32("PADDING_X", PADDING_X);
-    cc.add_const_f32("PADDING_Y", PADDING_Y);
-    cc.add_const_f32("TITLE_FONTSIZE", TITLE_FONTSIZE);
-    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
-    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
-    cc.add_const_f32("ROW_HEIGHT", ROW_HEIGHT);
-
-    let atom = &mut PropertyAtomicGuard::none();
-
-    // ============================================
-    // Main Wallet Layer
-    // ============================================
-    let wallet_layer = create_layer("wallet_main_layer");
-    let prop = wallet_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    wallet_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    wallet_layer.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-    let wallet_layer = wallet_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(wallet_layer.clone());
-
-    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
-
-    create_bg_mesh(app, atom, &wallet_layer, "wallet_bg").await;
-    create_header_bg(app, atom, &wallet_layer, "wallet_header_bg").await;
-
-    // Back button
-    let node = create_vector_art("wallet_back_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    let node = create_button("wallet_back_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let sg_root = app.sg_root.clone();
-    let renderer = app.renderer.clone();
-    let menu_is_visible =
-        PropertyBool::wrap(&sg_root.lookup_node("/window/content/menu_layer").unwrap(), Role::App, "is_visible", 0).unwrap();
-    let wallet_is_visible1 = wallet_is_visible.clone();
-    let goback = async move || {
-        info!(target: "app::wallet", "clicked back from wallet");
-        let atom = &mut renderer.make_guard(gfxtag!("wallet goback action"));
-        wallet_is_visible1.set(atom, false);
-        menu_is_visible.set(atom, true);
-    };
-
-    let (slot, recvr) = Slot::new("wallet_back_clicked");
-    node.register("click", slot).unwrap();
-    let goback2 = goback.clone();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            goback2().await;
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    let mut y = HEADER_HEIGHT;
-
-    // Balance display
-    let node = create_text("wallet_balance");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + TITLE_PADDING).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "DRK 0").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    wallet_layer.link(node);
-
-    y += TITLE_PADDING * 2. + TITLE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, &wallet_layer, "wallet_balance_separator", &mut y).await;
-
-    // Receive button bg
-    let node = create_vector_art("wallet_receive_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        COLOR_TEAL,
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    // Receive button click handler
-    let node = create_button("wallet_receive_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-
-    let (receive_layer, receive_address_text) = wallet_receive_layer(app, content.clone(), wallet_layer.clone(), i18n_fish, window_scale.clone()).await;
-    let receive_is_visible =
-        PropertyBool::wrap(&receive_layer, Role::App, "is_visible", 0).unwrap();
-
-    let renderer = app.renderer.clone();
-    let wallet_is_visible2 = wallet_is_visible.clone();
-    let sg_root = app.sg_root.clone();
-    let (slot, recvr) = Slot::new("receive_clicked");
-    node.register("click", slot).unwrap();
-    let sg_root3 = sg_root.clone();
-    let listen_click = app.ex.spawn(async move {
-        while recvr.recv().await.is_ok() {
-            let atom = &mut renderer.make_guard(gfxtag!("receive button click"));
-            wallet_is_visible2.set(atom, false);
-            receive_is_visible.set(atom, true);
-
-            // Get the default address from drk plugin and update the UI
-            if let Some(drk_node) = sg_root3.lookup_node("/plugin/drk") {
-                if let Ok(Some(response_data)) = drk_node.call_method("get_default_address", vec![]).await {
-                    let mut cur = std::io::Cursor::new(response_data);
-                    if let Ok(address) = String::decode(&mut cur) {
-                        d!("Got default address from drk: {address}");
-                        receive_address_text.set(atom, address);
-                    } else {
-                        e!("Failed to decode default address response");
-                        receive_address_text.set(atom, MOCK_RECEIVE_ADDRESS);
-                    }
-                } else {
-                    e!("Failed to call get_default_address method");
-                    receive_address_text.set(atom, MOCK_RECEIVE_ADDRESS);
-                }
-            } else {
-                e!("Failed to lookup drk plugin node");
-                receive_address_text.set(atom, MOCK_RECEIVE_ADDRESS);
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    // Receive label
-    let node = create_text("wallet_receive_label");
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile("PADDING_X + (w / 2 - PADDING_X * 1.5) / 2 - (BUTTON_FONTSIZE * 0.6 * 7) / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X + BUTTON_HEIGHT / 2. - BUTTON_FONTSIZE / 1.8).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "receive").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    wallet_layer.link(node);
-
-    // Send button bg
-    let node = create_vector_art("wallet_send_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile("PADDING_X / 2 + w / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        COLOR_TEAL,
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    // Send button click handler
-    let node = create_button("wallet_send_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile("PADDING_X / 2 + w / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-
-    let send_layer = wallet_send_layer(app, content.clone(), wallet_layer.clone(), i18n_fish, window_scale.clone()).await;
-    let send_is_visible = PropertyBool::wrap(&send_layer, Role::App, "is_visible", 0).unwrap();
-    let renderer = app.renderer.clone();
-    let wallet_is_visible3 = wallet_is_visible.clone();
-    let (slot, recvr) = Slot::new("send_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("send button click"));
-            wallet_is_visible3.set(atom, false);
-            send_is_visible.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    wallet_layer.link(node);
-
-    // Send label
-    let node = create_text("wallet_send_label");
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile("PADDING_X / 2 + w / 2 + (w / 2 - PADDING_X * 1.5) / 2 - (BUTTON_FONTSIZE * 0.6 * 4) / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_X + BUTTON_HEIGHT / 2. - BUTTON_FONTSIZE / 1.8).unwrap();
-    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "send").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    wallet_layer.link(node);
-
-    y += PADDING_X * 2. + BUTTON_HEIGHT + 1.;
-
-    create_separator(&app.renderer, atom, &wallet_layer, "wallet_buttons_separator", &mut y).await;
-
-    create_title(app, atom, &wallet_layer, &window_scale, i18n_fish, "TOKENS", &mut y).await;
-
-    let mut cc = expr::Compiler::new();
-    cc.add_const_f32("PADDING_X", PADDING_X);
-    cc.add_const_f32("TOKENS_Y", y);
-
-    let tokens_table = create_tokentable("tokens_table");
-    let prop = tokens_table.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile("TOKENS_Y").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    tokens_table.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    tokens_table.set_property_f32(atom, Role::App, "column_spacing", TOKEN_NAME_OFFSET).unwrap();
-    tokens_table.set_property_f32(atom, Role::App, "padding_x", PADDING_X).unwrap();
-    tokens_table.set_property_f32(atom, Role::App, "padding_y", PADDING_Y).unwrap();
-    tokens_table.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    tokens_table.set_property_u32(atom, Role::App, "priority", 0).unwrap();
-
-    let prop = tokens_table.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-
-    let prop = tokens_table.get_property("separator_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.2).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.2745).unwrap();
-    prop.set_f32(atom, Role::App, 2, 0.2784).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-
-    let tokens_table = tokens_table
-        .setup(|me| TokenTable::new(me, app.renderer.clone(), app.sg_root.clone()))
-        .await;
-    wallet_layer.link(tokens_table.clone());
-
-    let wallet_layer = wallet_layer.clone();
-    let wallet_is_visible2 = wallet_is_visible.clone();
-    let tokens_table2 = tokens_table.clone();
-    let sg_root2 = app.sg_root.clone();
-    let renderer2 = app.renderer.clone();
-    let wallet_is_visible_sub = wallet_is_visible.prop().subscribe_modify();
-    let listen_wallet_visible = app.ex.spawn(async move {
-        while let Ok(_) = wallet_is_visible_sub.receive().await {
-            if wallet_is_visible2.get() {
-                let atom = &mut renderer2.make_guard(gfxtag!("wallet - refresh tokens"));
-
-                if let Some(drk_node) = sg_root2.lookup_node("/plugin/drk") {
-                    if let Ok(Some(response_data)) = drk_node.call_method("get_balances", vec![]).await {
-                        let mut cur = std::io::Cursor::new(response_data);
-                        if let Ok(balances) = Vec::<(String, TokenId, f32)>::decode(&mut cur) {
-                            let token_rows: Vec<TokenRow> = balances
-                                .iter()
-                                .enumerate()
-                                .map(|(i, (symbol, token_id, balance))| TokenRow {
-                                    id: *token_id,
-                                    symbol: symbol.clone(),
-                                    balance: balance.to_string(),
-                                })
-                                .collect();
-
-                            let mut data: Vec<u8> = vec![];
-                            for row in &token_rows {
-                                let _ = TokenRow::encode(row, &mut data);
-                            }
-
-                            let _ = tokens_table2.call_method("set_tokens", data).await;
-
-                            // Update main wallet balance
-                            use darkfi_money_contract::model::DARK_TOKEN_ID;
-                            if let Some(drk_balance) = balances.iter().find(|(_, token_id, _)| *token_id == *DARK_TOKEN_ID) {
-                                if let Some(balance_node) = sg_root2.lookup_node("/window/content/wallet_main_layer/wallet_balance") {
-                                    balance_node.set_property_str(atom, Role::App, "text", format!("DRK {}", drk_balance.2)).unwrap();
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_wallet_visible);
-}
-
-async fn wallet_receive_layer(
-    app: &App,
-    content: SceneNodePtr,
-    wallet_layer: SceneNodePtr,
-    i18n_fish: &I18nBabelFish,
-    window_scale: PropertyFloat32,
-) -> (SceneNodePtr, PropertyStr) {
-    let atom = &mut PropertyAtomicGuard::none();
-
-    let mut cc = expr::Compiler::new();
-    cc.add_const_f32("PADDING_Y", PADDING_Y);
-    cc.add_const_f32("COPY_WIDTH", COPY_WIDTH);
-
-    // Receive layer
-    let receive_layer = create_layer("wallet_receive_layer");
-    let prop = receive_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    receive_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    receive_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let receive_layer = receive_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(receive_layer.clone());
-
-    create_bg_mesh(app, atom, &receive_layer, "receive_bg").await;
-    create_header_bg(app, atom, &receive_layer, "receive_header_bg").await;
-
-    // Back button
-    let node = create_vector_art("receive_back_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    receive_layer.link(node);
-
-    let mut y = 0.;
-
-    let node = create_button("receive_back_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
-    let receive_is_visible = PropertyBool::wrap(&receive_layer, Role::App, "is_visible", 0).unwrap();
-    let renderer = app.renderer.clone();
-    let (slot, recvr) = Slot::new("receive_back_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("receive back button"));
-            receive_is_visible.set(atom, false);
-            wallet_is_visible.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    receive_layer.link(node);
-
-    y += HEADER_HEIGHT;
-
-    create_title(app, atom, &receive_layer, &window_scale, i18n_fish, "RECEIVE", &mut y).await;
-
-    create_subtitle(app, atom, &receive_layer, &window_scale, i18n_fish, "address", "Address", &mut y).await;
-
-    // Address display
-    let node = create_text("receive_address");
-    let addr_h_prop = node.get_property("height").unwrap();
-    let receive_address_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    let code = cc.compile("w - COPY_WIDTH").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE * 2.).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", MOCK_RECEIVE_ADDRESS).unwrap();
-    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    receive_layer.link(node);
-
-    // Copy button
-    let node = create_vector_art("receive_copy_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile("w - COPY_WIDTH / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    let code = cc.compile(format!("{y} + (PADDING_Y * 2. + addr_height) / 2")).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_copy(COLOR_CYAN).scaled(COPY_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    receive_layer.link(node);
-
-    let node = create_button("receive_copy_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    let code = cc.compile("w").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    let code = cc.compile("PADDING_Y * 2 + addr_height").unwrap();
-    prop.set_expr(atom, Role::App, 3, code).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-
-    let (slot, recvr) = Slot::new("receive_copy_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let addr = receive_address_text.get();
-            miniquad::window::clipboard_set(&addr);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    receive_layer.link(node);
-
-    let sep = create_separator_expr(app, atom, &receive_layer, "receive_address_separator", &mut cc, &format!("{y} + PADDING_Y * 2 + addr_height + 1")).await;
-    let prop = sep.get_property("rect").unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-
-    // Get the address text property to return
-    let receive_address_node = receive_layer.lookup_node("/receive_address").unwrap();
-    let receive_address_text = PropertyStr::wrap(&receive_address_node, Role::App, "text", 0).unwrap();
-
-    (receive_layer, receive_address_text)
-}
-
-async fn wallet_send_layer(
-    app: &App,
-    content: SceneNodePtr,
-    wallet_layer: SceneNodePtr,
-    i18n_fish: &I18nBabelFish,
-    window_scale: PropertyFloat32,
-) -> SceneNodePtr {
-    let mut cc = expr::Compiler::new();
-    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
-    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
-    cc.add_const_f32("PADDING_X", PADDING_X);
-    cc.add_const_f32("PADDING_Y", PADDING_Y);
-
-    let send_tx_data: Arc<Mutex<SendTxData>> = Arc::new(Mutex::new(SendTxData::new()));
-    let send_step1_layer =
-        wallet_send_layers(app, content.clone(), wallet_layer.clone(), i18n_fish, window_scale.clone(), send_tx_data).await;
-
-    send_step1_layer
-}
-
-/// Update positions for amount input wrapper and token symbol to center them together.
-fn update_amount_screen(
-    atom: &mut PropertyAtomicGuard,
-    amount_text: &str,
-    token_symbol: &str,
-    amount_wrapper_node: &SceneNodePtr,
-    amount_input_node: &SceneNodePtr,
-    token_node: &SceneNodePtr,
-    available_balance_node: Option<&SceneNodePtr>,
-) {
-    let mut cc = expr::Compiler::new();
-
-    let display_text = if amount_text.is_empty() { "0" } else { amount_text };
-    let char_count = display_text.chars().count() as f32;
-    let token_char_count = token_symbol.chars().count() as f32;
-
-    let text_width = char_count * AMOUNT_CHAR_WIDTH + 6.;
-    let token_width = token_char_count * AMOUNT_CHAR_WIDTH;
-    let total_width = text_width + AMOUNT_TOKEN_SPACING + token_width;
-    cc.add_const_f32("AMOUNT_TOTAL_WIDTH", total_width);
-    cc.add_const_f32("AMOUNT_WIDTH", text_width);
-    cc.add_const_f32("AMOUNT_TOKEN_SPACING", AMOUNT_TOKEN_SPACING);
-    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
-
-    let wrapper_rect = amount_wrapper_node.get_property("rect").unwrap();
-    wrapper_rect.set_expr(atom, Role::App, 0, cc.compile("(w - AMOUNT_TOTAL_WIDTH) / 2").unwrap()).unwrap();
-
-    let width_code = cc.compile("AMOUNT_WIDTH").unwrap();
-    let amount_rect = amount_input_node.get_property("rect").unwrap();
-    amount_rect.set_expr(atom, Role::App, 2, width_code).unwrap();
-
-    // Reset scroll to prevent content from being cropped
-    if let Pimpl::Edit(edit) = amount_input_node.pimpl() {
-        edit.reset_scroll();
-    }
-
-    // Update token symbol rect (x position)
-    let token_rect = token_node.get_property("rect").unwrap();
-    token_rect.set_expr(atom, Role::App, 0, cc.compile("(w - AMOUNT_TOTAL_WIDTH) / 2 + AMOUNT_TOKEN_SPACING + AMOUNT_WIDTH").unwrap()).unwrap();
-
-    // Set available balance
-    if let Some(available_balance_node) = available_balance_node {
-        let available_balance = get_balance(token_symbol);
-        available_balance_node.set_property_str(atom, Role::App, "text", format!("{available_balance} available")).unwrap();
-    }
-}
-
-async fn wallet_send_layers(
-    app: &App,
-    content: SceneNodePtr,
-    wallet_layer: SceneNodePtr,
-    i18n_fish: &I18nBabelFish,
-    window_scale: PropertyFloat32,
-    send_tx_data: Arc<Mutex<SendTxData>>,
-) -> SceneNodePtr {
-    let atom = &mut PropertyAtomicGuard::none();
-
-    let mut cc = expr::Compiler::new();
-    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
-    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
-    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
-    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
-    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
-    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
-    cc.add_const_f32("PADDING_X", PADDING_X);
-    cc.add_const_f32("PADDING_Y", PADDING_Y);
-    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
-    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
-
-    // ============================================
-    // Step 1: Pick token layer
-    // ============================================
-    let send_step1_layer = create_layer("wallet_send_step1_layer");
-    let prop = send_step1_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    send_step1_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    send_step1_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let send_step1_layer = send_step1_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(send_step1_layer.clone());
-    let step1_is_visible = PropertyBool::wrap(&send_step1_layer, Role::App, "is_visible", 0).unwrap();
-
-    create_bg_mesh(app, atom, &send_step1_layer, "send_bg").await;
-    create_header_bg(app, atom, &send_step1_layer, "send_header_bg").await;
-
-    // Back button
-    let node = create_vector_art("send_back_btn_bg");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    send_step1_layer.link(node);
-
-    let mut y = 0.;
-
-    let node = create_button("send_back_btn");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
-    let renderer = app.renderer.clone();
-    let wallet_is_visible1 = wallet_is_visible.clone();
-    let step1_is_visible1 = step1_is_visible.clone();
-    let (slot, recvr) = Slot::new("send_back_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("send back button"));
-            wallet_is_visible1.set(atom, true);
-            step1_is_visible1.set(atom, false);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    send_step1_layer.link(node);
-
-    y += HEADER_HEIGHT;
-
-    create_title(app, atom, &send_step1_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
-
-    create_subtitle(app, atom, &send_step1_layer, &window_scale, i18n_fish, "pick_label", "Pick a token to send", &mut y).await;
-
-    // ============================================
-    // Step 2: Recipient layer
-    // ============================================
-    let send_step2_layer = create_layer("wallet_send_step2_layer");
-    let prop = send_step2_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    send_step2_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    send_step2_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let send_step2_layer = send_step2_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(send_step2_layer.clone());
-    let step2_is_visible = PropertyBool::wrap(&send_step2_layer, Role::App, "is_visible", 0).unwrap();
-
-    // ============================================
-    // Step 3: Amount layer
-    // ============================================
-    let send_step3_layer = create_layer("wallet_send_step3_layer");
-    let prop = send_step3_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    send_step3_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    send_step3_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let send_step3_layer = send_step3_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(send_step3_layer.clone());
-    let step3_is_visible = PropertyBool::wrap(&send_step3_layer, Role::App, "is_visible", 0).unwrap();
-
-    // ============================================
-    // Step 4: Confirmation layer (amount as text, send button)
-    // ============================================
-    let send_step4_layer = create_layer("wallet_send_step4_layer");
-    let prop = send_step4_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    send_step4_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    send_step4_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let send_step4_layer = send_step4_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(send_step4_layer.clone());
-    let step4_is_visible = PropertyBool::wrap(&send_step4_layer, Role::App, "is_visible", 0).unwrap();
-
-    // ============================================
-    // Transaction status layer
-    // ============================================
-    let mut tx_status_layer = create_layer("wallet_tx_status_layer");
-    let prop = tx_status_layer.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    tx_status_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    tx_status_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-
-    let prop = Property::new("tx_id", PropertyType::Str, PropertySubType::Null);
-    tx_status_layer.add_property(prop).unwrap();
-    tx_status_layer.set_property_str(atom, Role::App, "tx_id", "").unwrap();
-
-    tx_status_layer.add_method(
-        "set_tx_status",
-        vec![
-            ("tx_id", "Transaction ID", CallArgType::Str),
-            ("status_text", "Transaction status text", CallArgType::Str),
-        ],
-        None,
-    ).unwrap();
-
-    tx_status_layer.add_method(
-        "set_built_tx",
-        vec![
-            ("tx_bytes", "Transaction bytes", CallArgType::Hash),
-        ],
-        None,
-    ).unwrap();
-
-    let tx_status_layer = tx_status_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    content.link(tx_status_layer.clone());
-    let tx_status_is_visible = PropertyBool::wrap(&tx_status_layer, Role::App, "is_visible", 0).unwrap();
-
-    let mut tx_y = 0.;
-    tx_y += HEADER_HEIGHT;
-    create_title(app, atom, &tx_status_layer, &window_scale, i18n_fish, "SEND", &mut tx_y).await;
-    let status_subtitle_node = create_subtitle(app, atom, &tx_status_layer, &window_scale, i18n_fish, "status", "", &mut tx_y).await;
-
-    // Transaction info text: "Sending {amount} {token_symbol} to {recipient_address}"
-    let node = create_text("send_tx_info5");
-    let info_h_prop = node.get_property("height").unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, tx_y + PADDING_Y).unwrap();
-    let code = cc.compile("w - PADDING_X * 2").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Sending 0 DRK to recipient").unwrap();
-    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    tx_status_layer.link(node);
-
-    let sep = create_separator(&app.renderer, atom, &tx_status_layer, "send_info_separator5", &mut 0.).await;
-    let prop = sep.get_property("rect").unwrap();
-    let code = cc.compile(format!("{tx_y} + PADDING_Y * 2 + info_height + 1")).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.add_depend(&info_h_prop, 0, "info_height");
-
-    // Hint text
-    let hint_text_node = create_text("send_close_hint5");
-    let prop = hint_text_node.get_property("rect").unwrap();
-    let code = cc.compile("w / 2 - (HINT_FONTSIZE * 0.7 * 31) / 2").unwrap();
-    prop.set_expr(atom, Role::App, 0, code).unwrap();
-    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - HINT_FONTSIZE * 2").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("HINT_FONTSIZE * 0.7 * 31").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, HINT_FONTSIZE/2.).unwrap();
-    hint_text_node.set_property_f32(atom, Role::App, "font_size", HINT_FONTSIZE).unwrap();
-    hint_text_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
-    hint_text_node.set_property_str(atom, Role::App, "text", "You can close this screen while the transaction is confirming.").unwrap();
-    let prop = hint_text_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 0.7).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 0.7).unwrap();
-    }
-    hint_text_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-
-    let node = hint_text_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    tx_status_layer.link(node);
-
-    // Close label (bottom button)
-    let close_label_node = create_text("send_close_label");
-    let prop = close_label_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT + BUTTON_HEIGHT / 2 - BUTTON_FONTSIZE / 1.8").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    close_label_node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-    close_label_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
-    close_label_node.set_property_str(atom, Role::App, "text", "close").unwrap();
-    let prop = close_label_node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-    prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-    prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-    prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-    close_label_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-    let close_label_node = close_label_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    tx_status_layer.link(close_label_node);
-
-    // Close button
-    let node = create_bottom_button(
-        app,
-        atom,
-        &tx_status_layer,
-        "send_close_btn",
-        &mut cc,
-        Some("close"),
-        &window_scale,
-        i18n_fish,
-    ).await;
-
-    // Click handler
-    let wallet_is_visible = wallet_is_visible.clone();
-    let tx_status_is_visible_for_close = tx_status_is_visible.clone();
-    let renderer = app.renderer.clone();
-    let (slot, recvr) = Slot::new("send_close_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("close button"));
-            tx_status_is_visible_for_close.set(atom, false);
-            wallet_is_visible.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    create_bg_mesh(app, atom, &tx_status_layer, "send_bg5").await;
-    create_header_bg(app, atom, &tx_status_layer, "send_header_bg5").await;
-
-    let method_sub = tx_status_layer.subscribe_method_call("set_tx_status").unwrap();
-    let renderer = app.renderer.clone();
-    let sg_root2 = app.sg_root.clone();
-    let tx_status_layer2 = tx_status_layer.clone();
-    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
-        while let Ok(method_call) = method_sub.receive().await {
-            let mut cur = std::io::Cursor::new(method_call.data);
-            let tx_id = Option::<String>::decode(&mut cur).unwrap();
-            let status_text = Option::<String>::decode(&mut cur).unwrap();
-            let amount = Option::<String>::decode(&mut cur).unwrap();
-            let token_symbol = Option::<String>::decode(&mut cur).unwrap();
-            let recipient_str = Option::<String>::decode(&mut cur).unwrap();
-            let atom = &mut renderer.make_guard(gfxtag!("set_tx_status"));
-            if let Some(tx_id) = tx_id {
-                tx_status_layer2.set_property_str(atom, Role::App, "tx_id", tx_id.as_str()).unwrap();
-            }
-            if let Some(status_text) = status_text {
-                status_subtitle_node.set_property_str(atom, Role::App, "text", status_text.as_str()).unwrap();
-            }
-            if let Some(tx_info_node) = sg_root2.lookup_node("/window/content/wallet_tx_status_layer/send_tx_info5") {
-                if amount.is_some() && token_symbol.is_some() && recipient_str.is_some() {
-                    let tx_text = format!("Sending {} {} to {}", amount.unwrap(), token_symbol.unwrap(), recipient_str.unwrap());
-                    tx_info_node.set_property_str(atom, Role::App, "text", tx_text).unwrap();
-                }
-            }
-        }
-    }));
-
-    let set_built_tx_sub = tx_status_layer.subscribe_method_call("set_built_tx").unwrap();
-    let send_tx_data8 = send_tx_data.clone();
-    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
-        while let Ok(method_call) = set_built_tx_sub.receive().await {
-            let mut cur = std::io::Cursor::new(method_call.data);
-            if let Ok(tx) = Transaction::decode(&mut cur) {
-                let mut data = send_tx_data8.lock().unwrap();
-                data.tx_built = true;
-                data.tx = Some(tx);
-            }
-        }
-    }));
-
-    // ========
-
-    let step2_is_visible_for_focus = step2_is_visible.clone();
-    let sg_root_for_focus = app.sg_root.clone();
-    let step2_is_visible_sub = step2_is_visible.prop().subscribe_modify();
-    let listen_step2_visible = app.ex.spawn(async move {
-        while let Ok(_) = step2_is_visible_sub.receive().await {
-            let recipient_input_node = sg_root_for_focus.lookup_node("/window/content/wallet_send_step2_layer/send_recipient_input").unwrap();
-            if step2_is_visible_for_focus.get() {
-                // Focus when becoming visible
-                loop {
-                    msleep(16).await;
-                    let input_rect = PropertyRect::wrap(&recipient_input_node, Role::App, "rect").unwrap();
-                    if input_rect.has_cached() {
-                        break;
-                    }
-                }
-                recipient_input_node.call_method("focus", vec![]).await.unwrap();
-            } else {
-                // Unfocus when becoming hidden
-                recipient_input_node.call_method("unfocus", vec![]).await.unwrap();
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_step2_visible);
-
-    let send_tokens_table = create_tokentable("tokens_table");
-    let prop = send_tokens_table.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    send_tokens_table.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    send_tokens_table.set_property_f32(atom, Role::App, "column_spacing", TOKEN_NAME_OFFSET).unwrap();
-    send_tokens_table.set_property_f32(atom, Role::App, "padding_x", PADDING_X).unwrap();
-    send_tokens_table.set_property_f32(atom, Role::App, "padding_y", PADDING_Y).unwrap();
-    send_tokens_table.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    send_tokens_table.set_property_u32(atom, Role::App, "priority", 0).unwrap();
-
-    let prop = send_tokens_table.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-
-    let prop = send_tokens_table.get_property("separator_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.2).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.2745).unwrap();
-    prop.set_f32(atom, Role::App, 2, 0.2784).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-
-    let send_tokens_table = send_tokens_table
-        .setup(|me| TokenTable::new(me, app.renderer.clone(), app.sg_root.clone()))
-        .await;
-    send_step1_layer.link(send_tokens_table.clone());
-
-    let (slot, recvr) = Slot::new("token_row_clicked");
-    send_tokens_table.register("row_click", slot).unwrap();
-    let sg_root = app.sg_root.clone();
-    let renderer = app.renderer.clone();
-    let send_tx_data2 = send_tx_data.clone();
-    let step1_is_visible3 = step1_is_visible.clone();
-    let step2_is_visible3 = step2_is_visible.clone();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(data) = recvr.recv().await {
-            let mut cur = std::io::Cursor::new(data);
-            if let Ok(row) = TokenRow::decode(&mut cur) {
-                let mut data = send_tx_data2.lock().unwrap();
-                data.token_symbol = Some(row.symbol.clone());
-                data.token_id = Some(row.id.clone());
-                drop(data);
-
-                let atom = &mut renderer.make_guard(gfxtag!("token selection"));
-                let selected_token_symbol = sg_root.lookup_node("/window/content/wallet_send_step2_layer/send_selected_token_symbol").unwrap();
-                selected_token_symbol.set_property_str(atom, Role::App, "text", &row.symbol).unwrap();
-                // let selected_token_name = sg_root.lookup_node("/window/content/wallet_send_step2_layer/send_selected_token_name").unwrap();
-                // selected_token_name.set_property_str(atom, Role::App, "text", &row.name).unwrap();
-
-                step1_is_visible3.set(atom, false);
-                step2_is_visible3.set(atom, true);
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let step1_is_visible3 = step1_is_visible.clone();
-    let sg_root2 = app.sg_root.clone();
-    let renderer2 = app.renderer.clone();
-    let send_tokens_table2 = send_tokens_table.clone();
-    let step1_is_visible_sub = step1_is_visible.prop().subscribe_modify();
-    let listen_step1_visible = app.ex.spawn(async move {
-        while let Ok(_) = step1_is_visible_sub.receive().await {
-            if step1_is_visible3.get() {
-                let atom = &mut renderer2.make_guard(gfxtag!("wallet - refresh send tokens"));
-
-                if let Some(drk_node) = sg_root2.lookup_node("/plugin/drk") {
-                    if let Ok(Some(response_data)) = drk_node.call_method("get_balances", vec![]).await {
-                        let mut cur = std::io::Cursor::new(response_data);
-                        if let Ok(balances) = Vec::<(String, TokenId, f32)>::decode(&mut cur) {
-                            let token_rows: Vec<TokenRow> = balances
-                                .iter()
-                                .enumerate()
-                                .map(|(i, (symbol, token_id, balance))| TokenRow {
-                                    id: *token_id,
-                                    symbol: symbol.clone(),
-                                    balance: balance.to_string(),
-                                })
-                                .collect();
-
-                            let mut data: Vec<u8> = vec![];
-                            for row in &token_rows {
-                                let _ = TokenRow::encode(row, &mut data);
-                            }
-
-                            let _ = send_tokens_table2.call_method("set_tokens", data).await;
-
-                            // Update main wallet balance
-                            use darkfi_money_contract::model::DARK_TOKEN_ID;
-                            if let Some(drk_balance) = balances.iter().find(|(_, token_id, _)| *token_id == *DARK_TOKEN_ID) {
-                                if let Some(balance_node) = sg_root2.lookup_node("/window/content/wallet_main_layer/wallet_balance") {
-                                    balance_node.set_property_str(atom, Role::App, "text", format!("DRK {}", drk_balance.2)).unwrap();
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_step1_visible);
-
-    create_bg_mesh(app, atom, &send_step2_layer, "send_bg2").await;
-    create_header_bg(app, atom, &send_step2_layer, "send_header_bg2").await;
-
-    // Back button
-    let node = create_vector_art("send_back_btn_bg2");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    send_step2_layer.link(node);
-
-    y = 0.;
-
-    let node = create_button("send_back_btn2");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let step1_is_visible2 = step1_is_visible.clone();
-    let step2_is_visible1 = step2_is_visible.clone();
-    let renderer = app.renderer.clone();
-    let (slot, recvr) = Slot::new("send_back_clicked2");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("send step2 back button"));
-            step2_is_visible1.set(atom, false);
-            step1_is_visible2.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    send_step2_layer.link(node);
-
-    y += HEADER_HEIGHT;
-
-    create_title(app, atom, &send_step2_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
-
-    // Selected token display
-    let node = create_text("send_selected_token_symbol");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Token").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step2_layer.link(node);
-
-    // let node = create_text("send_selected_token_name");
-    // let prop = node.get_property("rect").unwrap();
-    // prop.set_f32(atom, Role::App, 0, PADDING_X + TOKEN_NAME_OFFSET).unwrap();
-    // prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    // prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    // prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    // node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    // node.set_property_str(atom, Role::App, "text", "DRK").unwrap();
-    // let prop = node.get_property("text_color").unwrap();
-    // if COLOR_SCHEME == ColorScheme::DarkMode {
-    //     prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-    //     prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-    //     prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    //     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    // } else {
-    //     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    //     prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    //     prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-    //     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    // }
-    // node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    // let selected_token_text = node
-    //     .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-    //     .await;
-    // send_step2_layer.link(selected_token_text.clone());
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, &send_step2_layer, "send_token_separator", &mut y).await;
-
-    // Recipient label
-    let node = create_text("send_recipient_label");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step2_layer.link(node);
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE;
-
-    // Recipient input outline
-    let node = create_vector_art("send_recipient_input_outline");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    let code = cc.compile("w - RECIPIENT_INPUT_MARGIN * 2").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, RECIPIENT_INPUT_HEIGHT).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        [0.2, 0.2745, 0.2784, 1.],
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    send_step2_layer.link(node);
-
-    // Recipient placeholder
-    let recipient_placeholder_node = create_text("send_recipient_input_placeholder");
-    let prop = recipient_placeholder_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + (RECIPIENT_INPUT_HEIGHT - RECIPIENT_INPUT_FONTSIZE) / 2.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 200.).unwrap();
-    recipient_placeholder_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-    recipient_placeholder_node.set_property_f32(atom, Role::App, "font_size", RECIPIENT_INPUT_FONTSIZE).unwrap();
-    recipient_placeholder_node.set_property_str(atom, Role::App, "text", "recipient address...").unwrap();
-    let prop = recipient_placeholder_node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 0.45).unwrap();
-    recipient_placeholder_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-
-    let recipient_placeholder_node = recipient_placeholder_node
-        .setup(|me| {
-            Text::new(
-                me,
-                window_scale.clone(),
-                app.renderer.clone(),
-                i18n_fish.clone(),
-            )
-        })
-        .await;
-    send_step2_layer.link(recipient_placeholder_node.clone());
-
-    // Recipient input
-    let recipient_input = create_singleline_edit("send_recipient_input");
-    recipient_input.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    recipient_input.set_property_bool(atom, Role::App, "is_focused", false).unwrap();
-    let prop = recipient_input.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    let code = cc.compile("parent_w - RECIPIENT_INPUT_MARGIN * 2 - RECIPIENT_INPUT_PADDING_X").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, RECIPIENT_INPUT_HEIGHT).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "font_size", RECIPIENT_INPUT_FONTSIZE).unwrap();
-    let prop = recipient_input.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    let prop = recipient_input.get_property("hi_bg_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::PaperLight {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    let prop = recipient_input.get_property("text_hi_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.44).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.96).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    let prop = recipient_input.get_property("cursor_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.816).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.627).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "cursor_ascent", 0.).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "cursor_descent", RECIPIENT_INPUT_FONTSIZE*1.3).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "select_ascent", RECIPIENT_INPUT_FONTSIZE*1.3).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "select_descent", RECIPIENT_INPUT_FONTSIZE/3.).unwrap();
-    recipient_input.set_property_f32(atom, Role::App, "handle_descent", RECIPIENT_INPUT_FONTSIZE/2.5).unwrap();
-    recipient_input.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let recipient_input = recipient_input
-        .setup(|me| {
-            BaseEdit::new(
-                me,
-                window_scale.clone(),
-                app.renderer.clone(),
-                BaseEditType::SingleLine,
-                app.ex.clone(),
-            )
-        })
-        .await;
-    send_step2_layer.link(recipient_input.clone());
-
-    y += RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_HEIGHT;
-
-    // Add recipient button
-    let (node, btn_bg_valid, btn_bg_invalid, add_recipient_label_node) = create_bottom_button_with_states(
-        app,
-        atom,
-        &send_step2_layer,
-        "send_add_recipient_btn",
-        &mut cc,
-        "add recipient",
-        &window_scale,
-        i18n_fish,
-        false,
-    ).await;
-    let recipient_input2 = recipient_input.clone();
-    let recipient_text = recipient_input.get_property("text").unwrap();
-    let recipient_text_sub = recipient_text.subscribe_modify();
-    let renderer = app.renderer.clone();
-    let btn_bg_valid_clone = btn_bg_valid.clone();
-    let btn_bg_invalid_clone = btn_bg_invalid.clone();
-    let listen_recipient_text = app.ex.spawn(async move {
-        while let Ok(_) = recipient_text_sub.receive().await {
-            let atom = &mut renderer.make_guard(gfxtag!("wallet recipient input recv"));
-            let text_color = recipient_placeholder_node.get_property("text_color").unwrap();
-            let label_text_color = add_recipient_label_node.get_property("text_color").unwrap();
-            let btn_bg_valid_visible = btn_bg_valid_clone.get_property("is_visible").unwrap();
-            let btn_bg_invalid_visible = btn_bg_invalid_clone.get_property("is_visible").unwrap();
-            let addr = recipient_input2.get_property_str("text").unwrap();
-            if addr.is_empty() {
-                text_color.set_f32(atom, Role::App, 3, 0.45).unwrap();
-                // Grey color for invalid
-                label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
-                btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
-                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
-            } else {
-                text_color.set_f32(atom, Role::App, 3, 0.).unwrap();
-                // Cyan color for valid
-                if is_valid_address(&addr) {
-                    label_text_color.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-                    btn_bg_valid_visible.set_bool(atom, Role::App, 0, true).unwrap();
-                    btn_bg_invalid_visible.set_bool(atom, Role::App, 0, false).unwrap();
-                } else {
-                    // Grey color for invalid
-                    label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
-                    label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
-                    btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
-                    btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_recipient_text);
-
-    let step2_is_visible2 = step2_is_visible.clone();
-    let step3_is_visible2 = step3_is_visible.clone();
-    let renderer = app.renderer.clone();
-    let recipient_input2 = recipient_input.clone();
-    let send_tx_data3 = send_tx_data.clone();
-    let sg_root = app.sg_root.clone();
-    let (slot, recvr) = Slot::new("send_add_recipient_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let text = recipient_input2.get_property_str("text").unwrap();
-            // Only proceed if address is valid
-            if !is_valid_address(&text) {
-                continue;
-            }
-
-            let atom = &mut renderer.make_guard(gfxtag!("add recipient button"));
-
-            let mut tx_data = send_tx_data3.lock().unwrap();
-            tx_data.recipient_str = Some(text.clone());
-            // unwrap is okay here, recipient string is already verified by is_valid_address()
-            tx_data.recipient = Some(text.clone().parse::<Address>().unwrap());
-            drop(tx_data);
-
-            // Update step3 display with token and recipient
-            let data = send_tx_data3.lock().unwrap().clone();
-
-            if let Some(token_symbol) = &data.token_symbol {
-                let token_symbol_node = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_selected_token_symbol3").unwrap();
-                token_symbol_node.set_property_str(atom, Role::App, "text", token_symbol).unwrap();
-
-                // Update amount token symbol
-                if let Some(amount_token_node) = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_amount_token_symbol") {
-                    amount_token_node.set_property_str(atom, Role::App, "text", token_symbol).unwrap();
-
-                    // Update available balance
-                    let available_balance = get_balance(token_symbol);
-                    if let Some(available_balance_node) = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_available_balance") {
-                        available_balance_node.set_property_str(atom, Role::App, "text", format!("{available_balance} available")).unwrap();
-                    }
-                }
-            }
-            // if let Some(token_name) = &data.token_name {
-            //     let token_name_node = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_selected_token_name3").unwrap();
-            //     token_name_node.set_property_str(atom, Role::App, "text", token_name).unwrap();
-            // }
-            if let Some(recipient_str) = &data.recipient_str {
-                let recipient_node = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_recipient_value3").unwrap();
-                recipient_node.set_property_str(atom, Role::App, "text", recipient_str).unwrap();
-            }
-
-            step2_is_visible2.set(atom, false);
-            step3_is_visible2.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    // ============================================
-    // Step 3: Amount layer content
-    // ============================================
-    create_bg_mesh(app, atom, &send_step3_layer, "send_bg3").await;
-    create_header_bg(app, atom, &send_step3_layer, "send_header_bg3").await;
-
-    // Back button
-    let node = create_vector_art("send_back_btn_bg3");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    send_step3_layer.link(node);
-
-    let mut y = 0.;
-
-    let node = create_button("send_back_btn3");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let step2_is_visible3 = step2_is_visible.clone();
-    let step3_is_visible1 = step3_is_visible.clone();
-    let renderer = app.renderer.clone();
-    let (slot, recvr) = Slot::new("send_back_clicked3");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("send step3 back button"));
-            step3_is_visible1.set(atom, false);
-            step2_is_visible3.set(atom, true);
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    send_step3_layer.link(node);
-
-    y += HEADER_HEIGHT;
-
-    create_title(app, atom, &send_step3_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
-
-    // Selected token display (same layout as step2)
-    let node = create_text("send_selected_token_symbol3");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    // Set initial token value from send_tx_data
-    let token_symbol_text = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
-    node.set_property_str(atom, Role::App, "text", &token_symbol_text).unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step3_layer.link(node);
-
-    let node = create_text("send_selected_token_name3");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X + TOKEN_NAME_OFFSET).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    // Set initial token value from send_tx_data
-    let token_name_text = send_tx_data.lock().unwrap().token_name.clone().unwrap_or_else(|| "".to_string());
-    node.set_property_str(atom, Role::App, "text", &token_name_text).unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let selected_token_text3 = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step3_layer.link(selected_token_text3.clone());
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, &send_step3_layer, "send_token_separator3", &mut y).await;
-
-    // Recipient display
-    let node = create_text("send_recipient_label3");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step3_layer.link(node);
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
-
-    // Recipient address value
-    let node = create_text("send_recipient_value3");
-    let addr_h_prop = node.get_property("height").unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    let code = cc.compile("w - PADDING_X * 2").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "").unwrap();
-    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step3_layer.link(node);
-
-    // Separator line
-    let mut y_ = y.clone();
-    let y2 = format!("{y} + (PADDING_Y * 2. + addr_height) + 1");
-    let node = create_separator(&app.renderer, atom, &send_step3_layer, "send_amount_label_separator", &mut y_).await;
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile(&y2).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-
-    // Available balance text
-    let available_balance_node = create_text("send_available_balance");
-    let prop = available_balance_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    available_balance_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    available_balance_node.set_property_str(atom, Role::App, "text", "").unwrap();
-    let prop = available_balance_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    available_balance_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let available_balance_node = available_balance_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
-    send_step3_layer.link(available_balance_node.clone());
-
-    // Separator line below available balance
-    create_separator_expr(
-        app,
-        atom,
-        &send_step3_layer,
-        "available_balance_separator",
-        &mut cc,
-        "h - PADDING_X * 2 - BUTTON_HEIGHT",
-    ).await;
-
-    // Amount wrapper layer
-    let amount_y = format!("({y2}) + (h - ({y2}) - BUTTON_HEIGHT - PADDING_X * 2 - PADDING_Y - BASE_FONTSIZE - AMOUNT_FONTSIZE - 1) / 2");
-    let amount_wrapper = create_layer("send_amount_wrapper");
-    amount_wrapper.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-    let prop = amount_wrapper.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile(&amount_y).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    amount_wrapper.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let amount_wrapper = amount_wrapper.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    send_step3_layer.link(amount_wrapper.clone());
-
-    // Amount input
-    let input_node = create_singleline_edit("send_amount_input");
-    input_node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = input_node.get_property("rect").unwrap();
-    // Position at (0, 0) within wrapper - wrapper controls the position
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    cc.add_const_f32("AMOUNT_CHAR_WIDTH", AMOUNT_CHAR_WIDTH+6.); // Initial width for "0"
-    let code = cc.compile("AMOUNT_CHAR_WIDTH").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    input_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
-    let prop = input_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    let prop = input_node.get_property("hi_bg_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::PaperLight {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    let prop = input_node.get_property("text_hi_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.44).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.96).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    let prop = input_node.get_property("cursor_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.816).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.627).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    input_node.set_property_f32(atom, Role::App, "cursor_ascent", 0.).unwrap();
-    input_node.set_property_f32(atom, Role::App, "cursor_descent", AMOUNT_FONTSIZE*1.3).unwrap();
-    input_node.set_property_f32(atom, Role::App, "select_ascent", AMOUNT_FONTSIZE*1.3).unwrap();
-    input_node.set_property_f32(atom, Role::App, "select_descent", AMOUNT_FONTSIZE/3.).unwrap();
-    input_node.set_property_f32(atom, Role::App, "handle_descent", AMOUNT_FONTSIZE/2.5).unwrap();
-    input_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let input_node = input_node
-        .setup(|me| {
-            BaseEdit::new(
-                me,
-                window_scale.clone(),
-                app.renderer.clone(),
-                BaseEditType::SingleLine,
-                app.ex.clone(),
-            )
-        })
-        .await;
-    amount_wrapper.link(input_node.clone());
-
-    // Set initial text to "0" for the amount input
-    let input_text_prop = input_node.get_property("text").unwrap();
-    input_text_prop.set_str(atom, Role::App, 0, "0").unwrap();
-    // Update editor's internal state
-    if let Pimpl::Edit(edit) = input_node.pimpl() {
-        edit.on_text_prop_changed();
-    }
-
-    // Token symbol text node (displayed next to amount)
-    let token_symbol_node = create_text("send_amount_token_symbol");
-    let prop = token_symbol_node.get_property("rect").unwrap();
-    // Position will be dynamically updated based on amount text
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile(format!("{amount_y} - AMOUNT_FONTSIZE * 0.1")).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    token_symbol_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
-    // Initial text - will be set from send_tx_data.token_symbol
-    let token_symbol_initial = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
-    token_symbol_node.set_property_str(atom, Role::App, "text", &token_symbol_initial).unwrap();
-    let prop = token_symbol_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    token_symbol_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let token_symbol_node = token_symbol_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step3_layer.link(token_symbol_node.clone());
-
-    let token_symbol_initial = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
-    update_amount_screen(
-        atom,
-        "0",
-        &token_symbol_initial,
-        &amount_wrapper,
-        &input_node,
-        &token_symbol_node,
-        Some(&available_balance_node),
-    );
-
-    y += PADDING_Y * 2. + BUTTON_HEIGHT + 10.;
-
-    // Add amount button with states
-    let (node, btn_bg_valid, btn_bg_invalid, add_amount_label_node) = create_bottom_button_with_states(
-        app,
-        atom,
-        &send_step3_layer,
-        "send_add_amount_btn",
-        &mut cc,
-        "add amount",
-        &window_scale,
-        i18n_fish,
-        false, // Start with invalid state (grey)
-    ).await;
-
-    // Amount input validation listener
-    let amount_input2 = input_node.clone();
-    let amount_wrapper2 = amount_wrapper.clone();
-    let available_balance_node2 = available_balance_node.clone();
-    let token_symbol_node2 = token_symbol_node.clone();
-    let send_tx_data5 = send_tx_data.clone();
-    let amount_text = amount_input2.get_property("text").unwrap();
-    let amount_text_sub = amount_text.subscribe_modify();
-    let renderer = app.renderer.clone();
-    let btn_bg_valid_clone = btn_bg_valid.clone();
-    let btn_bg_invalid_clone = btn_bg_invalid.clone();
-    let add_amount_label_node_for_validation = add_amount_label_node.clone();
-    let listen_amount_text = app.ex.spawn(async move {
-        let mut old_amount_text = "0".to_string();
-        while let Ok(_) = amount_text_sub.receive().await {
-            let atom = &mut renderer.make_guard(gfxtag!("wallet amount input recv"));
-            let label_text_color = add_amount_label_node_for_validation.get_property("text_color").unwrap();
-            let btn_bg_valid_visible = btn_bg_valid_clone.get_property("is_visible").unwrap();
-            let btn_bg_invalid_visible = btn_bg_invalid_clone.get_property("is_visible").unwrap();
-            let amount_input_text_color = amount_input2.get_property("text_color").unwrap();
-            let amount = amount_input2.get_property_str("text").unwrap();
-
-            let sanitized_amount = if amount.is_empty() {
-                "0".to_string()
-            } else {
-                let mut result = String::new();
-                let mut dot_seen = false;
-                for c in amount.chars() {
-                    if c.is_ascii_digit() {
-                        result.push(c);
-                    } else if c == '.' && !dot_seen {
-                        dot_seen = true;
-                        result.push(c);
-                    }
-                }
-
-                // Strip leading zeros
-                let parts: Vec<&str> = result.split('.').collect();
-                let integer_part = if !parts.is_empty() && !parts[0].is_empty() {
-                    let trimmed = parts[0].trim_start_matches('0');
-                    let trimmed = if trimmed.is_empty() {
-                        "0".to_string()
-                    } else {
-                        trimmed.to_string()
-                    };
-
-                    // Forces removing "0" chars when the amount was previously "0".
-                    // This is so that when the input cursor is BEFORE the initial
-                    // "0" and you insert a digit, it removes the initial "0".
-                    if old_amount_text == "0" && trimmed != "0" && parts.len() == 1 {
-                        trimmed.chars().filter(|&c| c != '0').collect()
-                    } else {
-                        trimmed
-                    }
-                } else {
-                    "0".to_string()
-                };
-
-                if parts.len() > 1 {
-                    format!("{}.{}", integer_part, parts[1])
-                } else {
-                    integer_part
-                }
-            };
-
-            if sanitized_amount != amount {
-                let input_text_prop = amount_input2.get_property("text").unwrap();
-                input_text_prop.set_str(atom, Role::Ignored, 0, &sanitized_amount).unwrap();
-                if let Pimpl::Edit(edit) = amount_input2.pimpl() {
-                    edit.on_text_prop_changed();
-                }
-            }
-
-            old_amount_text = sanitized_amount.clone();
-
-            // Update positions to center amount and token symbol
-            let token_symbol = send_tx_data5.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
-            update_amount_screen(
-                atom,
-                &sanitized_amount,
-                &token_symbol,
-                &amount_wrapper2,
-                &amount_input2,
-                &token_symbol_node2,
-                Some(&available_balance_node2),
-            );
-            let available_balance = get_balance(&token_symbol);
-            let is_valid = if sanitized_amount == "0" {
-                false
-            } else {
-                match sanitized_amount.parse::<f32>() {
-                    Ok(v) if v > 0. && v <= available_balance => true,
-                    _ => false,
-                }
-            };
-
-            // Set amount input text color: grey if "0", white otherwise
-            if sanitized_amount == "0" {
-                amount_input_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
-            } else {
-                amount_input_text_color.set_f32(atom, Role::App, 0, 1.).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 1, 1.).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 2, 1.).unwrap();
-                amount_input_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
-            }
-
-            if is_valid {
-                label_text_color.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-                label_text_color.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-                label_text_color.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-                label_text_color.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-                btn_bg_valid_visible.set_bool(atom, Role::App, 0, true).unwrap();
-                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, false).unwrap();
-            } else {
-                label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
-                label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
-                btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
-                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_amount_text);
-
-    let renderer = app.renderer.clone();
-    let amount_input2 = input_node.clone();
-    let send_tx_data4 = send_tx_data.clone();
-    let step3_is_visible2 = step3_is_visible.clone();
-    let step4_is_visible2 = step4_is_visible.clone();
-    let sg_root = app.sg_root.clone();
-    let add_amount_btn_node = node.clone();
-    let add_amount_label_node2 = add_amount_label_node.clone();
-    let (slot, recvr) = Slot::new("send_add_amount_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let text = amount_input2.get_property_str("text").unwrap();
-            let available_balance = 100.24_f32; // TODO
-            let is_valid = if text.is_empty() {
-                false
-            } else {
-                match text.parse::<f32>() {
-                    Ok(v) if v > 0. && v <= available_balance => true,
-                    _ => false,
-                }
-            };
-            if is_valid {
-                let atom = &mut renderer.make_guard(gfxtag!("switch to step4 and show building"));
-
-                // Store the amount
-                let mut data = send_tx_data4.lock().unwrap();
-                data.amount = Some(text.clone());
-
-                // Update step4 display with transaction data
-                if let Some(token_symbol_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_selected_token_symbol4") {
-                    token_symbol_node.set_property_str(atom, Role::App, "text", &data.token_symbol.clone().unwrap()).unwrap();
-                }
-                if let Some(amount_token_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_amount_token_symbol4") {
-                    amount_token_node.set_property_str(atom, Role::App, "text", &data.token_symbol.clone().unwrap()).unwrap();
-                }
-                if let Some(recipient_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_recipient_value4") {
-                    recipient_node.set_property_str(atom, Role::App, "text", &data.recipient_str.clone().unwrap()).unwrap();
-                }
-                if let Some(amount_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_amount_wrapper4/send_amount_text4") {
-                    amount_node.set_property_str(atom, Role::App, "text", &data.amount.clone().unwrap()).unwrap();
-                }
-
-                // Switch to step4 and set send button to "building tx..."
-                step3_is_visible2.set(atom, false);
-                step4_is_visible2.set(atom, true);
-                if let Some(send_label_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
-                    send_label_node.set_property_str(atom, Role::App, "text", "building tx...").unwrap();
-                    let prop = send_label_node.get_property("text_color").unwrap();
-                    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-                    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-                    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-                    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-                }
-                if let Some(send_bg_grey_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg_grey") {
-                    send_bg_grey_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-                }
-                if let Some(send_bg_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
-                    send_bg_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-                }
-
-                // Build the transaction via DrkPlugin (non-blocking, emits tx_built signal when done)
-                if let (Some(drk_node), Some(token_id), Some(recipient)) = (
-                    sg_root.lookup_node("/plugin/drk"),
-                    data.token_id,
-                    data.recipient,
-                ) {
-                    let mut encoded_data = vec![];
-                    data.amount.clone().unwrap().encode(&mut encoded_data).unwrap();
-                    token_id.encode(&mut encoded_data).unwrap();
-                    recipient.public_key().encode(&mut encoded_data).unwrap();
-
-                    // Call build_tx - returns immediately, emits tx_built signal when done
-                    let _ = smol::block_on(drk_node.call_method("build_tx", encoded_data));
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    // Add listener for step3 visibility to focus/unfocus amount input
-    let step3_is_visible_clone = step3_is_visible.clone();
-    let renderer_clone = app.renderer.clone();
-    let amount_wrapper_clone = amount_wrapper.clone();
-    let input_node_clone = input_node.clone();
-    let token_symbol_node_clone = token_symbol_node.clone();
-    let send_tx_data_clone = send_tx_data.clone();
-    let step3_is_visible_sub = step3_is_visible.prop().subscribe_modify();
-    let listen_step3_visible = app.ex.spawn(async move {
-        while let Ok(_) = step3_is_visible_sub.receive().await {
-            if step3_is_visible_clone.get() {
-                loop { // TODO: this waits for draw, there's probably a better way
-                    msleep(50).await;
-                    let input_rect = PropertyRect::wrap(&input_node_clone, Role::App, "rect").unwrap();
-                    if input_rect.has_cached() {
-                        break;
-                    }
-                }
-
-                // Focus the amount input
-                input_node_clone.call_method("focus", vec![]).await.unwrap();
-
-                // Get the current token symbol and update positions
-                let token_symbol = send_tx_data_clone.lock().unwrap().token_symbol.clone().unwrap_or_default();
-                if !token_symbol.is_empty() {
-                    let atom = &mut renderer_clone.make_guard(gfxtag!("update amount positions on visible"));
-                    let current_amount = input_node_clone.get_property_str("text").unwrap();
-                    update_amount_screen(
-                        atom,
-                        &current_amount,
-                        &token_symbol,
-                        &amount_wrapper_clone,
-                        &input_node_clone,
-                        &token_symbol_node_clone,
-                        Some(&available_balance_node),
-                    );
-                }
-            } else {
-                // Unfocus when becoming hidden
-                input_node_clone.call_method("unfocus", vec![]).await.unwrap();
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_step3_visible);
-
-    // ============================================
-    // Step 4: Confirmation layer content
-    // ============================================
-    create_bg_mesh(app, atom, &send_step4_layer, "send_bg4").await;
-    create_header_bg(app, atom, &send_step4_layer, "send_header_bg4").await;
-
-    // Back button
-    let node = create_vector_art("send_back_btn_bg4");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    send_step4_layer.link(node);
-
-    let mut y = 0.;
-
-    let node = create_button("send_back_btn4");
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, y).unwrap();
-    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-
-    let step3_is_visible2 = step3_is_visible.clone();
-    let step4_is_visible1 = step4_is_visible.clone();
-    let renderer = app.renderer.clone();
-    let sg_root2 = app.sg_root.clone();
-    let (slot, recvr) = Slot::new("send_back_clicked4");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            let atom = &mut renderer.make_guard(gfxtag!("send step4 back button"));
-            step4_is_visible1.set(atom, false);
-            step3_is_visible2.set(atom, true);
-
-            // Reset button state when going back to Step 3
-            if let Some(btn_node) = sg_root2.lookup_node("/window/content/wallet_send_step3_layer/send_add_amount_btn") {
-                btn_node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-            }
-            if let Some(label_node) = sg_root2.lookup_node("/window/content/wallet_send_step3_layer/send_add_amount_btn_label") {
-                label_node.set_property_str(atom, Role::App, "text", "add amount").unwrap();
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    send_step4_layer.link(node);
-
-    y += HEADER_HEIGHT;
-
-    create_title(app, atom, &send_step4_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
-
-    // Selected token display
-    let node = create_text("send_selected_token_symbol4");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Token").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step4_layer.link(node);
-
-    let node = create_text("send_selected_token_name4");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X + TOKEN_NAME_OFFSET).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step4_layer.link(node);
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, &send_step4_layer, "send_token_separator4", &mut y).await;
-
-    // Recipient label
-    let node = create_text("send_recipient_label4");
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step4_layer.link(node);
-
-    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
-
-    // Recipient address value
-    let recipient_addr_node = create_text("send_recipient_value4");
-    let addr_h_prop = recipient_addr_node.get_property("height").unwrap();
-    let prop = recipient_addr_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
-    let code = cc.compile("w - PADDING_X * 2").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    recipient_addr_node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    recipient_addr_node.set_property_str(atom, Role::App, "text", "").unwrap();
-    recipient_addr_node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
-    let prop = recipient_addr_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    recipient_addr_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let recipient_addr_node = recipient_addr_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step4_layer.link(recipient_addr_node.clone());
-
-    // Separator line
-    let mut y_ = y.clone();
-    let y2 = format!("{y} + (PADDING_Y * 2. + addr_height) + 1");
-    let node = create_separator(&app.renderer, atom, &send_step4_layer, "send_amount_label_separator4", &mut y_).await;
-    let prop = node.get_property("rect").unwrap();
-    let code = cc.compile(&y2).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-
-    // Amount display - same layout as step3's amount input
-    let amount_y = format!("({y2}) + (h - ({y2}) - BUTTON_HEIGHT - PADDING_X * 2 - PADDING_Y - BASE_FONTSIZE - AMOUNT_FONTSIZE - 1) / 2 - AMOUNT_FONTSIZE * 0.1");
-    let amount_wrapper = create_layer("send_amount_wrapper4");
-    amount_wrapper.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-    let prop = amount_wrapper.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile(&amount_y).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    amount_wrapper.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let amount_wrapper = amount_wrapper.setup(|me| Layer::new(me, app.renderer.clone())).await;
-    send_step4_layer.link(amount_wrapper.clone());
-
-    // Amount text
-    let amount_text_node = create_text("send_amount_text4");
-    let prop = amount_text_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    amount_text_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
-    let prop = amount_text_node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    amount_text_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    amount_text_node.set_property_str(atom, Role::App, "text", "0").unwrap();
-    let amount_text_node = amount_text_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    amount_wrapper.link(amount_text_node.clone());
-
-    // Token symbol text node (displayed next to amount)
-    let token_symbol_node = create_text("send_amount_token_symbol4");
-    let prop = token_symbol_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile(amount_y).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
-    prop.add_depend(&addr_h_prop, 0, "addr_height");
-    token_symbol_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
-    token_symbol_node.set_property_str(atom, Role::App, "text", "").unwrap();
-    let prop = token_symbol_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    token_symbol_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let token_symbol_node = token_symbol_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    send_step4_layer.link(token_symbol_node.clone());
-
-    // Transaction fee label
-    let tx_fee_label_node = create_text("send_fee_label");
-    let prop = tx_fee_label_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    tx_fee_label_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    tx_fee_label_node.set_property_str(atom, Role::App, "text", "Transaction fee").unwrap();
-    let prop = tx_fee_label_node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    tx_fee_label_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    tx_fee_label_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    let prop = tx_fee_label_node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 0.).unwrap();
-    let tx_fee_label_node = tx_fee_label_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
-    send_step4_layer.link(tx_fee_label_node.clone());
-
-    // Transaction fee value
-    let tx_fee_value_node = create_text("send_fee_value");
-    let prop = tx_fee_value_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
-    tx_fee_value_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
-    tx_fee_value_node.set_property_str(atom, Role::App, "text", MOCK_TX_FEE).unwrap();
-    tx_fee_value_node.set_property_enum(atom, Role::App, "text_align", "end").unwrap();
-    tx_fee_value_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let prop = tx_fee_value_node.get_property("text_color").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 3, 0.).unwrap();
-    let tx_fee_value_node = tx_fee_value_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
-    send_step4_layer.link(tx_fee_value_node.clone());
-
-    // Separator line
-    create_separator_expr(
-        app,
-        atom,
-        &send_step4_layer,
-        "tx_fee_separator",
-        &mut cc,
-        "h - PADDING_X * 2 - BUTTON_HEIGHT",
-    ).await;
-
-    y += PADDING_Y * 2. + BUTTON_HEIGHT + 10.;
-
-    // Send button (bottom button)
-    let (node, _bg_valid, _bg_invalid, _label) = create_bottom_button_with_states(
-        app,
-        atom,
-        &send_step4_layer,
-        "send_send_btn",
-        &mut cc,
-        "send",
-        &window_scale,
-        i18n_fish,
-        true, // initial_valid
-    ).await;
-
-    let renderer = app.renderer.clone();
-    let sg_root = app.sg_root.clone();
-    let step4_is_visible1 = step4_is_visible.clone();
-    let tx_status_is_visible1 = tx_status_is_visible.clone();
-    let send_tx_data_for_send = send_tx_data.clone();
-    let tx_status = tx_status_layer.lookup_node("/status").unwrap();
-    let (slot, recvr) = Slot::new("send_send_clicked");
-    node.register("click", slot).unwrap();
-    let listen_click = app.ex.spawn(async move {
-        while let Ok(_) = recvr.recv().await {
-            // Skip if the button is disabled
-            if let Some(btn_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
-                if !btn_node.get_property_bool("is_visible").unwrap() {
-                    continue;
-                }
-            }
-            let atom = &mut renderer.make_guard(gfxtag!("send button"));
-
-            step4_is_visible1.set(atom, false);
-            tx_status_is_visible1.set(atom, true);
-
-            // Broadcast
-            let tx = send_tx_data_for_send.lock().unwrap().tx.clone();
-            if let (Some(tx), Some(drk_node)) = (tx, sg_root.lookup_node("/plugin/drk")) {
-                let mut encoded_data = vec![];
-                tx.encode(&mut encoded_data).unwrap();
-                if let Ok(Some(response_data)) = drk_node.call_method("broadcast_tx", encoded_data).await {
-                    let mut cur = std::io::Cursor::new(response_data);
-                    if let Ok(tx_id) = String::decode(&mut cur) {
-                        d!("Transaction broadcasted: {tx_id}");
-                        let mut tx_id_data = vec![];
-                        tx_id.encode(&mut tx_id_data).unwrap();
-                        if let Ok(Some(data)) = drk_node.call_method("get_tx_status", tx_id_data).await {
-                            let mut cur = std::io::Cursor::new(data);
-                            let status_text = String::decode(&mut cur).unwrap();
-                            tx_status.set_property_str(atom, Role::App, "text", status_text).unwrap();
-                        }
-                    }
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_click);
-
-    // Add listener for tx built signal to update send button label and show fee
-    let set_built_tx_sub = tx_status_layer.subscribe_method_call("set_built_tx").unwrap();
-    let renderer_for_built = app.renderer.clone();
-    let sg_root_for_built = app.sg_root.clone();
-    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
-        while let Ok(mcall) = set_built_tx_sub.receive().await {
-            let mut cur = std::io::Cursor::new(mcall.data);
-            let tx = Transaction::decode(&mut cur).unwrap();
-            let mut fees: u64 = 0;
-            for (i, call) in tx.calls.iter().enumerate() {
-                if call.data.is_money_fee() {
-                    if let Ok(fee) = deserialize(&call.data.data[1..9]) {
-                        fees = fees.checked_add(fee).unwrap_or(u64::MAX);
-                    }
-                }
-            }
-
-            let atom = &mut renderer_for_built.make_guard(gfxtag!("tx built - update send button"));
-
-            // Make send button active
-            if let Some(send_label_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
-                send_label_node.set_property_str(atom, Role::App, "text", "send").unwrap();
-                let prop = send_label_node.get_property("text_color").unwrap();
-                prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-                prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-                prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-                prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-            }
-            if let Some(send_bg_grey_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg_grey") {
-                send_bg_grey_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-            }
-            if let Some(send_bg_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
-                send_bg_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-            }
-
-            // Show transaction fee
-            if let Some(tx_fee_label) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
-                let prop = tx_fee_label.get_property("text_color").unwrap();
-                prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-            }
-            if let Some(tx_fee_value) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
-                tx_fee_value.set_property_str(atom, Role::App, "text", encode_base10(fees, BALANCE_BASE10_DECIMALS)).unwrap();
-                let prop = tx_fee_value.get_property("text_color").unwrap();
-                prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-                prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-            }
-        }
-    }));
-
-    // Add listener for step4 visibility to update amount positions
-    let step4_is_visible_clone = step4_is_visible.clone();
-    let renderer_clone = app.renderer.clone();
-    let amount_wrapper_clone = amount_wrapper.clone();
-    let amount_text_node_clone = amount_text_node.clone();
-    let token_symbol_node_clone = token_symbol_node.clone();
-    let send_tx_data_clone2 = send_tx_data.clone();
-    let sg_root_clone_for_visibility = app.sg_root.clone();
-    let step4_is_visible_sub = step4_is_visible.prop().subscribe_modify();
-    let listen_step4_visible = app.ex.spawn(async move {
-        while let Ok(_) = step4_is_visible_sub.receive().await {
-            if step4_is_visible_clone.get() {
-                loop {
-                    msleep(50).await;
-                    let text_rect = PropertyRect::wrap(&amount_text_node_clone, Role::App, "rect").unwrap();
-                    if text_rect.has_cached() {
-                        break;
-                    }
-                }
-
-                let atom = &mut renderer_clone.make_guard(gfxtag!("update step4 amount positions"));
-                let data = send_tx_data_clone2.lock().unwrap().clone();
-
-                let amount_text = data.amount.unwrap_or_else(|| "0".to_string());
-                let token_symbol = data.token_symbol.unwrap_or_else(|| "".to_string());
-
-                // Update positions to center amount and token symbol
-                update_amount_screen(
-                    atom,
-                    &amount_text,
-                    &token_symbol,
-                    &amount_wrapper_clone,
-                    &amount_text_node_clone,
-                    &token_symbol_node_clone,
-                    None,
-                );
-
-                if data.tx_built {
-                    // Set send button label
-                    if let Some(send_label_node) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
-                        send_label_node.set_property_str(atom, Role::App, "text", "send").unwrap();
-                    }
-                    // Show transaction fee
-                    if let Some(tx_fee_label) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
-                        let prop = tx_fee_label.get_property("text_color").unwrap();
-                        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-                    }
-                    if let Some(tx_fee_value) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
-                        let prop = tx_fee_value.get_property("text_color").unwrap();
-                        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-                        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-                    }
-                } else {
-                    // Hide transaction fee
-                    if let Some(tx_fee_label) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
-                        let prop = tx_fee_label.get_property("text_color").unwrap();
-                        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 3, 0.).unwrap();
-                    }
-                    if let Some(tx_fee_value) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
-                        let prop = tx_fee_value.get_property("text_color").unwrap();
-                        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-                        prop.set_f32(atom, Role::App, 3, 0.).unwrap();
-                    }
-                }
-            }
-        }
-    });
-    app.tasks.lock().unwrap().push(listen_step4_visible);
-
-    send_step1_layer
-}
-
-/// Creates a title text node with separator line.
-/// Returns the text node after setup, linked to the layer
-async fn create_title(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    window_scale: &PropertyFloat32,
-    i18n_fish: &I18nBabelFish,
-    name: &str,
-    y: &mut f32,
-) -> SceneNodePtr {
-    let node = create_text(name);
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, *y + TITLE_PADDING).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", name).unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
-    layer.link(node.clone());
-
-    *y += TITLE_PADDING * 2. + TITLE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, layer, &format!("{}_separator", name), y).await;
-    node
-}
-
-/// Creates a subtitle text node with separator line (e.g., "Pick a token to send", "Recipient", "Address")
-/// Returns the text node after setup, linked to the layer
-async fn create_subtitle(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    window_scale: &PropertyFloat32,
-    i18n_fish: &I18nBabelFish,
-    name: &str,
-    text: &str,
-    y: &mut f32,
-) -> SceneNodePtr {
-    let node = create_text(name);
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    prop.set_f32(atom, Role::App, 1, *y + PADDING_Y).unwrap();
-    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
-    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", text).unwrap();
-    let prop = node.get_property("text_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let node = node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
-    layer.link(node.clone());
-
-    *y += PADDING_Y * 2. + TITLE_FONTSIZE + 1.;
-
-    create_separator(&app.renderer, atom, layer, &format!("{}_separator", text), y).await;
-
-    node
-}
-
-/// Creates a background mesh with gradient box.
-async fn create_bg_mesh(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-) {
-    let node = create_vector_art(name);
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_gradient_box(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        [[0., 0., 0., 0.5], [0., 0., 0., 0.5], [0., 0., 0., 0.5], [0., 0., 0., 0.8]],
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(node);
-}
-
-/// Creates a header background with filled box and separator line at bottom.
-async fn create_header_bg(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-) {
-    let node = create_vector_art(name);
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-
-    let (bg_color, sep_color) = match COLOR_SCHEME {
-        ColorScheme::DarkMode => ([0., 0.11, 0.11, 1.], [0.2, 0.2745, 0.2784, 1.]),
-        ColorScheme::PaperLight => ([1., 1., 1., 1.], [0., 0.6, 0.65, 1.]),
-    };
-
-    let mut shape = VectorShape::new();
-    shape.add_filled_box(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::const_f32(HEADER_HEIGHT),
-        bg_color,
-    );
-    shape.add_filled_box(
-        expr::const_f32(0.),
-        expr::const_f32(HEADER_HEIGHT - 1.),
-        expr::load_var("w"),
-        expr::const_f32(HEADER_HEIGHT),
-        sep_color,
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(node);
-}
-
-/// Creates a separator line at the given y expression.
-/// Returns the separator node after setup, linked to the layer
-async fn create_separator_expr(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-    cc: &mut expr::Compiler,
-    y_expr: &str,
-) -> SceneNodePtr {
-    let sep_node = create_vector_art(name);
-    let prop = sep_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    let code = cc.compile(y_expr).unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    sep_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_filled_box(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::const_f32(1.),
-        [0.2, 0.2745, 0.2784, 1.],
-    );
-    let sep_node = sep_node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(sep_node.clone());
-    sep_node
-}
-
-/// Creates a separator line at the current y position and increments y.
-/// Returns the separator node after setup, linked to the layer
-async fn create_separator(
-    renderer: &Renderer,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-    y: &mut f32,
-) -> SceneNodePtr {
-    let sep_node = create_vector_art(name);
-    let prop = sep_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-    prop.set_f32(atom, Role::App, 1, *y).unwrap();
-    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
-    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    sep_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_filled_box(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::const_f32(1.),
-        [0.2, 0.2745, 0.2784, 1.],
-    );
-    let sep_node = sep_node.setup(|me| VectorArt::new(me, shape, renderer.clone())).await;
-    layer.link(sep_node.clone());
-
-    *y += 1.;
-    sep_node
-}
-
-/// Creates a bottom button with teal outline and click handler.
-/// Returns the button node after setup, linked to the layer.
-/// The caller can use the returned button node to add custom click handlers.
-async fn create_bottom_button(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-    cc: &mut expr::Compiler,
-    label_text: Option<&str>,
-    window_scale: &PropertyFloat32,
-    i18n_fish: &I18nBabelFish,
-) -> SceneNodePtr {
-    // Button bg (teal outline)
-    let node = create_vector_art(&format!("{}_bg", name));
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        COLOR_TEAL,
-    );
-    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(node.clone());
-
-    // Button label text (if provided)
-    if let Some(text) = label_text {
-        let label_node = create_text(&format!("{}_label", name));
-        let prop = label_node.get_property("rect").unwrap();
-        prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-        let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT + BUTTON_HEIGHT / 2 - BUTTON_FONTSIZE / 1.8").unwrap();
-        prop.set_expr(atom, Role::App, 1, code).unwrap();
-        let code = cc.compile("w - PADDING_X * 2.").unwrap();
-        prop.set_expr(atom, Role::App, 2, code).unwrap();
-        prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-        label_node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-        label_node.set_property_str(atom, Role::App, "text", text).unwrap();
-        label_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
-
-        let prop = label_node.get_property("text_color").unwrap();
-        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-        label_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-        let label_node = label_node
-            .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-            .await;
-        layer.link(label_node);
-    }
-
-    // Button
-    let node = create_button(name);
-    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-
-    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
-    layer.link(node.clone());
-    node
-}
-
-/// Creates a bottom button with two states: valid (teal) and invalid (grey).
-/// Returns a tuple of (button_node, bg_valid_node, bg_invalid_node, label_node) for visibility control.
-async fn create_bottom_button_with_states(
-    app: &App,
-    atom: &mut PropertyAtomicGuard,
-    layer: &SceneNodePtr,
-    name: &str,
-    cc: &mut expr::Compiler,
-    label_text: &str,
-    window_scale: &PropertyFloat32,
-    i18n_fish: &I18nBabelFish,
-    initial_valid: bool,
-) -> (SceneNodePtr, SceneNodePtr, SceneNodePtr, SceneNodePtr) {
-    // Button bg (teal outline - valid state)
-    let bg_valid = create_vector_art(&format!("{}_bg", name));
-    let prop = bg_valid.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    bg_valid.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    bg_valid.set_property_bool(atom, Role::App, "is_visible", initial_valid).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        COLOR_TEAL,
-    );
-    let bg_valid = bg_valid.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(bg_valid.clone());
-
-    // Button bg (grey outline - invalid state)
-    let bg_invalid = create_vector_art(&format!("{}_bg_grey", name));
-    let prop = bg_invalid.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    bg_invalid.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    bg_invalid.set_property_bool(atom, Role::App, "is_visible", !initial_valid).unwrap();
-    let mut shape = VectorShape::new();
-    shape.add_outline(
-        expr::const_f32(0.),
-        expr::const_f32(0.),
-        expr::load_var("w"),
-        expr::load_var("h"),
-        1.,
-        [0.5, 0.5, 0.5, 1.],
-    );
-    let bg_invalid = bg_invalid.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
-    layer.link(bg_invalid.clone());
-
-    // Button label text
-    let label_node = create_text(&format!("{}_label", name));
-    let prop = label_node.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT + BUTTON_HEIGHT / 2 - BUTTON_FONTSIZE / 1.8").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-    label_node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
-    label_node.set_property_str(atom, Role::App, "text", label_text).unwrap();
-    label_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
-
-    let prop = label_node.get_property("text_color").unwrap();
-    if initial_valid {
-        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
-        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
-        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
-        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-    label_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
-    let label_node = label_node
-        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
-        .await;
-    layer.link(label_node.clone());
-
-    // Button
-    let btn = create_button(name);
-    btn.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-    let prop = btn.get_property("rect").unwrap();
-    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
-    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
-    prop.set_expr(atom, Role::App, 1, code).unwrap();
-    let code = cc.compile("w - PADDING_X * 2.").unwrap();
-    prop.set_expr(atom, Role::App, 2, code).unwrap();
-    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
-
-    let btn = btn.setup(|me| Button::new(me, app.renderer.clone())).await;
-    layer.link(btn.clone());
-
-    (btn, bg_valid, bg_invalid, label_node)
-}

+ 152 - 0
bin/app/src/app/schema/wallet/data.rs

@@ -0,0 +1,152 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi::tx::Transaction;
+use darkfi_money_contract::model::TokenId;
+use darkfi_sdk::crypto::keypair::Address;
+
+use super::super::ColorScheme;
+
+#[cfg(any(target_os = "android", feature = "emulate-android"))]
+mod android_ui_consts {
+    pub const BACKARROW_SCALE: f32 = 30.;
+    pub const BACKARROW_X: f32 = 50.;
+    pub const BACKARROW_Y: f32 = 70.;
+    pub const TITLE_FONTSIZE: f32 = 56.;
+    pub const BUTTON_FONTSIZE: f32 = 48.;
+    pub const BASE_FONTSIZE: f32 = 48.;
+    pub const HINT_FONTSIZE: f32 = BASE_FONTSIZE * 0.8;
+    pub const AMOUNT_FONTSIZE: f32 = 118.;
+    pub const AMOUNT_CHAR_WIDTH: f32 = AMOUNT_FONTSIZE * 0.6;
+    pub const AMOUNT_TOKEN_SPACING: f32 = AMOUNT_FONTSIZE * 0.35;
+    pub const BUTTON_HEIGHT: f32 = 200.;
+    pub const TOKEN_ROW_HEIGHT: f32 = 80.;
+    pub const TITLE_PADDING: f32 = 50.;
+    pub const TOKEN_NAME_OFFSET: f32 = 200.;
+    pub const PADDING_X: f32 = 40.;
+    pub const PADDING_Y: f32 = 30.;
+    pub const RECIPIENT_INPUT_MARGIN: f32 = 30.;
+    pub const RECIPIENT_INPUT_PADDING_X: f32 = 40.;
+    pub const RECIPIENT_INPUT_HEIGHT: f32 = 120.;
+    pub const RECIPIENT_INPUT_FONTSIZE: f32 = 48.;
+    pub const HEADER_HEIGHT: f32 = 140.;
+    pub const ROW_HEIGHT: f32 = 80.;
+    pub const WALLET_BTN_SIZE: f32 = 50.;
+    pub const COPY_WIDTH: f32 = 200.;
+    pub const COPY_SCALE: f32 = 30.;
+}
+
+#[cfg(target_os = "android")]
+mod ui_consts {
+    pub use super::android_ui_consts::*;
+}
+
+#[cfg(feature = "emulate-android")]
+mod ui_consts {
+    pub use super::android_ui_consts::*;
+}
+
+#[cfg(all(
+    any(target_os = "linux", target_os = "macos", target_os = "windows"),
+    not(feature = "emulate-android")
+))]
+mod ui_consts {
+    pub const BACKARROW_SCALE: f32 = 15.;
+    pub const BACKARROW_X: f32 = 38.;
+    pub const BACKARROW_Y: f32 = 26.;
+    pub const TITLE_FONTSIZE: f32 = 20.;
+    pub const BUTTON_FONTSIZE: f32 = 20.;
+    pub const BASE_FONTSIZE: f32 = 20.;
+    pub const HINT_FONTSIZE: f32 = BASE_FONTSIZE * 0.8;
+    pub const AMOUNT_FONTSIZE: f32 = 56.;
+    pub const AMOUNT_CHAR_WIDTH: f32 = AMOUNT_FONTSIZE * 0.6;
+    pub const AMOUNT_TOKEN_SPACING: f32 = AMOUNT_FONTSIZE * 0.35;
+    pub const BUTTON_HEIGHT: f32 = 90.;
+    pub const TOKEN_ROW_HEIGHT: f32 = 40.;
+    pub const TITLE_PADDING: f32 = 25.;
+    pub const TOKEN_NAME_OFFSET: f32 = 90.;
+    pub const PADDING_X: f32 = 20.;
+    pub const PADDING_Y: f32 = 15.;
+    pub const RECIPIENT_INPUT_MARGIN: f32 = 15.;
+    pub const RECIPIENT_INPUT_PADDING_X: f32 = 20.;
+    pub const RECIPIENT_INPUT_HEIGHT: f32 = 60.;
+    pub const RECIPIENT_INPUT_FONTSIZE: f32 = 20.;
+    pub const HEADER_HEIGHT: f32 = 60.;
+    pub const ROW_HEIGHT: f32 = 80.;
+    pub const WALLET_BTN_SIZE: f32 = 50.;
+    pub const COPY_WIDTH: f32 = 100.;
+    pub const COPY_SCALE: f32 = 15.;
+}
+
+pub use ui_consts::*;
+
+pub const BALANCE_BASE10_DECIMALS: usize = 8;
+
+pub const MOCK_TOKENS: &[(&str, &str, f32)] = &[
+    ("DRK", "Dark", 100.24),
+    ("wXMR", "wrapped Monero", 5.56487),
+    ("wBTC", "wrapped Bitcoin", 0.78956413),
+    ("RNDM", "Random", 0.78956413),
+    ("OTHER", "Other token", 0.78956413),
+    ("OTHER", "Other token", 0.78956413),
+    ("OTHER", "Other token", 0.78956413),
+    ("OTHER", "Other token", 0.78956413),
+];
+
+pub const MOCK_RECEIVE_ADDRESS: &str = "8siycy4q5rsf3rwxa9ptw5v7syfj1m1ov27tt78rj7kprth1ux41lqpvut6cht7w";
+pub const MOCK_TX_FEE: &str = "0.001 DRK";
+
+pub fn is_valid_address(address: &str) -> bool {
+    address.len() > 3 // TODO
+}
+
+pub fn get_balance(token_symbol: &str) -> f32 {
+    MOCK_TOKENS
+        .iter()
+        .find(|(symbol, _, _)| *symbol == token_symbol)
+        .map(|(_, _, balance)| *balance)
+        .unwrap_or(0.0)
+}
+
+// Send transaction data shared across all send pages
+#[derive(Debug, Clone)]
+pub struct SendTxData {
+    pub token_symbol: Option<String>,
+    pub token_name: Option<String>,
+    pub token_id: Option<TokenId>,
+    pub recipient_str: Option<String>,
+    pub recipient: Option<Address>,
+    pub amount: Option<String>,
+    pub tx_built: bool,
+    pub tx: Option<Transaction>,
+}
+
+impl SendTxData {
+    pub fn new() -> Self {
+        Self {
+            token_symbol: None,
+            token_name: None,
+            token_id: None,
+            recipient_str: None,
+            recipient: None,
+            amount: None,
+            tx_built: false,
+            tx: None,
+        }
+    }
+}

+ 437 - 0
bin/app/src/app/schema/wallet/main.rs

@@ -0,0 +1,437 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi_serial::{Decodable, Encodable};
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_text, create_tokentable, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::{gfxtag},
+    mesh::{COLOR_CYAN, COLOR_TEAL},
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::{SceneNodePtr, Slot},
+    shape,
+    ui::{Button, Layer, Text, TokenTable, TokenRow, VectorArt, VectorShape},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_main_wallet_layer(
+    app: &App,
+    content: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("TOKEN_ROW_HEIGHT", TOKEN_ROW_HEIGHT);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("TITLE_FONTSIZE", TITLE_FONTSIZE);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("ROW_HEIGHT", ROW_HEIGHT);
+
+    // ============================================
+    // Main Wallet Layer
+    // ============================================
+    let wallet_layer = create_layer("wallet_main_layer");
+    let prop = wallet_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    wallet_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    wallet_layer.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+    let wallet_layer = wallet_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(wallet_layer.clone());
+
+    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
+
+    create_bg_mesh(app, atom, &wallet_layer, "wallet_bg").await;
+    create_header_bg(app, atom, &wallet_layer, "wallet_header_bg").await;
+
+    // Back button
+    let node = create_vector_art("wallet_back_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    let node = create_button("wallet_back_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let sg_root = app.sg_root.clone();
+    let renderer = app.renderer.clone();
+    let menu_is_visible =
+        PropertyBool::wrap(&sg_root.lookup_node("/window/content/menu_layer").unwrap(), Role::App, "is_visible", 0).unwrap();
+    let wallet_is_visible1 = wallet_is_visible.clone();
+    let goback = async move || {
+        info!(target: "app::wallet", "clicked back from wallet");
+        let atom = &mut renderer.make_guard(gfxtag!("wallet goback action"));
+        wallet_is_visible1.set(atom, false);
+        menu_is_visible.set(atom, true);
+    };
+
+    let (slot, recvr) = Slot::new("wallet_back_clicked");
+    node.register("click", slot).unwrap();
+    let goback2 = goback.clone();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            goback2().await;
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    let mut y = HEADER_HEIGHT;
+
+    // Balance display
+    let node = create_text("wallet_balance");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + TITLE_PADDING).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "DRK 0").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    wallet_layer.link(node);
+
+    y += TITLE_PADDING * 2. + TITLE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, &wallet_layer, "wallet_balance_separator", &mut y).await;
+
+    // Receive button bg
+    let node = create_vector_art("wallet_receive_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        COLOR_TEAL,
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    // Receive button click handler
+    let node = create_button("wallet_receive_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+
+    let renderer = app.renderer.clone();
+    let wallet_is_visible2 = wallet_is_visible.clone();
+    let sg_root = app.sg_root.clone();
+    let (slot, recvr) = Slot::new("receive_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while recvr.recv().await.is_ok() {
+            let atom = &mut renderer.make_guard(gfxtag!("receive button click"));
+            wallet_is_visible2.set(atom, false);
+
+            let receive_layer = sg_root.lookup_node("/window/content/wallet_receive_layer").unwrap();
+            receive_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+
+            // Get the default address from drk plugin and update the UI
+            if let Some(drk_node) = sg_root.lookup_node("/plugin/drk") {
+                if let Ok(Some(response_data)) = drk_node.call_method("get_default_address", vec![]).await {
+                    let mut cur = std::io::Cursor::new(response_data);
+                    if let Ok(address) = String::decode(&mut cur) {
+                        d!("Got default address from drk: {address}");
+                        if let Some(receive_address_node) = receive_layer.lookup_node("/receive_address") {
+                            receive_address_node.set_property_str(atom, Role::App, "text", address).unwrap();
+                        }
+                    } else {
+                        e!("Failed to decode default address response");
+                        if let Some(receive_address_node) = receive_layer.lookup_node("/receive_address") {
+                            receive_address_node.set_property_str(atom, Role::App, "text", MOCK_RECEIVE_ADDRESS).unwrap();
+                        }
+                    }
+                } else {
+                    e!("Failed to call get_default_address method");
+                    if let Some(receive_address_node) = receive_layer.lookup_node("/receive_address") {
+                        receive_address_node.set_property_str(atom, Role::App, "text", MOCK_RECEIVE_ADDRESS).unwrap();
+                    }
+                }
+            } else {
+                e!("Failed to lookup drk plugin node");
+                if let Some(receive_address_node) = receive_layer.lookup_node("/receive_address") {
+                    receive_address_node.set_property_str(atom, Role::App, "text", MOCK_RECEIVE_ADDRESS).unwrap();
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    // Receive label
+    let node = create_text("wallet_receive_label");
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("PADDING_X + (w / 2 - PADDING_X * 1.5) / 2 - (BUTTON_FONTSIZE * 0.6 * 7) / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X + BUTTON_HEIGHT / 2. - BUTTON_FONTSIZE / 1.8).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "receive").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    wallet_layer.link(node);
+
+    // Send button bg
+    let node = create_vector_art("wallet_send_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("PADDING_X / 2 + w / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        COLOR_TEAL,
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    // Send button click handler
+    let node = create_button("wallet_send_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("PADDING_X / 2 + w / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+
+    let renderer = app.renderer.clone();
+    let sg_root = app.sg_root.clone();
+    let wallet_is_visible3 = wallet_is_visible.clone();
+    let (slot, recvr) = Slot::new("send_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("send button click"));
+            wallet_is_visible3.set(atom, false);
+            let send_layer = sg_root.lookup_node("/window/content/wallet_send_step1_layer").unwrap();
+            send_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    wallet_layer.link(node);
+
+    // Send label
+    let node = create_text("wallet_send_label");
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("PADDING_X / 2 + w / 2 + (w / 2 - PADDING_X * 1.5) / 2 - (BUTTON_FONTSIZE * 0.6 * 4) / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_X + BUTTON_HEIGHT / 2. - BUTTON_FONTSIZE / 1.8).unwrap();
+    let code = cc.compile("w / 2 - PADDING_X * 1.5").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "send").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    wallet_layer.link(node);
+
+    y += PADDING_X * 2. + BUTTON_HEIGHT + 1.;
+
+    create_separator(&app.renderer, atom, &wallet_layer, "wallet_buttons_separator", &mut y).await;
+
+    create_title(app, atom, &wallet_layer, &window_scale, i18n_fish, "TOKENS", &mut y).await;
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("TOKENS_Y", y);
+
+    let tokens_table = create_tokentable("tokens_table");
+    let prop = tokens_table.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile("TOKENS_Y").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    tokens_table.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    tokens_table.set_property_f32(atom, Role::App, "column_spacing", TOKEN_NAME_OFFSET).unwrap();
+    tokens_table.set_property_f32(atom, Role::App, "padding_x", PADDING_X).unwrap();
+    tokens_table.set_property_f32(atom, Role::App, "padding_y", PADDING_Y).unwrap();
+    tokens_table.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    tokens_table.set_property_u32(atom, Role::App, "priority", 0).unwrap();
+
+    let prop = tokens_table.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+
+    let prop = tokens_table.get_property("separator_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.2).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.2745).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.2784).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let tokens_table = tokens_table
+        .setup(|me| TokenTable::new(me, app.renderer.clone(), app.sg_root.clone()))
+        .await;
+    wallet_layer.link(tokens_table.clone());
+
+    let wallet_layer = wallet_layer.clone();
+    let wallet_is_visible2 = wallet_is_visible.clone();
+    let tokens_table2 = tokens_table.clone();
+    let sg_root2 = app.sg_root.clone();
+    let renderer2 = app.renderer.clone();
+    let wallet_is_visible_sub = wallet_is_visible.prop().subscribe_modify();
+    let listen_wallet_visible = app.ex.spawn(async move {
+        while let Ok(_) = wallet_is_visible_sub.receive().await {
+            if wallet_is_visible2.get() {
+                let atom = &mut renderer2.make_guard(gfxtag!("wallet - refresh tokens"));
+
+                if let Some(drk_node) = sg_root2.lookup_node("/plugin/drk") {
+                    if let Ok(Some(response_data)) = drk_node.call_method("get_balances", vec![]).await {
+                        let mut cur = std::io::Cursor::new(response_data);
+                        if let Ok(balances) = Vec::<(String, darkfi_money_contract::model::TokenId, f32)>::decode(&mut cur) {
+                            let token_rows: Vec<TokenRow> = balances
+                                .iter()
+                                .enumerate()
+                                .map(|(i, (symbol, token_id, balance))| TokenRow {
+                                    id: *token_id,
+                                    symbol: symbol.clone(),
+                                    balance: balance.to_string(),
+                                })
+                                .collect();
+
+                            let mut data: Vec<u8> = vec![];
+                            for row in &token_rows {
+                                let _ = TokenRow::encode(row, &mut data);
+                            }
+
+                            let _ = tokens_table2.call_method("set_tokens", data).await;
+
+                            // Update main wallet balance
+                            use darkfi_money_contract::model::DARK_TOKEN_ID;
+                            if let Some(drk_balance) = balances.iter().find(|(_, token_id, _)| *token_id == *DARK_TOKEN_ID) {
+                                if let Some(balance_node) = sg_root2.lookup_node("/window/content/wallet_main_layer/wallet_balance") {
+                                    balance_node.set_property_str(atom, Role::App, "text", format!("DRK {}", drk_balance.2)).unwrap();
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_wallet_visible);
+
+    wallet_layer
+}

+ 74 - 0
bin/app/src/app/schema/wallet/mod.rs

@@ -0,0 +1,74 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "app::schema::wallet", $($arg)*); } }
+macro_rules! e { ($($arg:tt)*) => { error!(target: "app::schema::wallet", $($arg)*); } }
+
+pub mod data;
+pub mod util;
+pub mod main;
+pub mod receive;
+pub mod send;
+pub mod send_step1;
+pub mod send_step2;
+pub mod send_step3;
+pub mod send_step4;
+pub mod tx_status;
+
+use crate::{
+    app::App,
+    gfx::gfxtag,
+    prop::{PropertyAtomicGuard, PropertyFloat32, Role},
+    scene::SceneNodePtr,
+    util::i18n::I18nBabelFish,
+};
+
+use main::make_main_wallet_layer;
+use receive::make_receive_layer;
+use send::make_send_layer;
+
+pub async fn make(app: &App, content: SceneNodePtr, i18n_fish: &I18nBabelFish) {
+    let window_scale = PropertyFloat32::wrap(
+        &app.sg_root.lookup_node("/window").unwrap(),
+        Role::Internal,
+        "scale",
+        0,
+    )
+    .unwrap();
+
+    // Create main wallet layer
+    let wallet_layer = make_main_wallet_layer(app, content.clone(), i18n_fish, window_scale.clone()).await;
+
+    // Create receive layer
+    let _ = make_receive_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+    ).await;
+
+    // Create send layer
+    let _ = make_send_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+    ).await;
+}

+ 187 - 0
bin/app/src/app/schema/wallet/receive.rs

@@ -0,0 +1,187 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_text, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, PropertyStr, Role},
+    scene::{SceneNodePtr, Slot},
+    shape,
+    ui::{Button, Layer, Text, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_receive_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("COPY_WIDTH", COPY_WIDTH);
+
+    // Receive layer
+    let receive_layer = create_layer("wallet_receive_layer");
+    let prop = receive_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    receive_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    receive_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let receive_layer = receive_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(receive_layer.clone());
+
+    create_bg_mesh(app, atom, &receive_layer, "receive_bg").await;
+    create_header_bg(app, atom, &receive_layer, "receive_header_bg").await;
+
+    // Back button
+    let node = create_vector_art("receive_back_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    receive_layer.link(node);
+
+    let mut y = 0.;
+
+    let node = create_button("receive_back_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
+    let receive_is_visible = PropertyBool::wrap(&receive_layer, Role::App, "is_visible", 0).unwrap();
+    let renderer = app.renderer.clone();
+    let (slot, recvr) = Slot::new("receive_back_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("receive back button"));
+            receive_is_visible.set(atom, false);
+            wallet_is_visible.set(atom, true);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    receive_layer.link(node);
+
+    y += HEADER_HEIGHT;
+
+    create_title(app, atom, &receive_layer, &window_scale, i18n_fish, "RECEIVE", &mut y).await;
+
+    create_subtitle(app, atom, &receive_layer, &window_scale, i18n_fish, "address", "Address", &mut y).await;
+
+    // Address display
+    let node = create_text("receive_address");
+    let addr_h_prop = node.get_property("height").unwrap();
+    let receive_address_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    let code = cc.compile("w - COPY_WIDTH").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE * 2.).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", MOCK_RECEIVE_ADDRESS).unwrap();
+    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    receive_layer.link(node);
+
+    // Copy button
+    let node = create_vector_art("receive_copy_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("w - COPY_WIDTH / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    let code = cc.compile(format!("{y} + (PADDING_Y * 2. + addr_height) / 2")).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_copy(COLOR_CYAN).scaled(COPY_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    receive_layer.link(node);
+
+    let node = create_button("receive_copy_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    let code = cc.compile("w").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    let code = cc.compile("PADDING_Y * 2 + addr_height").unwrap();
+    prop.set_expr(atom, Role::App, 3, code).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+
+    let (slot, recvr) = Slot::new("receive_copy_clicked");
+    node.register("click", slot).unwrap();
+    let receive_address_text2 = receive_address_text.clone();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let addr = receive_address_text2.get();
+            miniquad::window::clipboard_set(&addr);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    receive_layer.link(node);
+
+    let sep = create_separator_expr(app, atom, &receive_layer, "receive_address_separator", &mut cc, &format!("{y} + PADDING_Y * 2 + addr_height + 1")).await;
+    let prop = sep.get_property("rect").unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+
+    receive_layer
+}

+ 184 - 0
bin/app/src/app/schema/wallet/send.rs

@@ -0,0 +1,184 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi::util::parse::encode_base10;
+use darkfi::tx::Transaction;
+use darkfi_serial::Decodable;
+
+use crate::{
+    app::App,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::SceneNodePtr,
+    util::i18n::I18nBabelFish,
+};
+
+use super::{data::SendTxData, data::BALANCE_BASE10_DECIMALS, send_step1::*, send_step2::*, send_step3::*, send_step4::*, tx_status::*};
+
+pub async fn make_send_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+) -> SceneNodePtr {
+    let send_tx_data = std::sync::Arc::new(std::sync::Mutex::new(SendTxData::new()));
+
+    let send_step1_layer = make_send_step1_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+        send_tx_data.clone(),
+    ).await;
+
+    let step1_is_visible = PropertyBool::wrap(&send_step1_layer, Role::App, "is_visible", 0).unwrap();
+
+    let send_step2_layer = make_send_step2_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+        send_tx_data.clone(),
+        step1_is_visible.clone(),
+    ).await;
+
+    let step2_is_visible = PropertyBool::wrap(&send_step2_layer, Role::App, "is_visible", 0).unwrap();
+
+    let send_step3_layer = make_send_step3_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+        send_tx_data.clone(),
+        step1_is_visible.clone(),
+        step2_is_visible.clone(),
+    ).await;
+
+    let step3_is_visible = PropertyBool::wrap(&send_step3_layer, Role::App, "is_visible", 0).unwrap();
+
+    let send_step4_layer = make_send_step4_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+        send_tx_data.clone(),
+        step2_is_visible.clone(),
+        step3_is_visible.clone(),
+        step1_is_visible.clone(),
+    ).await;
+
+    let step4_is_visible = PropertyBool::wrap(&send_step4_layer, Role::App, "is_visible", 0).unwrap();
+
+    let tx_status_layer = make_tx_status_layer(
+        app,
+        content.clone(),
+        wallet_layer.clone(),
+        i18n_fish,
+        window_scale.clone(),
+        send_tx_data,
+        step3_is_visible,
+        step4_is_visible,
+    ).await;
+
+    // Add listener for tx built signal to update send button label and show fee
+    let set_built_tx_sub = tx_status_layer.subscribe_method_call("set_built_tx").unwrap();
+    let renderer_for_built = app.renderer.clone();
+    let sg_root_for_built = app.sg_root.clone();
+    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
+        while let Ok(mcall) = set_built_tx_sub.receive().await {
+            let mut cur = std::io::Cursor::new(mcall.data);
+            let tx = Transaction::decode(&mut cur).unwrap();
+            let mut fees: u64 = 0;
+            for (i, call) in tx.calls.iter().enumerate() {
+                if call.data.is_money_fee() {
+                    if let Ok(fee) = darkfi_serial::deserialize(&call.data.data[1..9]) {
+                        fees = fees.saturating_add(fee);
+                    }
+                }
+            }
+
+            let atom = &mut renderer_for_built.make_guard(gfxtag!("tx built - update send button"));
+
+            // Make send button active
+            if let Some(send_label_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
+                send_label_node.set_property_str(atom, Role::App, "text", "send").unwrap();
+                let prop = send_label_node.get_property("text_color").unwrap();
+                prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+                prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+                prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+                prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+            }
+            if let Some(send_bg_grey_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg_grey") {
+                send_bg_grey_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+            }
+            if let Some(send_bg_node) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
+                send_bg_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+            }
+
+            // Show transaction fee
+            if let Some(tx_fee_label) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
+                let prop = tx_fee_label.get_property("text_color").unwrap();
+                prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+            }
+            if let Some(tx_fee_value) = sg_root_for_built.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
+                tx_fee_value.set_property_str(atom, Role::App, "text", encode_base10(fees, BALANCE_BASE10_DECIMALS)).unwrap();
+                let prop = tx_fee_value.get_property("text_color").unwrap();
+                prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+                prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+            }
+        }
+    }));
+
+    // Setup step2 visibility listener for auto-focusing recipient input
+    let step2_is_visible_for_focus = step2_is_visible.clone();
+    let sg_root_for_focus = app.sg_root.clone();
+    let step2_is_visible_sub = step2_is_visible.prop().subscribe_modify();
+    let listen_step2_visible = app.ex.spawn(async move {
+        while let Ok(_) = step2_is_visible_sub.receive().await {
+            let recipient_input_node = sg_root_for_focus.lookup_node("/window/content/wallet_send_step2_layer/send_recipient_input").unwrap();
+            if step2_is_visible_for_focus.get() {
+                // Focus when becoming visible
+                loop {
+                    darkfi::system::msleep(16).await;
+                    let input_rect = crate::prop::PropertyRect::wrap(&recipient_input_node, Role::App, "rect").unwrap();
+                    if input_rect.has_cached() {
+                        break;
+                    }
+                }
+                recipient_input_node.call_method("focus", vec![]).await.unwrap();
+            } else {
+                // Unfocus when becoming hidden
+                recipient_input_node.call_method("unfocus", vec![]).await.unwrap();
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_step2_visible);
+
+    send_step1_layer
+}

+ 240 - 0
bin/app/src/app/schema/wallet/send_step1.rs

@@ -0,0 +1,240 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::Arc;
+
+use darkfi_money_contract::model::DARK_TOKEN_ID;
+use darkfi_serial::{Decodable, Encodable};
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_tokentable, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::{SceneNodePtr, Slot},
+    shape,
+    ui::{Button, Layer, TokenTable, TokenRow, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_send_step1_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+    send_tx_data: Arc<std::sync::Mutex<SendTxData>>,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
+    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
+    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
+
+    // ============================================
+    // Step 1: Pick token layer
+    // ============================================
+    let send_step1_layer = create_layer("wallet_send_step1_layer");
+    let prop = send_step1_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    send_step1_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    send_step1_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let send_step1_layer = send_step1_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(send_step1_layer.clone());
+    let step1_is_visible = PropertyBool::wrap(&send_step1_layer, Role::App, "is_visible", 0).unwrap();
+
+    create_bg_mesh(app, atom, &send_step1_layer, "send_bg").await;
+    create_header_bg(app, atom, &send_step1_layer, "send_header_bg").await;
+
+    // Back button
+    let node = create_vector_art("send_back_btn_bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    send_step1_layer.link(node);
+
+    let mut y = 0.;
+
+    let node = create_button("send_back_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
+    let renderer = app.renderer.clone();
+    let wallet_is_visible1 = wallet_is_visible.clone();
+    let step1_is_visible1 = step1_is_visible.clone();
+    let (slot, recvr) = Slot::new("send_back_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("send back button"));
+            wallet_is_visible1.set(atom, true);
+            step1_is_visible1.set(atom, false);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    send_step1_layer.link(node);
+
+    y += HEADER_HEIGHT;
+
+    create_title(app, atom, &send_step1_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
+
+    create_subtitle(app, atom, &send_step1_layer, &window_scale, i18n_fish, "pick_label", "Pick a token to send", &mut y).await;
+
+    let send_tokens_table = create_tokentable("tokens_table");
+    let prop = send_tokens_table.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    send_tokens_table.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    send_tokens_table.set_property_f32(atom, Role::App, "column_spacing", TOKEN_NAME_OFFSET).unwrap();
+    send_tokens_table.set_property_f32(atom, Role::App, "padding_x", PADDING_X).unwrap();
+    send_tokens_table.set_property_f32(atom, Role::App, "padding_y", PADDING_Y).unwrap();
+    send_tokens_table.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    send_tokens_table.set_property_u32(atom, Role::App, "priority", 0).unwrap();
+
+    let prop = send_tokens_table.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+
+    let prop = send_tokens_table.get_property("separator_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.2).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.2745).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.2784).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let send_tokens_table = send_tokens_table
+        .setup(|me| TokenTable::new(me, app.renderer.clone(), app.sg_root.clone()))
+        .await;
+    send_step1_layer.link(send_tokens_table.clone());
+
+    let (slot, recvr) = Slot::new("token_row_clicked");
+    send_tokens_table.register("row_click", slot).unwrap();
+    let sg_root = app.sg_root.clone();
+    let renderer = app.renderer.clone();
+    let send_tx_data2 = send_tx_data.clone();
+    let step1_is_visible3 = step1_is_visible.clone();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            let mut cur = std::io::Cursor::new(data);
+            if let Ok(row) = TokenRow::decode(&mut cur) {
+                let mut data = send_tx_data2.lock().unwrap();
+                data.token_symbol = Some(row.symbol.clone());
+                data.token_id = Some(row.id.clone());
+                drop(data);
+
+                let atom = &mut renderer.make_guard(gfxtag!("token selection"));
+                if let Some(selected_token_symbol) = sg_root.lookup_node("/window/content/wallet_send_step2_layer/send_selected_token_symbol") {
+                    selected_token_symbol.set_property_str(atom, Role::App, "text", &row.symbol).unwrap();
+                }
+
+                step1_is_visible3.set(atom, false);
+                if let Some(step2) = sg_root.lookup_node("/window/content/wallet_send_step2_layer") {
+                    step2.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let step1_is_visible3 = step1_is_visible.clone();
+    let sg_root2 = app.sg_root.clone();
+    let renderer2 = app.renderer.clone();
+    let send_tokens_table2 = send_tokens_table.clone();
+    let step1_is_visible_sub = step1_is_visible.prop().subscribe_modify();
+    let listen_step1_visible = app.ex.spawn(async move {
+        while let Ok(_) = step1_is_visible_sub.receive().await {
+            if step1_is_visible3.get() {
+                let atom = &mut renderer2.make_guard(gfxtag!("wallet - refresh send tokens"));
+
+                if let Some(drk_node) = sg_root2.lookup_node("/plugin/drk") {
+                    if let Ok(Some(response_data)) = drk_node.call_method("get_balances", vec![]).await {
+                        let mut cur = std::io::Cursor::new(response_data);
+                        if let Ok(balances) = Vec::<(String, darkfi_money_contract::model::TokenId, f32)>::decode(&mut cur) {
+                            let token_rows: Vec<TokenRow> = balances
+                                .iter()
+                                .enumerate()
+                                .map(|(i, (symbol, token_id, balance))| TokenRow {
+                                    id: *token_id,
+                                    symbol: symbol.clone(),
+                                    balance: balance.to_string(),
+                                })
+                                .collect();
+
+                            let mut data: Vec<u8> = vec![];
+                            for row in &token_rows {
+                                let _ = TokenRow::encode(row, &mut data);
+                            }
+
+                            let _ = send_tokens_table2.call_method("set_tokens", data).await;
+
+                            // Update main wallet balance
+                            if let Some(drk_balance) = balances.iter().find(|(_, token_id, _)| *token_id == *DARK_TOKEN_ID) {
+                                if let Some(balance_node) = sg_root2.lookup_node("/window/content/wallet_main_layer/wallet_balance") {
+                                    balance_node.set_property_str(atom, Role::App, "text", format!("DRK {}", drk_balance.2)).unwrap();
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_step1_visible);
+
+    send_step1_layer
+}

+ 418 - 0
bin/app/src/app/schema/wallet/send_step2.rs

@@ -0,0 +1,418 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::Arc;
+use darkfi_sdk::crypto::keypair::Address;
+use darkfi_serial::{Decodable, Encodable};
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_singleline_edit, create_text, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::{Pimpl, SceneNodePtr, Slot},
+    shape,
+    ui::{BaseEdit, BaseEditType, Button, Layer, Text, VectorArt, VectorShape},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_send_step2_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+    send_tx_data: Arc<std::sync::Mutex<SendTxData>>,
+    step1_is_visible: PropertyBool,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
+    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
+    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
+
+    // ============================================
+    // Step 2: Recipient layer
+    // ============================================
+    let send_step2_layer = create_layer("wallet_send_step2_layer");
+    let prop = send_step2_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    send_step2_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    send_step2_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let send_step2_layer = send_step2_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(send_step2_layer.clone());
+    let step2_is_visible = PropertyBool::wrap(&send_step2_layer, Role::App, "is_visible", 0).unwrap();
+
+    let step3_is_visible = app.sg_root.lookup_node("/window/content/wallet_send_step3_layer")
+        .and_then(|l| PropertyBool::wrap(&l, Role::App, "is_visible", 0).ok());
+
+    create_bg_mesh(app, atom, &send_step2_layer, "send_bg2").await;
+    create_header_bg(app, atom, &send_step2_layer, "send_header_bg2").await;
+
+    // Back button
+    let node = create_vector_art("send_back_btn_bg2");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    send_step2_layer.link(node);
+
+    let mut y = 0.;
+
+    let node = create_button("send_back_btn2");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let step1_is_visible2 = step1_is_visible.clone();
+    let step2_is_visible1 = step2_is_visible.clone();
+    let renderer = app.renderer.clone();
+    let (slot, recvr) = Slot::new("send_back_clicked2");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("send step2 back button"));
+            step2_is_visible1.set(atom, false);
+            step1_is_visible2.set(atom, true);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    send_step2_layer.link(node);
+
+    y += HEADER_HEIGHT;
+
+    create_title(app, atom, &send_step2_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
+
+    // Selected token display
+    let node = create_text("send_selected_token_symbol");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Token").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step2_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, &send_step2_layer, "send_token_separator", &mut y).await;
+
+    // Recipient label
+    let node = create_text("send_recipient_label");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step2_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE;
+
+    // Recipient input outline
+    let node = create_vector_art("send_recipient_input_outline");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    let code = cc.compile("w - RECIPIENT_INPUT_MARGIN * 2").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, RECIPIENT_INPUT_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        [0.2, 0.2745, 0.2784, 1.],
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    send_step2_layer.link(node);
+
+    // Recipient placeholder
+    let recipient_placeholder_node = create_text("send_recipient_input_placeholder");
+    let prop = recipient_placeholder_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + (RECIPIENT_INPUT_HEIGHT - RECIPIENT_INPUT_FONTSIZE) / 2.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 200.).unwrap();
+    recipient_placeholder_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+    recipient_placeholder_node.set_property_f32(atom, Role::App, "font_size", RECIPIENT_INPUT_FONTSIZE).unwrap();
+    recipient_placeholder_node.set_property_str(atom, Role::App, "text", "recipient address...").unwrap();
+    let prop = recipient_placeholder_node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 0.45).unwrap();
+    recipient_placeholder_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+
+    let recipient_placeholder_node = recipient_placeholder_node
+        .setup(|me| {
+            Text::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                i18n_fish.clone(),
+            )
+        })
+        .await;
+    send_step2_layer.link(recipient_placeholder_node.clone());
+
+    // Recipient input
+    let recipient_input = create_singleline_edit("send_recipient_input");
+    recipient_input.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    recipient_input.set_property_bool(atom, Role::App, "is_focused", false).unwrap();
+    let prop = recipient_input.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    let code = cc.compile("parent_w - RECIPIENT_INPUT_MARGIN * 2 - RECIPIENT_INPUT_PADDING_X").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, RECIPIENT_INPUT_HEIGHT).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "font_size", RECIPIENT_INPUT_FONTSIZE).unwrap();
+    let prop = recipient_input.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    let prop = recipient_input.get_property("hi_bg_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::PaperLight {
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    let prop = recipient_input.get_property("text_hi_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.44).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.96).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    let prop = recipient_input.get_property("cursor_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.816).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.627).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "cursor_ascent", 0.).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "cursor_descent", RECIPIENT_INPUT_FONTSIZE*1.3).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "select_ascent", RECIPIENT_INPUT_FONTSIZE*1.3).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "select_descent", RECIPIENT_INPUT_FONTSIZE/3.).unwrap();
+    recipient_input.set_property_f32(atom, Role::App, "handle_descent", RECIPIENT_INPUT_FONTSIZE/2.5).unwrap();
+    recipient_input.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let recipient_input = recipient_input
+        .setup(|me| {
+            BaseEdit::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                BaseEditType::SingleLine,
+                app.ex.clone(),
+            )
+        })
+        .await;
+    send_step2_layer.link(recipient_input.clone());
+
+    y += RECIPIENT_INPUT_MARGIN + RECIPIENT_INPUT_HEIGHT;
+
+    // Add recipient button
+    let (node, btn_bg_valid, btn_bg_invalid, add_recipient_label_node) = create_bottom_button_with_states(
+        app,
+        atom,
+        &send_step2_layer,
+        "send_add_recipient_btn",
+        &mut cc,
+        "add recipient",
+        &window_scale,
+        i18n_fish,
+        false,
+    ).await;
+    let recipient_input2 = recipient_input.clone();
+    let recipient_text = recipient_input.get_property("text").unwrap();
+    let recipient_text_sub = recipient_text.subscribe_modify();
+    let renderer = app.renderer.clone();
+    let btn_bg_valid_clone = btn_bg_valid.clone();
+    let btn_bg_invalid_clone = btn_bg_invalid.clone();
+    let listen_recipient_text = app.ex.spawn(async move {
+        while let Ok(_) = recipient_text_sub.receive().await {
+            let atom = &mut renderer.make_guard(gfxtag!("wallet recipient input recv"));
+            let text_color = recipient_placeholder_node.get_property("text_color").unwrap();
+            let label_text_color = add_recipient_label_node.get_property("text_color").unwrap();
+            let btn_bg_valid_visible = btn_bg_valid_clone.get_property("is_visible").unwrap();
+            let btn_bg_invalid_visible = btn_bg_invalid_clone.get_property("is_visible").unwrap();
+            let addr = recipient_input2.get_property_str("text").unwrap();
+            if addr.is_empty() {
+                text_color.set_f32(atom, Role::App, 3, 0.45).unwrap();
+                // Grey color for invalid
+                label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
+                btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
+                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
+            } else {
+                text_color.set_f32(atom, Role::App, 3, 0.).unwrap();
+                // Cyan color for valid
+                if is_valid_address(&addr) {
+                    label_text_color.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+                    btn_bg_valid_visible.set_bool(atom, Role::App, 0, true).unwrap();
+                    btn_bg_invalid_visible.set_bool(atom, Role::App, 0, false).unwrap();
+                } else {
+                    // Grey color for invalid
+                    label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
+                    label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
+                    btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
+                    btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_recipient_text);
+
+    let step2_is_visible2 = step2_is_visible.clone();
+    let renderer = app.renderer.clone();
+    let recipient_input2 = recipient_input.clone();
+    let send_tx_data3 = send_tx_data.clone();
+    let sg_root = app.sg_root.clone();
+    let (slot, recvr) = Slot::new("send_add_recipient_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let text = recipient_input2.get_property_str("text").unwrap();
+            // Only proceed if address is valid
+            if !is_valid_address(&text) {
+                continue;
+            }
+
+            let atom = &mut renderer.make_guard(gfxtag!("add recipient button"));
+
+            let mut tx_data = send_tx_data3.lock().unwrap();
+            tx_data.recipient_str = Some(text.clone());
+            // unwrap is okay here, recipient string is already verified by is_valid_address()
+            tx_data.recipient = Some(text.clone().parse::<Address>().unwrap());
+            drop(tx_data);
+
+            // Update step3 display with token and recipient
+            let data = send_tx_data3.lock().unwrap().clone();
+
+            if let Some(token_symbol) = &data.token_symbol {
+                let token_symbol_node = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_selected_token_symbol3").unwrap();
+                token_symbol_node.set_property_str(atom, Role::App, "text", token_symbol).unwrap();
+
+                // Update amount token symbol
+                if let Some(amount_token_node) = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_amount_token_symbol") {
+                    amount_token_node.set_property_str(atom, Role::App, "text", token_symbol).unwrap();
+
+                    // Update available balance
+                    let available_balance = get_balance(token_symbol);
+                    if let Some(available_balance_node) = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_available_balance") {
+                        available_balance_node.set_property_str(atom, Role::App, "text", format!("{available_balance} available")).unwrap();
+                    }
+                }
+            }
+            if let Some(recipient_str) = &data.recipient_str {
+                let recipient_node = sg_root.lookup_node("/window/content/wallet_send_step3_layer/send_recipient_value3").unwrap();
+                recipient_node.set_property_str(atom, Role::App, "text", recipient_str).unwrap();
+            }
+
+            step2_is_visible2.set(atom, false);
+            if let Some(step3) = sg_root.lookup_node("/window/content/wallet_send_step3_layer") {
+                step3.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    send_step2_layer
+}

+ 697 - 0
bin/app/src/app/schema/wallet/send_step3.rs

@@ -0,0 +1,697 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::Arc;
+
+use darkfi_money_contract::model::TokenId;
+use darkfi_serial::{Decodable, Encodable};
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_singleline_edit, create_text, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, PropertyRect, Role},
+    scene::{Pimpl, SceneNodePtr, Slot},
+    shape,
+    ui::{BaseEdit, BaseEditType, Button, Layer, Text, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_send_step3_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+    send_tx_data: Arc<std::sync::Mutex<SendTxData>>,
+    step1_is_visible: PropertyBool,
+    step2_is_visible: PropertyBool,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
+    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
+    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
+
+    // ============================================
+    // Step 3: Amount layer
+    // ============================================
+    let send_step3_layer = create_layer("wallet_send_step3_layer");
+    let prop = send_step3_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    send_step3_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    send_step3_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let send_step3_layer = send_step3_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(send_step3_layer.clone());
+    let step3_is_visible = PropertyBool::wrap(&send_step3_layer, Role::App, "is_visible", 0).unwrap();
+
+    let step4_is_visible = app.sg_root.lookup_node("/window/content/wallet_send_step4_layer")
+        .and_then(|l| PropertyBool::wrap(&l, Role::App, "is_visible", 0).ok());
+
+    create_bg_mesh(app, atom, &send_step3_layer, "send_bg3").await;
+    create_header_bg(app, atom, &send_step3_layer, "send_header_bg3").await;
+
+    // Back button
+    let node = create_vector_art("send_back_btn_bg3");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    send_step3_layer.link(node);
+
+    let mut y = 0.;
+
+    let node = create_button("send_back_btn3");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let step2_is_visible3 = step2_is_visible.clone();
+    let step3_is_visible1 = step3_is_visible.clone();
+    let renderer = app.renderer.clone();
+    let (slot, recvr) = Slot::new("send_back_clicked3");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("send step3 back button"));
+            step3_is_visible1.set(atom, false);
+            step2_is_visible3.set(atom, true);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    send_step3_layer.link(node);
+
+    y += HEADER_HEIGHT;
+
+    create_title(app, atom, &send_step3_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
+
+    // Selected token display (same layout as step2)
+    let node = create_text("send_selected_token_symbol3");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    // Set initial token value from send_tx_data
+    let token_symbol_text = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
+    node.set_property_str(atom, Role::App, "text", &token_symbol_text).unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step3_layer.link(node);
+
+    let node = create_text("send_selected_token_name3");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X + TOKEN_NAME_OFFSET).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    // Set initial token value from send_tx_data
+    let token_name_text = send_tx_data.lock().unwrap().token_name.clone().unwrap_or_else(|| "".to_string());
+    node.set_property_str(atom, Role::App, "text", &token_name_text).unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let selected_token_text3 = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step3_layer.link(selected_token_text3.clone());
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, &send_step3_layer, "send_token_separator3", &mut y).await;
+
+    // Recipient display
+    let node = create_text("send_recipient_label3");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step3_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    // Recipient address value
+    let node = create_text("send_recipient_value3");
+    let addr_h_prop = node.get_property("height").unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    let code = cc.compile("w - PADDING_X * 2").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "").unwrap();
+    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step3_layer.link(node);
+
+    // Separator line
+    let mut y_ = y.clone();
+    let y2 = format!("{y} + (PADDING_Y * 2. + addr_height) + 1");
+    let node = create_separator(&app.renderer, atom, &send_step3_layer, "send_amount_label_separator", &mut y_).await;
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile(&y2).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+
+    // Available balance text
+    let available_balance_node = create_text("send_available_balance");
+    let prop = available_balance_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    available_balance_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    available_balance_node.set_property_str(atom, Role::App, "text", "").unwrap();
+    let prop = available_balance_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    available_balance_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let available_balance_node = available_balance_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
+    send_step3_layer.link(available_balance_node.clone());
+
+    // Separator line below available balance
+    create_separator_expr(
+        app,
+        atom,
+        &send_step3_layer,
+        "available_balance_separator",
+        &mut cc,
+        "h - PADDING_X * 2 - BUTTON_HEIGHT",
+    ).await;
+
+    // Amount wrapper layer
+    let amount_y = format!("({y2}) + (h - ({y2}) - BUTTON_HEIGHT - PADDING_X * 2 - PADDING_Y - BASE_FONTSIZE - AMOUNT_FONTSIZE - 1) / 2");
+    let amount_wrapper = create_layer("send_amount_wrapper");
+    amount_wrapper.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    let prop = amount_wrapper.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile(&amount_y).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    amount_wrapper.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let amount_wrapper = amount_wrapper.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    send_step3_layer.link(amount_wrapper.clone());
+
+    // Amount input
+    let input_node = create_singleline_edit("send_amount_input");
+    input_node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = input_node.get_property("rect").unwrap();
+    // Position at (0, 0) within wrapper - wrapper controls the position
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    cc.add_const_f32("AMOUNT_CHAR_WIDTH", AMOUNT_CHAR_WIDTH+6.); // Initial width for "0"
+    let code = cc.compile("AMOUNT_CHAR_WIDTH").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    input_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
+    let prop = input_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    let prop = input_node.get_property("hi_bg_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::PaperLight {
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    let prop = input_node.get_property("text_hi_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.44).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.96).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    let prop = input_node.get_property("cursor_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.816).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.627).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    input_node.set_property_f32(atom, Role::App, "cursor_ascent", 0.).unwrap();
+    input_node.set_property_f32(atom, Role::App, "cursor_descent", AMOUNT_FONTSIZE*1.3).unwrap();
+    input_node.set_property_f32(atom, Role::App, "select_ascent", AMOUNT_FONTSIZE*1.3).unwrap();
+    input_node.set_property_f32(atom, Role::App, "select_descent", AMOUNT_FONTSIZE/3.).unwrap();
+    input_node.set_property_f32(atom, Role::App, "handle_descent", AMOUNT_FONTSIZE/2.5).unwrap();
+    input_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let input_node = input_node
+        .setup(|me| {
+            BaseEdit::new(
+                me,
+                window_scale.clone(),
+                app.renderer.clone(),
+                BaseEditType::SingleLine,
+                app.ex.clone(),
+            )
+        })
+        .await;
+    amount_wrapper.link(input_node.clone());
+
+    // Set initial text to "0" for the amount input
+    let input_text_prop = input_node.get_property("text").unwrap();
+    input_text_prop.set_str(atom, Role::App, 0, "0").unwrap();
+    // Update editor's internal state
+    if let Pimpl::Edit(edit) = input_node.pimpl() {
+        edit.on_text_prop_changed();
+    }
+
+    // Token symbol text node (displayed next to amount)
+    let token_symbol_node = create_text("send_amount_token_symbol");
+    let prop = token_symbol_node.get_property("rect").unwrap();
+    // Position will be dynamically updated based on amount text
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile(format!("{amount_y} - AMOUNT_FONTSIZE * 0.1")).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    token_symbol_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
+    // Initial text - will be set from send_tx_data.token_symbol
+    let token_symbol_initial = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
+    token_symbol_node.set_property_str(atom, Role::App, "text", &token_symbol_initial).unwrap();
+    let prop = token_symbol_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    token_symbol_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let token_symbol_node = token_symbol_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step3_layer.link(token_symbol_node.clone());
+
+    let token_symbol_initial = send_tx_data.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
+    update_amount_screen(
+        atom,
+        "0",
+        &token_symbol_initial,
+        &amount_wrapper,
+        &input_node,
+        &token_symbol_node,
+        Some(&available_balance_node),
+    );
+
+    y += PADDING_Y * 2. + BUTTON_HEIGHT + 10.;
+
+    // Add amount button with states
+    let (node, btn_bg_valid, btn_bg_invalid, add_amount_label_node) = create_bottom_button_with_states(
+        app,
+        atom,
+        &send_step3_layer,
+        "send_add_amount_btn",
+        &mut cc,
+        "add amount",
+        &window_scale,
+        i18n_fish,
+        false, // Start with invalid state (grey)
+    ).await;
+
+    // Amount input validation listener
+    let amount_input2 = input_node.clone();
+    let amount_wrapper2 = amount_wrapper.clone();
+    let available_balance_node2 = available_balance_node.clone();
+    let token_symbol_node2 = token_symbol_node.clone();
+    let send_tx_data5 = send_tx_data.clone();
+    let amount_text = amount_input2.get_property("text").unwrap();
+    let amount_text_sub = amount_text.subscribe_modify();
+    let renderer = app.renderer.clone();
+    let btn_bg_valid_clone = btn_bg_valid.clone();
+    let btn_bg_invalid_clone = btn_bg_invalid.clone();
+    let add_amount_label_node_for_validation = add_amount_label_node.clone();
+    let listen_amount_text = app.ex.spawn(async move {
+        let mut old_amount_text = "0".to_string();
+        while let Ok(_) = amount_text_sub.receive().await {
+            let atom = &mut renderer.make_guard(gfxtag!("wallet amount input recv"));
+            let label_text_color = add_amount_label_node_for_validation.get_property("text_color").unwrap();
+            let btn_bg_valid_visible = btn_bg_valid_clone.get_property("is_visible").unwrap();
+            let btn_bg_invalid_visible = btn_bg_invalid_clone.get_property("is_visible").unwrap();
+            let amount_input_text_color = amount_input2.get_property("text_color").unwrap();
+            let amount = amount_input2.get_property_str("text").unwrap();
+
+            let sanitized_amount = if amount.is_empty() {
+                "0".to_string()
+            } else {
+                let mut result = String::new();
+                let mut dot_seen = false;
+                for c in amount.chars() {
+                    if c.is_ascii_digit() {
+                        result.push(c);
+                    } else if c == '.' && !dot_seen {
+                        dot_seen = true;
+                        result.push(c);
+                    }
+                }
+
+                // Strip leading zeros
+                let parts: Vec<&str> = result.split('.').collect();
+                let integer_part = if !parts.is_empty() && !parts[0].is_empty() {
+                    let trimmed = parts[0].trim_start_matches('0');
+                    let trimmed = if trimmed.is_empty() {
+                        "0".to_string()
+                    } else {
+                        trimmed.to_string()
+                    };
+
+                    // Forces removing "0" chars when the amount was previously "0".
+                    // This is so that when the input cursor is BEFORE the initial
+                    // "0" and you insert a digit, it removes the initial "0".
+                    if old_amount_text == "0" && trimmed != "0" && parts.len() == 1 {
+                        trimmed.chars().filter(|&c| c != '0').collect()
+                    } else {
+                        trimmed
+                    }
+                } else {
+                    "0".to_string()
+                };
+
+                if parts.len() > 1 {
+                    format!("{}.{}", integer_part, parts[1])
+                } else {
+                    integer_part
+                }
+            };
+
+            if sanitized_amount != amount {
+                let input_text_prop = amount_input2.get_property("text").unwrap();
+                input_text_prop.set_str(atom, Role::Ignored, 0, &sanitized_amount).unwrap();
+                if let Pimpl::Edit(edit) = amount_input2.pimpl() {
+                    edit.on_text_prop_changed();
+                }
+            }
+
+            old_amount_text = sanitized_amount.clone();
+
+            // Update positions to center amount and token symbol
+            let token_symbol = send_tx_data5.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
+            update_amount_screen(
+                atom,
+                &sanitized_amount,
+                &token_symbol,
+                &amount_wrapper2,
+                &amount_input2,
+                &token_symbol_node2,
+                Some(&available_balance_node2),
+            );
+            let available_balance = get_balance(&token_symbol);
+            let is_valid = if sanitized_amount == "0" {
+                false
+            } else {
+                match sanitized_amount.parse::<f32>() {
+                    Ok(v) if v > 0. && v <= available_balance => true,
+                    _ => false,
+                }
+            };
+
+            // Set amount input text color: grey if "0", white otherwise
+            if sanitized_amount == "0" {
+                amount_input_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
+            } else {
+                amount_input_text_color.set_f32(atom, Role::App, 0, 1.).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 1, 1.).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 2, 1.).unwrap();
+                amount_input_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
+            }
+
+            if is_valid {
+                label_text_color.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+                label_text_color.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+                label_text_color.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+                label_text_color.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+                btn_bg_valid_visible.set_bool(atom, Role::App, 0, true).unwrap();
+                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, false).unwrap();
+            } else {
+                label_text_color.set_f32(atom, Role::App, 0, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 1, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 2, 0.5).unwrap();
+                label_text_color.set_f32(atom, Role::App, 3, 1.).unwrap();
+                btn_bg_valid_visible.set_bool(atom, Role::App, 0, false).unwrap();
+                btn_bg_invalid_visible.set_bool(atom, Role::App, 0, true).unwrap();
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_amount_text);
+
+    let renderer = app.renderer.clone();
+    let amount_input2 = input_node.clone();
+    let send_tx_data4 = send_tx_data.clone();
+    let step3_is_visible2 = step3_is_visible.clone();
+    let sg_root = app.sg_root.clone();
+    let add_amount_btn_node = node.clone();
+    let add_amount_label_node2 = add_amount_label_node.clone();
+    let (slot, recvr) = Slot::new("send_add_amount_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let text = amount_input2.get_property_str("text").unwrap();
+            let token_symbol = send_tx_data4.lock().unwrap().token_symbol.clone().unwrap_or_else(|| "".to_string());
+            let available_balance = get_balance(&token_symbol);
+            let is_valid = if text.is_empty() {
+                false
+            } else {
+                match text.parse::<f32>() {
+                    Ok(v) if v > 0. && v <= available_balance => true,
+                    _ => false,
+                }
+            };
+            if is_valid {
+                let atom = &mut renderer.make_guard(gfxtag!("switch to step4 and show building"));
+
+                // Store the amount
+                let mut data = send_tx_data4.lock().unwrap();
+                data.amount = Some(text.clone());
+
+                // Update step4 display with transaction data
+                if let Some(token_symbol_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_selected_token_symbol4") {
+                    token_symbol_node.set_property_str(atom, Role::App, "text", &data.token_symbol.clone().unwrap()).unwrap();
+                }
+                if let Some(amount_token_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_amount_token_symbol4") {
+                    amount_token_node.set_property_str(atom, Role::App, "text", &data.token_symbol.clone().unwrap()).unwrap();
+                }
+                if let Some(recipient_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_recipient_value4") {
+                    recipient_node.set_property_str(atom, Role::App, "text", &data.recipient_str.clone().unwrap()).unwrap();
+                }
+                if let Some(amount_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_amount_wrapper4/send_amount_text4") {
+                    amount_node.set_property_str(atom, Role::App, "text", &data.amount.clone().unwrap()).unwrap();
+                }
+
+                // Switch to step4 and set send button to "building tx..."
+                step3_is_visible2.set(atom, false);
+                if let Some(step4) = sg_root.lookup_node("/window/content/wallet_send_step4_layer") {
+                    step4.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                }
+                if let Some(send_label_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
+                    send_label_node.set_property_str(atom, Role::App, "text", "building tx...").unwrap();
+                    let prop = send_label_node.get_property("text_color").unwrap();
+                    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+                    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+                    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+                    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+                }
+                if let Some(send_bg_grey_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg_grey") {
+                    send_bg_grey_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                }
+                if let Some(send_bg_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
+                    send_bg_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+                }
+
+                // Build the transaction via DrkPlugin
+                if let (Some(drk_node), Some(token_id), Some(recipient)) = (
+                    sg_root.lookup_node("/plugin/drk"),
+                    data.token_id,
+                    data.recipient,
+                ) {
+                    let mut encoded_data = vec![];
+                    data.amount.clone().unwrap().encode(&mut encoded_data).unwrap();
+                    token_id.encode(&mut encoded_data).unwrap();
+                    recipient.public_key().encode(&mut encoded_data).unwrap();
+
+                    // Call build_tx - returns immediately, emits tx_built signal when done
+                    let _ = smol::block_on(drk_node.call_method("build_tx", encoded_data));
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    // Add listener for step3 visibility to focus/unfocus amount input
+    let step3_is_visible_clone = step3_is_visible.clone();
+    let renderer_clone = app.renderer.clone();
+    let amount_wrapper_clone = amount_wrapper.clone();
+    let input_node_clone = input_node.clone();
+    let token_symbol_node_clone = token_symbol_node.clone();
+    let send_tx_data_clone = send_tx_data.clone();
+    let step3_is_visible_sub = step3_is_visible.prop().subscribe_modify();
+    let listen_step3_visible = app.ex.spawn(async move {
+        while let Ok(_) = step3_is_visible_sub.receive().await {
+            if step3_is_visible_clone.get() {
+                loop { // TODO: this waits for draw, there's probably a better way
+                    darkfi::system::msleep(50).await;
+                    let input_rect = PropertyRect::wrap(&input_node_clone, Role::App, "rect").unwrap();
+                    if input_rect.has_cached() {
+                        break;
+                    }
+                }
+
+                // Focus the amount input
+                input_node_clone.call_method("focus", vec![]).await.unwrap();
+
+                // Get the current token symbol and update positions
+                let token_symbol = send_tx_data_clone.lock().unwrap().token_symbol.clone().unwrap_or_default();
+                if !token_symbol.is_empty() {
+                    let atom = &mut renderer_clone.make_guard(gfxtag!("update amount positions on visible"));
+                    let current_amount = input_node_clone.get_property_str("text").unwrap();
+                    update_amount_screen(
+                        atom,
+                        &current_amount,
+                        &token_symbol,
+                        &amount_wrapper_clone,
+                        &input_node_clone,
+                        &token_symbol_node_clone,
+                        Some(&available_balance_node),
+                    );
+                }
+            } else {
+                // Unfocus when becoming hidden
+                input_node_clone.call_method("unfocus", vec![]).await.unwrap();
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_step3_visible);
+
+    send_step3_layer
+}

+ 527 - 0
bin/app/src/app/schema/wallet/send_step4.rs

@@ -0,0 +1,527 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::Arc;
+
+use darkfi_serial::{Decodable, Encodable};
+use darkfi::util::parse::encode_base10;
+use darkfi::tx::Transaction;
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_text, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::{SceneNodePtr, Slot},
+    shape,
+    ui::{Button, Layer, Text, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_send_step4_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+    send_tx_data: Arc<std::sync::Mutex<SendTxData>>,
+    step2_is_visible: PropertyBool,
+    step3_is_visible: PropertyBool,
+    step1_is_visible: PropertyBool,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
+    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
+    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
+
+    // ============================================
+    // Step 4: Confirmation layer content
+    // ============================================
+    let send_step4_layer = create_layer("wallet_send_step4_layer");
+    let prop = send_step4_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    send_step4_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    send_step4_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let send_step4_layer = send_step4_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(send_step4_layer.clone());
+    let step4_is_visible = PropertyBool::wrap(&send_step4_layer, Role::App, "is_visible", 0).unwrap();
+
+    let tx_status_is_visible = app.sg_root.lookup_node("/window/content/wallet_tx_status_layer")
+        .and_then(|l| PropertyBool::wrap(&l, Role::App, "is_visible", 0).ok());
+
+    create_bg_mesh(app, atom, &send_step4_layer, "send_bg4").await;
+    create_header_bg(app, atom, &send_step4_layer, "send_header_bg4").await;
+
+    // Back button
+    let node = create_vector_art("send_back_btn_bg4");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, BACKARROW_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, BACKARROW_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 500.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 500.).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    send_step4_layer.link(node);
+
+    let mut y = 0.;
+
+    let node = create_button("send_back_btn4");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, y).unwrap();
+    prop.set_f32(atom, Role::App, 2, WALLET_BTN_SIZE * 2.).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+
+    let step3_is_visible2 = step3_is_visible.clone();
+    let step4_is_visible1 = step4_is_visible.clone();
+    let renderer = app.renderer.clone();
+    let sg_root2 = app.sg_root.clone();
+    let (slot, recvr) = Slot::new("send_back_clicked4");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("send step4 back button"));
+            step4_is_visible1.set(atom, false);
+            step3_is_visible2.set(atom, true);
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    send_step4_layer.link(node);
+
+    y += HEADER_HEIGHT;
+
+    create_title(app, atom, &send_step4_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
+
+    // Selected token display
+    let node = create_text("send_selected_token_symbol4");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Token").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step4_layer.link(node);
+
+    let node = create_text("send_selected_token_name4");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X + TOKEN_NAME_OFFSET).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step4_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, &send_step4_layer, "send_token_separator4", &mut y).await;
+
+    // Recipient label
+    let node = create_text("send_recipient_label4");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Recipient").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step4_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    // Recipient address value
+    let recipient_addr_node = create_text("send_recipient_value4");
+    let addr_h_prop = recipient_addr_node.get_property("height").unwrap();
+    let prop = recipient_addr_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    let code = cc.compile("w - PADDING_X * 2").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    recipient_addr_node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    recipient_addr_node.set_property_str(atom, Role::App, "text", "").unwrap();
+    recipient_addr_node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
+    let prop = recipient_addr_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    recipient_addr_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let recipient_addr_node = recipient_addr_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step4_layer.link(recipient_addr_node.clone());
+
+    // Separator line
+    let mut y_ = y.clone();
+    let y2 = format!("{y} + (PADDING_Y * 2. + addr_height) + 1");
+    let node = create_separator(&app.renderer, atom, &send_step4_layer, "send_amount_label_separator4", &mut y_).await;
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile(&y2).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+
+    // Amount display - same layout as step3's amount input
+    let amount_y = format!("({y2}) + (h - ({y2}) - BUTTON_HEIGHT - PADDING_X * 2 - PADDING_Y - BASE_FONTSIZE - AMOUNT_FONTSIZE - 1) / 2 - AMOUNT_FONTSIZE * 0.1");
+    let amount_wrapper = create_layer("send_amount_wrapper4");
+    amount_wrapper.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    let prop = amount_wrapper.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile(&amount_y).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    amount_wrapper.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let amount_wrapper = amount_wrapper.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    send_step4_layer.link(amount_wrapper.clone());
+
+    // Amount text
+    let amount_text_node = create_text("send_amount_text4");
+    let prop = amount_text_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    amount_text_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
+    let prop = amount_text_node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    amount_text_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    amount_text_node.set_property_str(atom, Role::App, "text", "0").unwrap();
+    let amount_text_node = amount_text_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    amount_wrapper.link(amount_text_node.clone());
+
+    // Token symbol text node (displayed next to amount)
+    let token_symbol_node = create_text("send_amount_token_symbol4");
+    let prop = token_symbol_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile(amount_y).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, AMOUNT_FONTSIZE).unwrap();
+    prop.add_depend(&addr_h_prop, 0, "addr_height");
+    token_symbol_node.set_property_f32(atom, Role::App, "font_size", AMOUNT_FONTSIZE).unwrap();
+    token_symbol_node.set_property_str(atom, Role::App, "text", "").unwrap();
+    let prop = token_symbol_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    token_symbol_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let token_symbol_node = token_symbol_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    send_step4_layer.link(token_symbol_node.clone());
+
+    // Transaction fee label
+    let tx_fee_label_node = create_text("send_fee_label");
+    let prop = tx_fee_label_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    tx_fee_label_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    tx_fee_label_node.set_property_str(atom, Role::App, "text", "Transaction fee").unwrap();
+    let prop = tx_fee_label_node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    tx_fee_label_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    tx_fee_label_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    let prop = tx_fee_label_node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 0.).unwrap();
+    let tx_fee_label_node = tx_fee_label_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
+    send_step4_layer.link(tx_fee_label_node.clone());
+
+    // Transaction fee value
+    let tx_fee_value_node = create_text("send_fee_value");
+    let prop = tx_fee_value_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - BASE_FONTSIZE - 1").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    tx_fee_value_node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    tx_fee_value_node.set_property_str(atom, Role::App, "text", MOCK_TX_FEE).unwrap();
+    tx_fee_value_node.set_property_enum(atom, Role::App, "text_align", "end").unwrap();
+    tx_fee_value_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let prop = tx_fee_value_node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 0.).unwrap();
+    let tx_fee_value_node = tx_fee_value_node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
+    send_step4_layer.link(tx_fee_value_node.clone());
+
+    // Separator line
+    create_separator_expr(
+        app,
+        atom,
+        &send_step4_layer,
+        "tx_fee_separator",
+        &mut cc,
+        "h - PADDING_X * 2 - BUTTON_HEIGHT",
+    ).await;
+
+    y += PADDING_Y * 2. + BUTTON_HEIGHT + 10.;
+
+    // Send button (bottom button)
+    let (node, _bg_valid, _bg_invalid, _label) = create_bottom_button_with_states(
+        app,
+        atom,
+        &send_step4_layer,
+        "send_send_btn",
+        &mut cc,
+        "send",
+        &window_scale,
+        i18n_fish,
+        true,
+    ).await;
+
+    let renderer = app.renderer.clone();
+    let sg_root = app.sg_root.clone();
+    let step4_is_visible1 = step4_is_visible.clone();
+    let send_tx_data_for_send = send_tx_data.clone();
+    let (slot, recvr) = Slot::new("send_send_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            // Skip if the button is disabled
+            if let Some(btn_node) = sg_root.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_bg") {
+                if !btn_node.get_property_bool("is_visible").unwrap() {
+                    continue;
+                }
+            }
+            let atom = &mut renderer.make_guard(gfxtag!("send button"));
+
+            step4_is_visible1.set(atom, false);
+            if let Some(tx_status) = sg_root.lookup_node("/window/content/wallet_tx_status_layer") {
+                tx_status.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+            }
+
+            // Broadcast
+            let tx = send_tx_data_for_send.lock().unwrap().tx.clone();
+            if let (Some(tx), Some(drk_node)) = (tx, sg_root.lookup_node("/plugin/drk")) {
+                let mut encoded_data = vec![];
+                tx.encode(&mut encoded_data).unwrap();
+                if let Ok(Some(response_data)) = drk_node.call_method("broadcast_tx", encoded_data).await {
+                    let mut cur = std::io::Cursor::new(response_data);
+                    if let Ok(tx_id) = String::decode(&mut cur) {
+                        d!("Transaction broadcasted: {tx_id}");
+                        let mut tx_id_data = vec![];
+                        tx_id.encode(&mut tx_id_data).unwrap();
+                        if let Ok(Some(data)) = drk_node.call_method("get_tx_status", tx_id_data).await {
+                            let mut cur = std::io::Cursor::new(data);
+                            if let Ok(status_text) = String::decode(&mut cur) {
+                                if let Some(tx_status) = sg_root.lookup_node("/window/content/wallet_tx_status_layer/status") {
+                                    tx_status.set_property_str(atom, Role::App, "text", status_text).unwrap();
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    // Add listener for step4 visibility to update amount positions
+    let step4_is_visible_clone = step4_is_visible.clone();
+    let renderer_clone = app.renderer.clone();
+    let amount_wrapper_clone = amount_wrapper.clone();
+    let amount_text_node_clone = amount_text_node.clone();
+    let token_symbol_node_clone = token_symbol_node.clone();
+    let send_tx_data_clone2 = send_tx_data.clone();
+    let sg_root_clone_for_visibility = app.sg_root.clone();
+    let step4_is_visible_sub = step4_is_visible.prop().subscribe_modify();
+    let listen_step4_visible = app.ex.spawn(async move {
+        while let Ok(_) = step4_is_visible_sub.receive().await {
+            if step4_is_visible_clone.get() {
+                loop {
+                    darkfi::system::msleep(50).await;
+                    let text_rect = crate::prop::PropertyRect::wrap(&amount_text_node_clone, Role::App, "rect").unwrap();
+                    if text_rect.has_cached() {
+                        break;
+                    }
+                }
+
+                let atom = &mut renderer_clone.make_guard(gfxtag!("update step4 amount positions"));
+                let data = send_tx_data_clone2.lock().unwrap().clone();
+
+                let amount_text = data.amount.unwrap_or_else(|| "0".to_string());
+                let token_symbol = data.token_symbol.unwrap_or_else(|| "".to_string());
+
+                // Update positions to center amount and token symbol
+                update_amount_screen(
+                    atom,
+                    &amount_text,
+                    &token_symbol,
+                    &amount_wrapper_clone,
+                    &amount_text_node_clone,
+                    &token_symbol_node_clone,
+                    None,
+                );
+
+                if data.tx_built {
+                    // Set send button label
+                    if let Some(send_label_node) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_send_btn_label") {
+                        send_label_node.set_property_str(atom, Role::App, "text", "send").unwrap();
+                    }
+                    // Show transaction fee
+                    if let Some(tx_fee_label) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
+                        let prop = tx_fee_label.get_property("text_color").unwrap();
+                        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+                    }
+                    if let Some(tx_fee_value) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
+                        let prop = tx_fee_value.get_property("text_color").unwrap();
+                        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+                        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+                    }
+                } else {
+                    // Hide transaction fee
+                    if let Some(tx_fee_label) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_label") {
+                        let prop = tx_fee_label.get_property("text_color").unwrap();
+                        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 3, 0.).unwrap();
+                    }
+                    if let Some(tx_fee_value) = sg_root_clone_for_visibility.lookup_node("/window/content/wallet_send_step4_layer/send_fee_value") {
+                        let prop = tx_fee_value.get_property("text_color").unwrap();
+                        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+                        prop.set_f32(atom, Role::App, 3, 0.).unwrap();
+                    }
+                }
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_step4_visible);
+
+    send_step4_layer
+}

+ 304 - 0
bin/app/src/app/schema/wallet/tx_status.rs

@@ -0,0 +1,304 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::sync::Arc;
+
+use darkfi_serial::{Decodable, Encodable};
+use darkfi::tx::Transaction;
+
+use crate::{
+    app::{
+        App,
+        node::{create_button, create_layer, create_text, create_vector_art},
+        schema::COLOR_SCHEME,
+    },
+    expr,
+    gfx::gfxtag,
+    mesh::COLOR_CYAN,
+    prop::{Property, PropertyAtomicGuard, PropertyBool, PropertyFloat32, PropertyStr, PropertySubType, PropertyType, Role},
+    scene::{SceneNodePtr, Slot},
+    shape,
+    ui::{Button, Layer, Text, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*, util::*};
+
+pub async fn make_tx_status_layer(
+    app: &App,
+    content: SceneNodePtr,
+    wallet_layer: SceneNodePtr,
+    i18n_fish: &I18nBabelFish,
+    window_scale: PropertyFloat32,
+    send_tx_data: Arc<std::sync::Mutex<SendTxData>>,
+    step3_is_visible: PropertyBool,
+    step4_is_visible: PropertyBool,
+) -> SceneNodePtr {
+    let atom = &mut PropertyAtomicGuard::none();
+
+    let mut cc = expr::Compiler::new();
+    cc.add_const_f32("HEADER_HEIGHT", HEADER_HEIGHT);
+    cc.add_const_f32("BUTTON_HEIGHT", BUTTON_HEIGHT);
+    cc.add_const_f32("BUTTON_FONTSIZE", BUTTON_FONTSIZE);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+    cc.add_const_f32("BASE_FONTSIZE", BASE_FONTSIZE);
+    cc.add_const_f32("HINT_FONTSIZE", HINT_FONTSIZE);
+    cc.add_const_f32("PADDING_X", PADDING_X);
+    cc.add_const_f32("PADDING_Y", PADDING_Y);
+    cc.add_const_f32("RECIPIENT_INPUT_MARGIN", RECIPIENT_INPUT_MARGIN);
+    cc.add_const_f32("RECIPIENT_INPUT_PADDING_X", RECIPIENT_INPUT_PADDING_X);
+
+    // ============================================
+    // Transaction status layer
+    // ============================================
+    let mut tx_status_layer = create_layer("wallet_tx_status_layer");
+
+    // Add methods for plugin calls
+    tx_status_layer
+        .add_method(
+            "set_tx_status",
+            vec![
+                ("tx_id", "Transaction ID", crate::scene::CallArgType::Str),
+                ("status", "Status text", crate::scene::CallArgType::Str),
+                ("amount", "Amount", crate::scene::CallArgType::Str),
+                ("token_symbol", "Token symbol", crate::scene::CallArgType::Str),
+                ("recipient", "Recipient address", crate::scene::CallArgType::Str),
+            ],
+            None,
+        )
+        .unwrap();
+
+    tx_status_layer
+        .add_method(
+            "set_built_tx",
+            vec![("tx", "Transaction", crate::scene::CallArgType::Hash)],
+            None,
+        )
+        .unwrap();
+
+    // Add tx_id property to store the transaction ID for status polling
+    let mut prop = Property::new("tx_id", PropertyType::Str, PropertySubType::Null);
+    prop.set_defaults_str(vec!["".to_string()]).unwrap();
+    tx_status_layer.add_property(prop).unwrap();
+
+    let prop = tx_status_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    tx_status_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    tx_status_layer.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    let tx_status_layer = tx_status_layer.setup(|me| Layer::new(me, app.renderer.clone())).await;
+    content.link(tx_status_layer.clone());
+    let tx_status_is_visible = PropertyBool::wrap(&tx_status_layer, Role::App, "is_visible", 0).unwrap();
+
+    create_bg_mesh(app, atom, &tx_status_layer, "tx_status_bg").await;
+    create_header_bg(app, atom, &tx_status_layer, "tx_status_header_bg").await;
+
+    let mut y = HEADER_HEIGHT;
+
+    create_title(app, atom, &tx_status_layer, &window_scale, i18n_fish, "SEND", &mut y).await;
+
+    // Status text
+    let node = create_text("status");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Broadcasting...").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    tx_status_layer.link(node);
+
+    y += PADDING_Y * 2. + BASE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, &tx_status_layer, "tx_status_separator", &mut y).await;
+
+    // Transaction info text: "Sending {amount} {token_symbol} to {recipient_address}"
+    let node = create_text("tx_info");
+    let info_h_prop = node.get_property("height").unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, y + PADDING_Y).unwrap();
+    let code = cc.compile("w - PADDING_X * 2").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BASE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", BASE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", "Sending 0 DRK to recipient").unwrap();
+    node.set_property_enum(atom, Role::App, "overflow_wrap", "anywhere").unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    tx_status_layer.link(node);
+
+    let sep = create_separator(&app.renderer, atom, &tx_status_layer, "tx_info_separator", &mut 0.).await;
+    let prop = sep.get_property("rect").unwrap();
+    let code = cc.compile(format!("{y} + PADDING_Y * 2 + info_height + 1")).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.add_depend(&info_h_prop, 0, "info_height");
+
+    // Hint text
+    let hint_node = create_text("tx_status_hint");
+    let prop = hint_node.get_property("rect").unwrap();
+    let code = cc.compile("w / 2 - (HINT_FONTSIZE * 0.7 * 31) / 2").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    let code = cc.compile("h - PADDING_X * 2 - BUTTON_HEIGHT - PADDING_Y - HINT_FONTSIZE * 2").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("HINT_FONTSIZE * 0.7 * 31").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, HINT_FONTSIZE/2.).unwrap();
+    hint_node.set_property_f32(atom, Role::App, "font_size", HINT_FONTSIZE).unwrap();
+    hint_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
+    hint_node.set_property_str(atom, Role::App, "text", "You can close this screen while the transaction is confirming.").unwrap();
+    let prop = hint_node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 0.45).unwrap();
+    hint_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let hint_node = hint_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    tx_status_layer.link(hint_node.clone());
+
+    // Separator line
+    create_separator_expr(
+        app,
+        atom,
+        &tx_status_layer,
+        "tx_status_hint_separator",
+        &mut cc,
+        "h - PADDING_X * 2 - BUTTON_HEIGHT",
+    ).await;
+
+    // Close button
+    let node = create_bottom_button(
+        app,
+        atom,
+        &tx_status_layer,
+        "tx_status_close_btn",
+        &mut cc,
+        Some("close"),
+        &window_scale,
+        i18n_fish,
+    ).await;
+
+    let wallet_is_visible = PropertyBool::wrap(&wallet_layer, Role::App, "is_visible", 0).unwrap();
+    let renderer = app.renderer.clone();
+    let tx_status_is_visible1 = tx_status_is_visible.clone();
+    let send_tx_data2 = send_tx_data.clone();
+    let (slot, recvr) = Slot::new("tx_status_close_clicked");
+    node.register("click", slot).unwrap();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let atom = &mut renderer.make_guard(gfxtag!("tx status close button"));
+            tx_status_is_visible1.set(atom, false);
+            wallet_is_visible.set(atom, true);
+
+            // Reset send_tx_data
+            *send_tx_data2.lock().unwrap() = SendTxData::new();
+
+            // TODO: reset all send inputs
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_click);
+
+    // Subscribe to method for receiving tx status updates from drk plugin
+    let set_tx_status_sub = tx_status_layer.subscribe_method_call("set_tx_status").unwrap();
+    let tx_status_layer_clone = tx_status_layer.clone();
+    let sg_root = app.sg_root.clone();
+    let renderer = app.renderer.clone();
+    let send_tx_data2 = send_tx_data.clone();
+    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
+        while let Ok(mcall) = set_tx_status_sub.receive().await {
+            let atom = &mut renderer.make_guard(gfxtag!("set_tx_status"));
+
+            let mut cur = std::io::Cursor::new(mcall.data);
+            let tx_id = Option::<String>::decode(&mut cur).unwrap();
+            let status_text = Option::<String>::decode(&mut cur).unwrap();
+            let amount = Option::<String>::decode(&mut cur).unwrap();
+            let token_symbol = Option::<String>::decode(&mut cur).unwrap();
+            let recipient_str = Option::<String>::decode(&mut cur).unwrap();
+
+            // Store the tx_id
+            if let Some(id) = tx_id.as_ref() {
+                tx_status_layer_clone.set_property_str(atom, Role::App, "tx_id", id).unwrap();
+            }
+
+            // Update status text
+            if let Some(text) = status_text {
+                if let Some(status_node) = sg_root.lookup_node("/window/content/wallet_tx_status_layer/status") {
+                    status_node.set_property_str(atom, Role::App, "text", &text).unwrap();
+                }
+            }
+
+            // Update transaction info display
+            if let (Some(amount), Some(token_symbol), Some(recipient_str)) = (amount, token_symbol, recipient_str) {
+                if let Some(tx_info) = sg_root.lookup_node("/window/content/wallet_tx_status_layer/tx_info") {
+                    let tx_text = format!("Sending {} {} to {}", amount, token_symbol, recipient_str);
+                    tx_info.set_property_str(atom, Role::App, "text", tx_text).unwrap();
+                }
+            }
+        }
+    }));
+
+    // Subscribe to method for receiving built transaction
+    let set_built_tx_sub = tx_status_layer.subscribe_method_call("set_built_tx").unwrap();
+    let renderer = app.renderer.clone();
+    let send_tx_data2 = send_tx_data.clone();
+    app.tasks.lock().unwrap().push(app.ex.spawn(async move {
+        while let Ok(mcall) = set_built_tx_sub.receive().await {
+            let mut cur = std::io::Cursor::new(mcall.data);
+            let tx = Transaction::decode(&mut cur).unwrap();
+
+            send_tx_data2.lock().unwrap().tx = Some(tx);
+            send_tx_data2.lock().unwrap().tx_built = true;
+        }
+    }));
+
+    tx_status_layer
+}

+ 475 - 0
bin/app/src/app/schema/wallet/util.rs

@@ -0,0 +1,475 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use crate::{
+    app::App,
+    app::node::{create_button, create_text, create_vector_art},
+    app::schema::COLOR_SCHEME,
+    expr::{self, Compiler},
+    gfx::Renderer,
+    mesh::{COLOR_TEAL, COLOR_CYAN},
+    prop::{PropertyAtomicGuard, PropertyFloat32, Role},
+    scene::SceneNodePtr,
+    scene::Pimpl,
+    ui::{Button, Text, VectorArt, VectorShape},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{super::ColorScheme, data::*};
+
+/// Update positions for amount input wrapper and token symbol to center them together.
+pub fn update_amount_screen(
+    atom: &mut PropertyAtomicGuard,
+    amount_text: &str,
+    token_symbol: &str,
+    amount_wrapper_node: &SceneNodePtr,
+    amount_input_node: &SceneNodePtr,
+    token_node: &SceneNodePtr,
+    available_balance_node: Option<&SceneNodePtr>,
+) {
+    let mut cc = expr::Compiler::new();
+
+    let display_text = if amount_text.is_empty() { "0" } else { amount_text };
+    let char_count = display_text.chars().count() as f32;
+    let token_char_count = token_symbol.chars().count() as f32;
+
+    let text_width = char_count * AMOUNT_CHAR_WIDTH + 6.;
+    let token_width = token_char_count * AMOUNT_CHAR_WIDTH;
+    let total_width = text_width + AMOUNT_TOKEN_SPACING + token_width;
+    cc.add_const_f32("AMOUNT_TOTAL_WIDTH", total_width);
+    cc.add_const_f32("AMOUNT_WIDTH", text_width);
+    cc.add_const_f32("AMOUNT_TOKEN_SPACING", AMOUNT_TOKEN_SPACING);
+    cc.add_const_f32("AMOUNT_FONTSIZE", AMOUNT_FONTSIZE);
+
+    let wrapper_rect = amount_wrapper_node.get_property("rect").unwrap();
+    wrapper_rect.set_expr(atom, Role::App, 0, cc.compile("(w - AMOUNT_TOTAL_WIDTH) / 2").unwrap()).unwrap();
+
+    let width_code = cc.compile("AMOUNT_WIDTH").unwrap();
+    let amount_rect = amount_input_node.get_property("rect").unwrap();
+    amount_rect.set_expr(atom, Role::App, 2, width_code).unwrap();
+
+    // Reset scroll to prevent content from being cropped
+    if let Pimpl::Edit(edit) = amount_input_node.pimpl() {
+        edit.reset_scroll();
+    }
+
+    // Update token symbol position
+    let token_rect = token_node.get_property("rect").unwrap();
+    token_rect.set_expr(atom, Role::App, 0, cc.compile("(w - AMOUNT_TOTAL_WIDTH) / 2 + AMOUNT_TOKEN_SPACING + AMOUNT_WIDTH").unwrap()).unwrap();
+
+    // Set available balance
+    if let Some(available_balance_node) = available_balance_node {
+        let available_balance = get_balance(token_symbol);
+        available_balance_node.set_property_str(atom, Role::App, "text", format!("{available_balance} available")).unwrap();
+    }
+}
+
+/// Creates a title text node with separator line.
+/// Returns the text node after setup, linked to the layer
+pub async fn create_title(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    window_scale: &PropertyFloat32,
+    i18n_fish: &I18nBabelFish,
+    name: &str,
+    y: &mut f32,
+) -> SceneNodePtr {
+    let node = create_text(name);
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, *y + TITLE_PADDING).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", name).unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
+    layer.link(node.clone());
+
+    *y += TITLE_PADDING * 2. + TITLE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, layer, &format!("{}_separator", name), y).await;
+    node
+}
+
+/// Creates a subtitle text node with separator line (e.g., "Pick a token to send", "Recipient", "Address")
+/// Returns the text node after setup, linked to the layer
+pub async fn create_subtitle(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    window_scale: &PropertyFloat32,
+    i18n_fish: &I18nBabelFish,
+    name: &str,
+    text: &str,
+    y: &mut f32,
+) -> SceneNodePtr {
+    let node = create_text(name);
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, *y + PADDING_Y).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1000.).unwrap();
+    prop.set_f32(atom, Role::App, 3, TITLE_FONTSIZE).unwrap();
+    node.set_property_f32(atom, Role::App, "font_size", TITLE_FONTSIZE).unwrap();
+    node.set_property_str(atom, Role::App, "text", text).unwrap();
+    let prop = node.get_property("text_color").unwrap();
+    if COLOR_SCHEME == ColorScheme::DarkMode {
+        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let node = node.setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone())).await;
+    layer.link(node.clone());
+
+    *y += PADDING_Y * 2. + TITLE_FONTSIZE + 1.;
+
+    create_separator(&app.renderer, atom, layer, &format!("{}_separator", text), y).await;
+
+    node
+}
+
+/// Creates a background mesh with gradient box.
+pub async fn create_bg_mesh(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+) {
+    let node = create_vector_art(name);
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_gradient_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        [[0., 0., 0., 0.5], [0., 0., 0., 0.5], [0., 0., 0., 0.5], [0., 0., 0., 0.8]],
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(node);
+}
+
+/// Creates a header background with filled box and separator line at bottom.
+pub async fn create_header_bg(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+) {
+    let node = create_vector_art(name);
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, HEADER_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+
+    let (bg_color, sep_color) = match COLOR_SCHEME {
+        ColorScheme::DarkMode => ([0., 0.11, 0.11, 1.], [0.2, 0.2745, 0.2784, 1.]),
+        ColorScheme::PaperLight => ([1., 1., 1., 1.], [0., 0.6, 0.65, 1.]),
+    };
+
+    let mut shape = VectorShape::new();
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::const_f32(HEADER_HEIGHT),
+        bg_color,
+    );
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(HEADER_HEIGHT - 1.),
+        expr::load_var("w"),
+        expr::const_f32(HEADER_HEIGHT),
+        sep_color,
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(node);
+}
+
+/// Creates a separator line at the given y expression.
+/// Returns the separator node after setup, linked to the layer
+pub async fn create_separator_expr(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+    cc: &mut Compiler,
+    y_expr: &str,
+) -> SceneNodePtr {
+    let sep_node = create_vector_art(name);
+    let prop = sep_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    let code = cc.compile(y_expr).unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    sep_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::const_f32(1.),
+        [0.2, 0.2745, 0.2784, 1.],
+    );
+    let sep_node = sep_node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(sep_node.clone());
+    sep_node
+}
+
+/// Creates a separator line at the current y position and increments y.
+/// Returns the separator node after setup, linked to the layer
+pub async fn create_separator(
+    renderer: &Renderer,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+    y: &mut f32,
+) -> SceneNodePtr {
+    let sep_node = create_vector_art(name);
+    let prop = sep_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, *y).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    sep_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::const_f32(1.),
+        [0.2, 0.2745, 0.2784, 1.],
+    );
+    let sep_node = sep_node.setup(|me| VectorArt::new(me, shape, renderer.clone())).await;
+    layer.link(sep_node.clone());
+
+    *y += 1.;
+    sep_node
+}
+
+/// Creates a bottom button with teal outline and click handler.
+/// Returns the button node after setup, linked to the layer.
+/// The caller can use the returned button node to add custom click handlers.
+pub async fn create_bottom_button(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+    cc: &mut Compiler,
+    label_text: Option<&str>,
+    window_scale: &PropertyFloat32,
+    i18n_fish: &I18nBabelFish,
+) -> SceneNodePtr {
+    let node = create_vector_art(&format!("{}_bg", name));
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        COLOR_TEAL,
+    );
+    let node = node.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(node.clone());
+
+    // Button label text (if provided)
+    if let Some(text) = label_text {
+        let label_node = create_text(&format!("{}_label", name));
+        let prop = label_node.get_property("rect").unwrap();
+        prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+        let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT + BUTTON_HEIGHT / 2 - BUTTON_FONTSIZE / 1.8").unwrap();
+        prop.set_expr(atom, Role::App, 1, code).unwrap();
+        let code = cc.compile("w - PADDING_X * 2.").unwrap();
+        prop.set_expr(atom, Role::App, 2, code).unwrap();
+        prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+        label_node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
+        label_node.set_property_str(atom, Role::App, "text", text).unwrap();
+        label_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
+
+        let prop = label_node.get_property("text_color").unwrap();
+        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+        label_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+        let label_node = label_node
+            .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+            .await;
+        layer.link(label_node);
+    }
+
+    // Button
+    let node = create_button(name);
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+
+    let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
+    layer.link(node.clone());
+    node
+}
+
+/// Creates a bottom button with two states: valid (teal) and invalid (grey).
+/// Returns a tuple of (button_node, bg_valid_node, bg_invalid_node, label_node) for visibility control.
+pub async fn create_bottom_button_with_states(
+    app: &App,
+    atom: &mut PropertyAtomicGuard,
+    layer: &SceneNodePtr,
+    name: &str,
+    cc: &mut Compiler,
+    label_text: &str,
+    window_scale: &PropertyFloat32,
+    i18n_fish: &I18nBabelFish,
+    initial_valid: bool,
+) -> (SceneNodePtr, SceneNodePtr, SceneNodePtr, SceneNodePtr) {
+    // Button bg (teal outline - valid state)
+    let bg_valid = create_vector_art(&format!("{}_bg", name));
+    let prop = bg_valid.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    bg_valid.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    bg_valid.set_property_bool(atom, Role::App, "is_visible", initial_valid).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        COLOR_TEAL,
+    );
+    let bg_valid = bg_valid.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(bg_valid.clone());
+
+    // Button bg (grey outline - invalid state)
+    let bg_invalid = create_vector_art(&format!("{}_bg_grey", name));
+    let prop = bg_invalid.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    bg_invalid.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    bg_invalid.set_property_bool(atom, Role::App, "is_visible", !initial_valid).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_outline(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        1.,
+        [0.5, 0.5, 0.5, 1.],
+    );
+    let bg_invalid = bg_invalid.setup(|me| VectorArt::new(me, shape, app.renderer.clone())).await;
+    layer.link(bg_invalid.clone());
+
+    // Button label text
+    let label_node = create_text(&format!("{}_label", name));
+    let prop = label_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT + BUTTON_HEIGHT / 2 - BUTTON_FONTSIZE / 1.8").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+    label_node.set_property_f32(atom, Role::App, "font_size", BUTTON_FONTSIZE).unwrap();
+    label_node.set_property_str(atom, Role::App, "text", label_text).unwrap();
+    label_node.set_property_enum(atom, Role::App, "text_align", "center").unwrap();
+
+    let prop = label_node.get_property("text_color").unwrap();
+    if initial_valid {
+        prop.set_f32(atom, Role::App, 0, COLOR_CYAN[0]).unwrap();
+        prop.set_f32(atom, Role::App, 1, COLOR_CYAN[1]).unwrap();
+        prop.set_f32(atom, Role::App, 2, COLOR_CYAN[2]).unwrap();
+        prop.set_f32(atom, Role::App, 3, COLOR_CYAN[3]).unwrap();
+    } else {
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    }
+    label_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    let label_node = label_node
+        .setup(|me| Text::new(me, window_scale.clone(), app.renderer.clone(), i18n_fish.clone()))
+        .await;
+    layer.link(label_node.clone());
+
+    // Button
+    let btn = create_button(name);
+    btn.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = btn.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, PADDING_X).unwrap();
+    let code = cc.compile("h - PADDING_X - BUTTON_HEIGHT").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    let code = cc.compile("w - PADDING_X * 2.").unwrap();
+    prop.set_expr(atom, Role::App, 2, code).unwrap();
+    prop.set_f32(atom, Role::App, 3, BUTTON_HEIGHT).unwrap();
+
+    let btn = btn.setup(|me| Button::new(me, app.renderer.clone())).await;
+    layer.link(btn.clone());
+
+    (btn, bg_valid, bg_invalid, label_node)
+}